feat: add Phase A research matrix (vol target, fill, corr, SE/DSR)
Ship shared Sharpe SE/PSR diagnostics, next-open fill and equity-curve vol targeting in the portfolio simulator, re-derived fip_id, and a checkpointed offline matrix runner for Mac-side validation sweeps.
This commit is contained in:
@@ -920,6 +920,206 @@ class TestSimulatePortfolio:
|
||||
def test_nothing_qualified_returns_none(self):
|
||||
assert bt._simulate_portfolio([], {}, None, "hold", 30) is None
|
||||
|
||||
def test_next_open_fill_anchors_stop_to_fill_and_allows_same_day_stop(self):
|
||||
# Signal day ORD close=100; next day gaps to open=102, low pierces stop.
|
||||
# ATR on flat history is small; build a series with ATR ≈ 2.
|
||||
n = 40
|
||||
closes = [100.0] * n
|
||||
highs = [102.0] * n
|
||||
lows = [98.0] * n
|
||||
opens = [100.0] * n
|
||||
ords = list(range(self.ORD, self.ORD + n))
|
||||
# Signal on last warm-up bar; fill bar is the next session.
|
||||
signal_i = n - 2
|
||||
fill_i = n - 1
|
||||
opens[fill_i] = 102.0
|
||||
highs[fill_i] = 103.0
|
||||
lows[fill_i] = 90.0 # pierces fill − 1.5×ATR
|
||||
closes[fill_i] = 91.0
|
||||
prices = {
|
||||
"AAA": (ords, opens, highs, lows, closes, [1_000_000] * n)
|
||||
}
|
||||
cand = _sim_cand(
|
||||
"AAA",
|
||||
self.ORD + signal_i,
|
||||
entry=100.0,
|
||||
stop=95.0,
|
||||
target=130.0,
|
||||
)
|
||||
sim = bt._simulate_portfolio(
|
||||
[cand],
|
||||
prices,
|
||||
None,
|
||||
"hold",
|
||||
30,
|
||||
fill_mode=bt.FILL_MODE_NEXT_OPEN,
|
||||
cost_per_side=0.0,
|
||||
include_trades=True,
|
||||
)
|
||||
assert sim is not None
|
||||
assert sim["fill_mode"] == "next_open"
|
||||
assert sim["trades"] == 1
|
||||
trade = sim["trade_details"][0]
|
||||
assert trade["entry"] == pytest.approx(102.0)
|
||||
# Stop = 102 − 1.5×ATR; ATR on this series is 4 (high-low), so stop=96.
|
||||
# Same-day low 90 → stop fill at 96 (not open).
|
||||
assert trade["reason"] == "stop"
|
||||
assert trade["initial_stop"] == pytest.approx(102.0 - 1.5 * 4.0)
|
||||
assert "overnight_slippage" in sim
|
||||
assert sim["overnight_slippage"]["n"] == 1
|
||||
assert sim["overnight_slippage"]["mean_pct"] == pytest.approx(2.0)
|
||||
|
||||
def test_next_open_skips_when_fill_bar_missing(self):
|
||||
closes = [100.0, 101.0]
|
||||
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||
cand = _sim_cand("AAA", self.ORD + 1, entry=101.0, stop=96.0, target=120.0)
|
||||
sim = bt._simulate_portfolio(
|
||||
[cand], prices, None, "hold", 5, fill_mode=bt.FILL_MODE_NEXT_OPEN
|
||||
)
|
||||
# Signal on last bar → no t+1 open → no trade.
|
||||
assert sim is None or sim["trades"] == 0 or sim.get("skipped_missing_fill", 0) >= 0
|
||||
|
||||
def test_vol_target_reports_avg_scalar_near_one_on_flat_book(self):
|
||||
# Long enough equity path for 20d vol lookback; mild uptrend.
|
||||
closes = [100.0 + i * 0.1 for i in range(80)]
|
||||
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||
candidates = [
|
||||
_sim_cand(
|
||||
"AAA",
|
||||
self.ORD + 10 + k * 5,
|
||||
entry=closes[10 + k * 5],
|
||||
stop=closes[10 + k * 5] - 5.0,
|
||||
target=closes[10 + k * 5] + 20.0,
|
||||
)
|
||||
for k in range(8)
|
||||
]
|
||||
sim = bt._simulate_portfolio(
|
||||
candidates,
|
||||
prices,
|
||||
None,
|
||||
"hold",
|
||||
4,
|
||||
vol_target=0.20,
|
||||
vol_lookback=20,
|
||||
vol_clamp=(0.5, 1.5),
|
||||
cost_per_side=0.0,
|
||||
)
|
||||
assert sim is not None
|
||||
assert sim["vol_target"] == 0.20
|
||||
assert sim["avg_vol_scalar"] is not None
|
||||
assert 0.5 <= sim["avg_vol_scalar"] <= 1.5
|
||||
assert sim["sharpe_se"] is not None or sim["n_returns"] < 3
|
||||
|
||||
def test_corr_skip_blocks_highly_correlated_second_name(self):
|
||||
n = 150
|
||||
base = [100.0]
|
||||
for i in range(1, n):
|
||||
base.append(base[-1] * (1.0 + 0.001 * ((-1) ** i)))
|
||||
# BBB nearly identical path → corr ≈ 1.
|
||||
prices = {
|
||||
"AAA": _sim_prices(self.ORD, base),
|
||||
"BBB": _sim_prices(self.ORD, [c * 1.01 for c in base]),
|
||||
}
|
||||
day = self.ORD + 130
|
||||
candidates = [
|
||||
_sim_cand("AAA", day, entry=base[130], stop=base[130] - 5, target=base[130] + 20),
|
||||
_sim_cand(
|
||||
"BBB",
|
||||
day,
|
||||
entry=base[130] * 1.01,
|
||||
stop=base[130] * 1.01 - 5,
|
||||
target=base[130] * 1.01 + 20,
|
||||
mp=80.0,
|
||||
),
|
||||
]
|
||||
# Rank AAA first.
|
||||
candidates[0]["momentum_percentile"] = 99.0
|
||||
candidates[0]["activation_momentum_percentile"] = 99.0
|
||||
sim = bt._simulate_portfolio(
|
||||
candidates,
|
||||
prices,
|
||||
None,
|
||||
"hold",
|
||||
5,
|
||||
corr_max=0.5,
|
||||
corr_action="skip",
|
||||
corr_lookback=120,
|
||||
corr_min_overlap=60,
|
||||
cost_per_side=0.0,
|
||||
include_trades=True,
|
||||
)
|
||||
assert sim is not None
|
||||
assert sim["skipped_corr"] >= 1
|
||||
assert sim["trades"] == 1
|
||||
assert sim["trade_details"][0]["symbol"] == "AAA"
|
||||
|
||||
def test_calendar_truncates_after_last_signal_plus_hold(self):
|
||||
closes = [100.0 + i for i in range(100)]
|
||||
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||
cand = _sim_cand("AAA", self.ORD + 10, entry=110.0, stop=105.0, target=200.0)
|
||||
sim = bt._simulate_portfolio(
|
||||
[cand], prices, None, "hold", 5, cost_per_side=0.0, include_trades=True
|
||||
)
|
||||
assert sim is not None
|
||||
end = date.fromisoformat(sim["end_date"])
|
||||
entry = date.fromisoformat(sim["trade_details"][0]["entry_date"])
|
||||
# end should be near entry + hold (trading days ≈ calendar for synthetic series)
|
||||
assert (end - entry).days <= 10
|
||||
|
||||
|
||||
def test_fip_id_sign_convention_steady_climber_vs_jump():
|
||||
# Steady climber: many up days, continuous path → lower (more negative) ID.
|
||||
steady = [100.0]
|
||||
for _ in range(280):
|
||||
steady.append(steady[-1] * 1.002)
|
||||
# Jump then flat: one big up day, then zeros → higher ID (more discrete).
|
||||
jumpy = [100.0] * 252
|
||||
jumpy.append(100.0 * 1.5)
|
||||
jumpy.extend([100.0 * 1.5] * 40)
|
||||
i = 260
|
||||
id_steady = bt._fip_id(steady, i)
|
||||
id_jumpy = bt._fip_id(jumpy, i)
|
||||
assert id_steady is not None and id_jumpy is not None
|
||||
assert id_steady < 0 # continuous positive PRET → negative ID
|
||||
assert id_jumpy > id_steady
|
||||
|
||||
|
||||
def test_fip_id_emitted_in_signal_values():
|
||||
dates, closes, highs, _ = _signal_test_series(extra_return=0.0005)
|
||||
out = bt._signal_values(dates, closes, highs, 260)
|
||||
assert "fip_id" in out
|
||||
assert -1.0 <= out["fip_id"] <= 1.0
|
||||
|
||||
|
||||
def test_sharpe_diagnostics_psr_and_se():
|
||||
# Positive-drift daily returns → positive Sharpe, high PSR vs 0.
|
||||
rets = [0.001 + 0.0001 * (i % 5) for i in range(300)]
|
||||
diag = bt.sharpe_diagnostics(rets)
|
||||
assert diag["sharpe"] is not None and diag["sharpe"] > 0
|
||||
assert diag["sharpe_se"] is not None and diag["sharpe_se"] > 0
|
||||
assert diag["psr"] is not None and diag["psr"] > 0.9
|
||||
assert diag["n_returns"] == 300
|
||||
|
||||
|
||||
def test_deflated_sharpe_requires_multiple_trials():
|
||||
rets = [0.001 + 0.0005 * ((-1) ** i) for i in range(400)]
|
||||
diag = bt.sharpe_diagnostics(rets)
|
||||
assert diag["sharpe"] is not None and diag["sharpe_se"] is not None
|
||||
assert bt.deflated_sharpe_ratio(
|
||||
diag["sharpe"], diag["sharpe_se"], n_trials=1, n_returns=diag["n_returns"]
|
||||
) is None
|
||||
dsr = bt.deflated_sharpe_ratio(
|
||||
diag["sharpe"],
|
||||
diag["sharpe_se"],
|
||||
n_trials=20,
|
||||
n_returns=diag["n_returns"],
|
||||
return_skew=diag["return_skew"],
|
||||
return_kurtosis=diag["return_kurtosis"],
|
||||
)
|
||||
assert dsr is not None
|
||||
assert 0.0 <= dsr <= 1.0
|
||||
|
||||
|
||||
def test_bucket_stats_counts_and_expectancy():
|
||||
cands = [
|
||||
_cand(70, OUTCOME_TARGET_HIT, 3.0), # +3R win
|
||||
|
||||
Reference in New Issue
Block a user