feat: add strategy variant lab and signal context snapshots
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 1m1s
Deploy / deploy (push) Successful in 33s

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:
2026-07-02 16:25:04 +02:00
parent 13374087db
commit 80b4113280
10 changed files with 885 additions and 28 deletions
+98 -1
View File
@@ -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)
+110
View File
@@ -0,0 +1,110 @@
"""Tests for point-in-time signal context snapshots."""
from __future__ import annotations
import json
from datetime import date, datetime, timezone
import pytest
from app.models.fundamental import FundamentalData
from app.models.score import CompositeScore, DimensionScore
from app.models.sentiment import SentimentScore
from app.models.signal_context_snapshot import SignalContextSnapshot
from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup
from app.services import rr_scanner_service as rr
from tests.conftest import _test_session_factory # type: ignore
@pytest.fixture
async def session():
async with _test_session_factory() as s:
yield s
async def test_create_signal_context_snapshot_captures_latest_context(session):
now = datetime(2026, 7, 2, 12, tzinfo=timezone.utc)
ticker = Ticker(symbol="CTX")
session.add(ticker)
await session.flush()
session.add_all([
DimensionScore(
ticker_id=ticker.id,
dimension="technical",
score=71.0,
is_stale=False,
computed_at=now,
),
DimensionScore(
ticker_id=ticker.id,
dimension="momentum",
score=82.0,
is_stale=False,
computed_at=now,
),
CompositeScore(
ticker_id=ticker.id,
score=76.5,
is_stale=False,
weights_json='{"technical": 0.25}',
computed_at=now,
),
SentimentScore(
ticker_id=ticker.id,
classification="BULLISH",
confidence=78,
source="test",
timestamp=now,
reasoning="",
citations_json="[]",
recommendation="BUY",
),
FundamentalData(
ticker_id=ticker.id,
pe_ratio=25.0,
revenue_growth=0.18,
earnings_surprise=0.05,
market_cap=1_000_000_000.0,
next_earnings_date=date(2026, 8, 1),
fetched_at=now,
unavailable_fields_json="{}",
),
])
setup = TradeSetup(
ticker_id=ticker.id,
direction="long",
entry_price=100.0,
stop_loss=95.0,
target=120.0,
rr_ratio=4.0,
composite_score=76.5,
detected_at=now,
confidence_score=64.0,
momentum_percentile=88.0,
recommended_action="LONG_HIGH",
risk_level="Low",
)
session.add(setup)
await session.flush()
await rr._create_signal_context_snapshots(session, [setup])
await session.commit()
row = (await session.get(SignalContextSnapshot, 1))
assert row is not None
assert row.trade_setup_id == setup.id
assert row.strategy_version == rr.STRATEGY_VERSION
assert row.momentum_percentile == 88.0
score = json.loads(row.score_context_json)
sentiment = json.loads(row.sentiment_context_json)
fundamental = json.loads(row.fundamental_context_json)
assert score["composite_score"] == 76.5
assert score["dimensions"]["technical"]["score"] == 71.0
assert sentiment["classification"] == "BULLISH"
assert sentiment["confidence"] == 78
assert fundamental["pe_ratio"] == 25.0
assert fundamental["next_earnings_date"] == "2026-08-01"