refine strategy variant lab
This commit is contained in:
@@ -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.)
|
||||
|
||||
|
||||
@@ -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."
|
||||
),
|
||||
},
|
||||
|
||||
@@ -571,7 +571,7 @@ export function BacktestPanel() {
|
||||
<td className="num px-4 py-2.5 text-right text-gray-300">{row.cutoff.toFixed(0)}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-gray-300">{row.max_positions}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-gray-300">
|
||||
{row.risk_scale === 'spy_200' ? '0.5-1.0%' : `${row.risk_per_trade_pct.toFixed(1)}%`}
|
||||
{`${row.risk_per_trade_pct.toFixed(1)}%`}
|
||||
</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${rColor(row.cagr_pct)}`}>{fmtSignedPct(row.cagr_pct)}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-amber-400">−{row.max_drawdown_pct.toFixed(1)}%</td>
|
||||
|
||||
@@ -118,15 +118,15 @@ def test_assigns_raw_and_residual_percentiles_independently():
|
||||
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
|
||||
def test_strategy_variants_keep_only_current_research_candidates():
|
||||
variants = {cfg["variant"]: cfg for cfg in bt.STRATEGY_VARIANTS}
|
||||
|
||||
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
|
||||
assert "raw_80_regime_scaled" not in variants
|
||||
assert "residual_80_regime_scaled" not in variants
|
||||
assert "residual_90_fixed10" not in variants
|
||||
assert variants["raw_90_fixed15"]["max_positions"] == 15
|
||||
assert variants["residual_80_fixed20"]["max_positions"] == 20
|
||||
assert all(cfg["risk_scale"] is None for cfg in bt.STRATEGY_VARIANTS)
|
||||
|
||||
|
||||
def test_strategy_variant_sims_emit_fixed_variants_without_mutating_qualified(monkeypatch):
|
||||
@@ -169,7 +169,7 @@ def test_strategy_variant_sims_emit_fixed_variants_without_mutating_qualified(mo
|
||||
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 any(call["max_positions"] == 20 for call in calls)
|
||||
assert cands[0]["qualified"] is False
|
||||
|
||||
|
||||
@@ -180,10 +180,12 @@ def test_build_research_recommendation_applies_promotion_rules():
|
||||
"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": "residual_80_fixed20", "label": "Residual 20", "sharpe": 1.40,
|
||||
"max_drawdown_pct": 20.5, "cagr_pct": 32.0, "risk_scale": None},
|
||||
{"variant": "raw_90_fixed10", "label": "Cutoff 90", "sharpe": 1.25,
|
||||
"max_drawdown_pct": 19.0, "cagr_pct": 28.0},
|
||||
{"variant": "raw_90_fixed15", "label": "Cutoff 90 / 15", "sharpe": 1.30,
|
||||
"max_drawdown_pct": 18.0, "cagr_pct": 29.0},
|
||||
]},
|
||||
}
|
||||
|
||||
@@ -191,8 +193,9 @@ def test_build_research_recommendation_applies_promotion_rules():
|
||||
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 "Residual 20" in by_topic["residual_momentum"]["text"]
|
||||
assert by_topic["cutoff_90"]["candidate"] is True
|
||||
assert "Cutoff 90 / 15" in by_topic["cutoff_90"]["text"]
|
||||
|
||||
|
||||
class TestStopFillR:
|
||||
|
||||
Reference in New Issue
Block a user