diff --git a/README.md b/README.md index ee3fa12..0295196 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m ### Highest-value next experiments (in order) 1. **Residual momentum portfolio variants** — compare raw vs beta-adjusted 12-1 momentum in the strategy-variant simulator before changing production ranking. -2. **Regime/risk scaling** — test whether SPY-200 risk scaling reduces drawdown enough to justify lower exposure. +2. **Capacity checks for promising variants** — compare max-position variants for raw 90 and residual 80 so a good-looking row is not just a book-slot artifact. 3. **Signal context snapshots** — accumulate point-in-time composite/sentiment/fundamental context for every new setup so the discretionary overlay can be tested forward-only. 4. **More breadth, not more history** — widening the ranked universe (e.g. `nasdaq_all`) strengthens each week's cross-section and the IC t-stat, even if only the top slice is traded. (Deeper history was considered and declined.) diff --git a/app/services/backtest_service.py b/app/services/backtest_service.py index 277413c..07c8d5f 100644 --- a/app/services/backtest_service.py +++ b/app/services/backtest_service.py @@ -968,7 +968,6 @@ def _simulate_portfolio( ranking_key: str = "momentum_percentile", max_positions: int = SIM_MAX_POSITIONS, risk_per_trade: float = SIM_RISK_PER_TRADE, - risk_scale_by_ord: dict[int, float] | None = None, ) -> dict | None: """Replay the qualified setups as ONE capital-constrained book and report portfolio economics from the daily equity curve (return, CAGR, drawdown, @@ -1077,12 +1076,8 @@ def _simulate_portfolio( risk_ps = entry - stop if risk_ps <= 0 or entry <= 0: continue - risk_scale = (risk_scale_by_ord or {}).get(o, 1.0) - effective_risk = risk_per_trade * risk_scale - if effective_risk <= 0: - continue shares = min( - (equity * effective_risk) / risk_ps, + (equity * risk_per_trade) / risk_ps, (equity * SIM_NOTIONAL_CAP) / entry, max(cash, 0.0) / (entry * (1.0 + COST_PER_SIDE)), ) @@ -1211,6 +1206,15 @@ STRATEGY_VARIANTS: tuple[dict, ...] = ( "risk_per_trade": 0.01, "risk_scale": None, }, + { + "variant": "raw_90_fixed15", + "label": "Raw 90 / max 15", + "percentile_key": "momentum_percentile", + "cutoff": 90.0, + "max_positions": 15, + "risk_per_trade": 0.01, + "risk_scale": None, + }, { "variant": "residual_80_fixed10", "label": "Residual 80 / max 10", @@ -1221,11 +1225,20 @@ STRATEGY_VARIANTS: tuple[dict, ...] = ( "risk_scale": None, }, { - "variant": "residual_90_fixed10", - "label": "Residual 90 / max 10", + "variant": "residual_80_fixed15", + "label": "Residual 80 / max 15", "percentile_key": "residual_momentum_percentile", - "cutoff": 90.0, - "max_positions": 10, + "cutoff": 80.0, + "max_positions": 15, + "risk_per_trade": 0.01, + "risk_scale": None, + }, + { + "variant": "residual_80_fixed20", + "label": "Residual 80 / max 20", + "percentile_key": "residual_momentum_percentile", + "cutoff": 80.0, + "max_positions": 20, "risk_per_trade": 0.01, "risk_scale": None, }, @@ -1238,24 +1251,6 @@ STRATEGY_VARIANTS: tuple[dict, ...] = ( "risk_per_trade": 0.01, "risk_scale": None, }, - { - "variant": "raw_80_regime_scaled", - "label": "Raw 80 / SPY-200 risk scale", - "percentile_key": "momentum_percentile", - "cutoff": 80.0, - "max_positions": 10, - "risk_per_trade": 0.01, - "risk_scale": "spy_200", - }, - { - "variant": "residual_80_regime_scaled", - "label": "Residual 80 / SPY-200 risk scale", - "percentile_key": "residual_momentum_percentile", - "cutoff": 80.0, - "max_positions": 10, - "risk_per_trade": 0.01, - "risk_scale": "spy_200", - }, ) @@ -1272,33 +1267,14 @@ def _qualifies_by_percentile(cand: dict, percentile_key: str, threshold: float) return pct is not None and pct >= threshold -def _spy_200_risk_scale(spy_closes: dict[date, float] | None) -> dict[int, float]: - """Entry-date risk scale: 0.5 when SPY closes below its 200-day SMA, else 1.0. - Missing/short benchmark history returns an empty map, which the simulator - treats as unscaled 1.0 risk.""" - if not spy_closes: - return {} - rows = sorted((d, c) for d, c in spy_closes.items() if c and c > 0) - out: dict[int, float] = {} - closes: list[float] = [] - for d, close in rows: - closes.append(float(close)) - if len(closes) < 200: - continue - sma = sum(closes[-200:]) / 200.0 - out[d.toordinal()] = 0.5 if close < sma else 1.0 - return out - - def _strategy_variant_sims( candidates: list[dict], prices: dict[str, tuple], - spy_closes: dict[date, float] | None, + _spy_closes: dict[date, float] | None, hold_days: int, ) -> list[dict]: - """Research-only portfolio variants for comparing rank signals, cutoff, book - capacity, and simple SPY-200 risk scaling. Live qualification is untouched.""" - risk_scales = {"spy_200": _spy_200_risk_scale(spy_closes)} + """Research-only portfolio variants for comparing rank signals, cutoff and + book capacity. Live qualification is untouched.""" rows: list[dict] = [] for cfg in STRATEGY_VARIANTS: percentile_key = str(cfg["percentile_key"]) @@ -1306,14 +1282,13 @@ def _strategy_variant_sims( sim = _simulate_portfolio( candidates, prices, - spy_closes, + _spy_closes, "hold", hold_days, qualified_fn=lambda c, pk=percentile_key, th=cutoff: _qualifies_by_percentile(c, pk, th), ranking_key=percentile_key, max_positions=int(cfg["max_positions"]), risk_per_trade=float(cfg["risk_per_trade"]), - risk_scale_by_ord=risk_scales.get(cfg["risk_scale"]), ) if sim is None: continue @@ -1358,7 +1333,7 @@ def _build_research_recommendation(report: dict) -> dict: residuals = [ v for key, v in variants.items() - if key.startswith("residual_") and v.get("risk_scale") is None + if key.startswith("residual_80_") and v.get("risk_scale") is None ] residual = max(residuals, key=lambda v: v.get("sharpe") or -999, default=None) if ( @@ -1378,32 +1353,15 @@ def _build_research_recommendation(report: dict) -> dict: ), }) - raw_regime = variants.get("raw_80_regime_scaled") - if ( - raw_regime and base_dd is not None and base_cagr is not None - and raw_regime.get("cagr_pct") is not None - and raw_regime.get("max_drawdown_pct") is not None - ): - dd_reduction = (base_dd - raw_regime["max_drawdown_pct"]) / base_dd if base_dd > 0 else None - cagr_loss = _pct_loss(base_cagr, raw_regime.get("cagr_pct")) - candidate = ( - dd_reduction is not None and cagr_loss is not None - and dd_reduction >= 0.20 and cagr_loss <= 0.15 - ) - items.append({ - "topic": "regime_scaling", - "candidate": candidate, - "text": ( - f"SPY-200 risk scaling {'is a promotion candidate' if candidate else 'stays research-only'}: " - f"drawdown {raw_regime['max_drawdown_pct']:.1f}% vs {base_dd:.1f}%, " - f"CAGR {raw_regime.get('cagr_pct'):+.1f}% vs {base_cagr:+.1f}%." - ), - }) - - raw_90 = variants.get("raw_90_fixed10") + raw_90s = [ + v for key, v in variants.items() + if key.startswith("raw_90_") and v.get("risk_scale") is None + ] + raw_90 = max(raw_90s, key=lambda v: v.get("sharpe") or -999, default=None) if ( raw_90 and base_sharpe is not None and base_dd is not None and base_cagr is not None and raw_90.get("sharpe") is not None and raw_90.get("cagr_pct") is not None + and raw_90.get("max_drawdown_pct") is not None ): cagr_loss = _pct_loss(base_cagr, raw_90.get("cagr_pct")) raw_90_sharpe = raw_90.get("sharpe") @@ -1418,7 +1376,7 @@ def _build_research_recommendation(report: dict) -> dict: "candidate": candidate, "text": ( f"Cutoff 90 {'is a promotion candidate' if candidate else 'stays research-only'}: " - f"Sharpe {raw_90_sharpe:.2f} vs {base_sharpe:.2f}, " + f"{raw_90['label']} Sharpe {raw_90_sharpe:.2f} vs {base_sharpe:.2f}, " f"drawdown {raw_90['max_drawdown_pct']:.1f}% vs {base_dd:.1f}%, " f"CAGR {raw_90.get('cagr_pct'):+.1f}% vs {base_cagr:+.1f}%." ), @@ -1822,8 +1780,8 @@ async def run_backtest( "variants": strategy_variant_rows, "note": ( "Research-only hold-to-horizon portfolio variants. These compare " - "raw vs residual momentum ranking, cutoff 80 vs 90, max 10 vs 15 " - "positions, and SPY-200 risk scaling. They do not change live " + "raw vs residual momentum ranking, cutoff 80 vs 90, and max 10/15/20 " + "position capacity. They do not change live " "qualification or paper-trade behavior." ), }, diff --git a/frontend/src/components/signals/BacktestPanel.tsx b/frontend/src/components/signals/BacktestPanel.tsx index 465c7cf..4594448 100644 --- a/frontend/src/components/signals/BacktestPanel.tsx +++ b/frontend/src/components/signals/BacktestPanel.tsx @@ -571,7 +571,7 @@ export function BacktestPanel() {