feat: add strategy variant lab and signal context snapshots
Backtest report now includes research-only hold-to-horizon portfolio variants comparing raw vs residual 12-1 momentum, cutoff 80 vs 90, max 10 vs 15 positions, and SPY-200 risk scaling. A dynamic research recommendation panel flags residual momentum, cutoff 90, or regime scaling only when transparent promotion rules pass. Adds signal_context_snapshots with migration 016 and captures one point-in-time context row per newly generated TradeSetup: setup fields, composite/dimensions, latest sentiment, latest fundamentals, and strategy_version=momentum_12_1_rr_time_v1. This is forward-only; no historical sentiment/fundamental backfill is attempted. No live gate, paper-trade exit, or production ranking behavior changes. Verification: 458 backend tests pass, ruff check app/ clean, frontend npm run build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -100,6 +100,101 @@ def test_residual_momentum_removes_market_beta_but_keeps_specific_drift():
|
||||
assert drift["mom_12_1_resid"] > pure["mom_12_1_resid"] + 0.12
|
||||
|
||||
|
||||
def test_assigns_raw_and_residual_percentiles_independently():
|
||||
cands = [
|
||||
{"iso_week": (2026, 1), "momentum": 0.10, "residual_momentum": 0.30},
|
||||
{"iso_week": (2026, 1), "momentum": 0.30, "residual_momentum": 0.10},
|
||||
{"iso_week": (2026, 1), "momentum": 0.20, "residual_momentum": 0.20},
|
||||
]
|
||||
|
||||
bt._assign_momentum_percentiles(cands)
|
||||
bt._assign_residual_momentum_percentiles(cands)
|
||||
|
||||
by_raw = {c["momentum"]: c["momentum_percentile"] for c in cands}
|
||||
by_resid = {c["residual_momentum"]: c["residual_momentum_percentile"] for c in cands}
|
||||
assert by_raw[0.30] == 100.0
|
||||
assert by_raw[0.10] == 0.0
|
||||
assert by_resid[0.30] == 100.0
|
||||
assert by_resid[0.10] == 0.0
|
||||
|
||||
|
||||
def test_spy_200_risk_scale_halves_risk_below_sma():
|
||||
base = date(2025, 1, 1)
|
||||
closes = {base + timedelta(days=i): 100.0 for i in range(210)}
|
||||
closes[base + timedelta(days=210)] = 80.0
|
||||
|
||||
scale = bt._spy_200_risk_scale(closes)
|
||||
|
||||
assert scale[(base + timedelta(days=199)).toordinal()] == 1.0
|
||||
assert scale[(base + timedelta(days=210)).toordinal()] == 0.5
|
||||
|
||||
|
||||
def test_strategy_variant_sims_emit_fixed_variants_without_mutating_qualified(monkeypatch):
|
||||
cands = [{
|
||||
"qualified": False,
|
||||
"meets_core": True,
|
||||
"direction": "long",
|
||||
"momentum_percentile": 90.0,
|
||||
"residual_momentum_percentile": 91.0,
|
||||
}]
|
||||
calls = []
|
||||
|
||||
def fake_sim(candidates, prices, spy_closes, exit_policy, hold_days, **kwargs):
|
||||
calls.append({"exit_policy": exit_policy, "hold_days": hold_days, **kwargs})
|
||||
return {
|
||||
"starting_capital": bt.SIM_STARTING_CAPITAL,
|
||||
"final_equity": 11_000.0,
|
||||
"total_return_pct": 10.0,
|
||||
"cagr_pct": 9.0,
|
||||
"max_drawdown_pct": 5.0,
|
||||
"sharpe": 1.1,
|
||||
"trades": 1,
|
||||
"win_rate": 100.0,
|
||||
"avg_trade_pnl": 100.0,
|
||||
"best_trade_r": 1.0,
|
||||
"worst_trade_r": 1.0,
|
||||
"best_trade_pnl": 100.0,
|
||||
"worst_trade_pnl": 100.0,
|
||||
"avg_hold_days": 30.0,
|
||||
"skipped_book_full": 0,
|
||||
"spy_return_pct": 1.0,
|
||||
"yearly_returns": [],
|
||||
"start_date": "2026-01-01",
|
||||
"end_date": "2026-02-01",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(bt, "_simulate_portfolio", fake_sim)
|
||||
rows = bt._strategy_variant_sims(cands, {}, {}, 30)
|
||||
|
||||
assert [r["variant"] for r in rows] == [cfg["variant"] for cfg in bt.STRATEGY_VARIANTS]
|
||||
assert all(call["exit_policy"] == "hold" for call in calls)
|
||||
assert any(call["ranking_key"] == "residual_momentum_percentile" for call in calls)
|
||||
assert any(call["max_positions"] == 15 for call in calls)
|
||||
assert cands[0]["qualified"] is False
|
||||
|
||||
|
||||
def test_build_research_recommendation_applies_promotion_rules():
|
||||
report = {
|
||||
"strategy_variants": {"variants": [
|
||||
{"variant": "production_raw_80_fixed10", "label": "Base", "sharpe": 1.20,
|
||||
"max_drawdown_pct": 20.0, "cagr_pct": 30.0},
|
||||
{"variant": "residual_80_fixed10", "label": "Residual", "sharpe": 1.35,
|
||||
"max_drawdown_pct": 21.0, "cagr_pct": 31.0, "risk_scale": None},
|
||||
{"variant": "raw_80_regime_scaled", "label": "Scaled", "sharpe": 1.1,
|
||||
"max_drawdown_pct": 15.0, "cagr_pct": 27.0},
|
||||
{"variant": "raw_90_fixed10", "label": "Cutoff 90", "sharpe": 1.25,
|
||||
"max_drawdown_pct": 19.0, "cagr_pct": 28.0},
|
||||
]},
|
||||
}
|
||||
|
||||
rec = bt._build_research_recommendation(report)
|
||||
by_topic = {item["topic"]: item for item in rec["items"]}
|
||||
|
||||
assert by_topic["residual_momentum"]["candidate"] is True
|
||||
assert by_topic["regime_scaling"]["candidate"] is True
|
||||
assert by_topic["cutoff_90"]["candidate"] is True
|
||||
|
||||
|
||||
class TestStopFillR:
|
||||
def test_intraday_fill_at_stop(self):
|
||||
assert bt._stop_fill_r("long", 100.0, 95.0, _bar(101, 94, 96)) == pytest.approx(-1.0)
|
||||
@@ -491,7 +586,8 @@ async def test_run_backtest_smoke(session):
|
||||
assert isinstance(report["candidates"], int)
|
||||
for key in (
|
||||
"overall_qualified", "overall_all", "by_direction", "sweep",
|
||||
"gate_ablation", "time_exit_sweep", "portfolio_sim", "recommendation",
|
||||
"gate_ablation", "time_exit_sweep", "portfolio_sim", "strategy_variants",
|
||||
"recommendation", "research_recommendation",
|
||||
):
|
||||
assert key in report
|
||||
# the oscillating series should yield at least some resolved setups
|
||||
@@ -516,6 +612,7 @@ async def test_run_backtest_smoke(session):
|
||||
assert "portfolio_sim" in report
|
||||
assert isinstance(report["portfolio_sim"]["policies"], list)
|
||||
assert report["portfolio_sim"]["params"]["max_positions"] == bt.SIM_MAX_POSITIONS
|
||||
assert isinstance(report["strategy_variants"]["variants"], list)
|
||||
|
||||
# sweep: lowering the momentum-percentile cutoff can only add qualifiers
|
||||
sweep = sorted(report["sweep"], key=lambda r: r["min_momentum_percentile"], reverse=True)
|
||||
|
||||
Reference in New Issue
Block a user