diff --git a/app/services/backtest_service.py b/app/services/backtest_service.py index 6e73d72..71331ab 100644 --- a/app/services/backtest_service.py +++ b/app/services/backtest_service.py @@ -394,6 +394,10 @@ def _replay_ticker(symbol: str, records: list, config: dict, activation: dict) - "best_prob": s["best_prob"], "momentum": s["momentum"], "meets_core": s["meets_core"], + # Gate fields the ablation recomputes floors from — without them + # every candidate looks NEUTRAL and the ablation rows collapse. + "action": s["action"], + "risk_level": s["risk_level"], "outcome": outcome, "target_hit": target_hit, "realized_r": realized_r, diff --git a/tests/unit/test_backtest_service.py b/tests/unit/test_backtest_service.py index 581970c..62eaa4b 100644 --- a/tests/unit/test_backtest_service.py +++ b/tests/unit/test_backtest_service.py @@ -313,6 +313,33 @@ def test_window_setups_too_short_returns_empty(): assert bt._window_setups([], {}, {}) == [] +def test_replay_ticker_candidates_carry_gate_fields(): + """The ablation recomputes floors from candidate fields — a candidate missing + action/risk_level silently zeroes the ablation rows (July 2026 regression).""" + from app.services.admin_service import ACTIVATION_DEFAULTS + from app.services.recommendation_service import DEFAULT_RECOMMENDATION_CONFIG + + base = date(2025, 1, 1) + bars = [] + for i in range(160): + close = 100.0 + 8.0 * math.sin(i / 6.0) + bars.append(SimpleNamespace( + date=base + timedelta(days=i), + open=close, + high=close + 1.5, + low=close - 1.5, + close=close, + volume=1_000_000 + (i % 5) * 1000, + )) + cands = bt._replay_ticker( + "OSC", bars, dict(DEFAULT_RECOMMENDATION_CONFIG), dict(ACTIVATION_DEFAULTS) + ) + assert cands, "expected the oscillating series to produce candidates" + for c in cands: + assert c.get("action") is not None + assert "risk_level" in c + + async def _seed_oscillating_ticker(session, symbol: str, n: int = 160) -> None: t = Ticker(symbol=symbol) session.add(t)