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:
2026-07-18 15:04:44 +02:00
parent cad4b49e7c
commit 529343ce82
3 changed files with 1536 additions and 36 deletions
+200
View File
@@ -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