Sweep the R:R floor; fix a holdout metric artifact
Deploy / lint (push) Successful in 10s
Deploy / test (push) Successful in 1m9s
Deploy / deploy (push) Successful in 37s

min_rr = 2.0 was hand-set in Admin (2026-06-24) and never swept — the gate
ablation only tested the floor on-vs-off, never its level. It was the last
un-swept knob in the live gate.

Swept against portfolio Sharpe under the real exit, with a parity self-check
(reproduces_production_gate: the row at the live floor must rebuild production's
exact 1,089-setup qualified set — it does).

  min_rr   qualified   in-sample Sh/CAGR   OOS Sh/CAGR (entries >= 2024-07)
  0.0        6636      1.98 / 58.5%        2.02 / 66.2%
  1.2        3897      1.34 / 33.9%        1.12 / 28.8%
  1.5        3127      1.20 / 29.6%        1.12 / 28.8%
  1.75       1974      1.64 / 44.5%        1.15 / 27.4%
  2.0 (live) 1089      2.04 / 50.4%        2.78 / 73.3%
  2.25        577      1.64 / 31.8%        1.71 / 31.9%
  2.5         286      1.67 / 29.0%        0.68 /  8.7%

KEEP 2.0. It is the optimum in both windows, and a peak that reproduces in data
it was never fitted to is real evidence. But treat it as fragile: unlike the ATR
trail (a plateau), this is a spike with a trough beside it — +/-0.25 costs ~0.4
Sharpe in-sample and ~1.6 out-of-sample — and the curve is bimodal (floor-off is
good, 1.2-1.75 is bad, 2.0 is good). The hand-set value landed on the peak by
luck, not by tuning. Do not nudge it.

Worth knowing: turning the floor OFF entirely is the second-best row in both
windows, with substantially higher CAGR (58.5% / 66.2%) and more trades. If CAGR
ever outranks Sharpe here, "no R:R floor" is a live option — and it would sever
the gate's last dependency on the weak S/R detector.

Also fixes a metric artifact in the holdout harness. The train book's equity curve
ran to the end of the data while its entries stopped at the split, so it sat in
flat cash for two years and deflated its own CAGR/Sharpe (reported 0.95 / 14.6%;
actually 1.31 / 29.6%). _simulate_portfolio now truncates the calendar to
hold_days after the last entry when end_date is set — it only triggers on the
holdout train window, so no other number moves. The clear-air OOS verdict is
unaffected: it rests on the test row, whose entries and curve both start at the
split and were always clean. Both holdout reports regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 16:45:59 +02:00
co-authored by Claude Opus 4.8
parent 906d1db7d1
commit ea11efe3d1
7 changed files with 220674 additions and 4783 deletions
+160
View File
@@ -23,6 +23,7 @@ held neutral here — this calibrates the price/S-R machinery only.
from __future__ import annotations
import asyncio
import bisect
import json
import logging
import math
@@ -58,6 +59,7 @@ from app.services.outcome_service import (
from app.services.price_service import query_ohlcv
from app.services.qualification import (
HIGH_CONVICTION_ACTIONS,
MIN_TARGET_PROBABILITY,
_action_direction,
best_target_probability,
setup_qualifies,
@@ -1218,6 +1220,18 @@ def _simulate_portfolio(
if not calendar:
return None
if end_ord is not None:
# Holdout train book: entries stop at the split, but the calendar would
# otherwise still run to the last bar in the data — leaving the book in
# flat cash for the whole test period and deflating CAGR/Sharpe into
# something that looks like a result and isn't. Every open position
# resolves within `hold_days` bars of the last entry, so cut there.
last_entry_ord = max(entries_by_ord)
cut = bisect.bisect_left(calendar, last_entry_ord) + hold_days + 1
calendar = calendar[:cut]
if not calendar:
return None
cash = SIM_STARTING_CAPITAL
positions: dict[str, dict] = {}
curve: list[tuple[int, float]] = []
@@ -1944,6 +1958,145 @@ def _lookback_start(max_ord: int | None, days: int | None) -> date | None:
return date.fromordinal(max_ord - days)
# The R:R floor is the last un-swept knob in the live gate: `gate_ablation` shows
# removing it halves expectancy, but the *level* (prod: 2.0) was hand-set in Admin
# and never tuned. Sweep it against portfolio Sharpe, not per-setup expectancy.
MIN_RR_SWEEP_VALUES: tuple[float, ...] = (0.0, 1.2, 1.5, 1.75, 2.0, 2.25, 2.5, 3.0, 4.0)
def _min_rr_sweep_enabled() -> bool:
return os.getenv("BACKTEST_MIN_RR_SWEEP", "").strip().lower() in {"1", "true", "yes", "on"}
def _meets_core_at(cand: dict, activation: dict, min_rr: float) -> bool:
"""Recompute the live gate's ``meets_core`` for a candidate at a different
R:R floor, from stored fields, mirroring ``qualification.setup_qualifies``.
The live-R:R freshness check is skipped (it needs a current price, which
historical candidates don't carry) and the momentum percentile is applied
separately — exactly as ``core_config`` does when meets_core is first built.
"""
if cand["rr"] < min_rr:
return False
primary_prob = cand.get("primary_prob")
if primary_prob is None or float(primary_prob) < MIN_TARGET_PROBABILITY:
return False
if (cand["confidence"] or 0.0) < float(activation.get("min_confidence", 0.0)):
return False
if activation.get("exclude_neutral"):
action_direction = _action_direction(cand.get("action"))
if action_direction == "neutral" or action_direction != cand["direction"]:
return False
if activation.get("require_high_conviction") and (
(cand.get("action") or "") not in HIGH_CONVICTION_ACTIONS
):
return False
if activation.get("exclude_conflicts") and (cand.get("risk_level") or "") != "Low":
return False
return True
def _min_rr_sweep(
candidates: list[dict],
prices: dict[str, tuple],
_spy_closes: dict[date, float] | None,
activation: dict,
threshold: float,
hold_days: int,
live_exit_policy: dict | None = None,
) -> dict:
"""Portfolio economics of the production book at each R:R floor.
Graded on Sharpe/CAGR/DD under the real exit — not per-setup expectancy —
because that is the metric every other promotion decision used.
"""
strategy = next((s for s in PORTFOLIO_MONITOR_STRATEGIES if s.get("is_production")), None)
if strategy is None:
return {}
entry_cfg = _entry_variant_config(str(strategy["entry_variant"]))
if entry_cfg is None:
return {}
exit_policy = str(strategy["exit_policy"])
row_hold_days = hold_days
trail_multiplier = ATR_TRAIL_MULTIPLIER
if strategy.get("use_live_config") and live_exit_policy is not None:
exit_policy = LIVE_EXIT_MODE_TO_SIM.get(
str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3"
)
row_hold_days = int(live_exit_policy.get("hold_days", hold_days))
trail_multiplier = float(live_exit_policy.get("atr_multiplier", ATR_TRAIL_MULTIPLIER))
live_min_rr = float(activation.get("min_rr", 0.0))
live_qualified = sum(1 for c in candidates if c.get("qualified"))
# When a holdout split is set, sweep on the TEST window only. A threshold
# picked off a full-history curve is fitted to that curve; the only way to
# know whether the peak is real is to look for it in data the choice never saw.
sweep_start = _holdout_split()
rows: list[dict] = []
for min_rr in MIN_RR_SWEEP_VALUES:
def qualified_fn(c: dict, min_rr: float = min_rr) -> bool:
if not _meets_core_at(c, activation, min_rr):
return False
if threshold <= 0:
return True
if c["direction"] == "short":
return False
mp = c.get(PRODUCTION_PERCENTILE_KEY)
return mp is not None and mp >= threshold
n_qualified = sum(1 for c in candidates if qualified_fn(c))
sim = _simulate_portfolio(
candidates,
prices,
_spy_closes,
exit_policy,
row_hold_days,
qualified_fn=qualified_fn,
ranking_key=str(entry_cfg.get("ranking_key") or entry_cfg["percentile_key"]),
max_positions=int(entry_cfg["max_positions"]),
risk_per_trade=float(entry_cfg["risk_per_trade"]),
atr_trail_multiplier=trail_multiplier,
start_date=sweep_start,
)
if sim is None:
continue
sim.pop("equity_curve", None)
sim.pop("benchmark_curve", None)
rows.append({
"min_rr": min_rr,
"is_live": abs(min_rr - live_min_rr) < 1e-9,
"qualified_setups": n_qualified,
**sim,
})
# Parity self-check: at the live floor the reconstructed gate must reproduce
# the production qualified set exactly. If it doesn't, the sweep is measuring
# some other gate and every row below is worthless.
live_row = next((r for r in rows if r["is_live"]), None)
reproduces = live_row is not None and live_row["qualified_setups"] == live_qualified
return {
"live_min_rr": live_min_rr,
"live_qualified_setups": live_qualified,
"reproduces_production_gate": reproduces,
"exit_policy": exit_policy,
"entries_from": sweep_start.isoformat() if sweep_start else None,
"window": "out-of-sample (test)" if sweep_start else "full history (in-sample)",
"rows": rows,
"note": (
"Portfolio economics of the production book at each activation R:R floor "
"(all other gate floors held at their live values). `reproduces_production_gate` "
"must be true: the row at the live floor has to rebuild the exact qualified set, "
"otherwise the sweep is grading a gate we don't run. Set BACKTEST_HOLDOUT_SPLIT "
"to sweep on the held-out window instead — a threshold read off the full-history "
"curve is fitted to it."
),
}
def _holdout_split() -> date | None:
"""Train/test split date for out-of-sample validation, e.g.
BACKTEST_HOLDOUT_SPLIT=2024-07-01. Off by default."""
@@ -2625,6 +2778,7 @@ async def run_backtest(
exit_policy_rows: list[dict] = []
portfolio_monitor_report: dict | None = None
holdout_report: dict | None = None
min_rr_sweep_report: dict | None = None
try:
qual_symbols = sorted({
c["symbol"]
@@ -2679,6 +2833,11 @@ async def run_backtest(
candidates, price_columns, spy_closes, hold_horizon, split,
live_exit_policy=live_exit_policy,
)
if _min_rr_sweep_enabled():
min_rr_sweep_report = _min_rr_sweep(
candidates, price_columns, spy_closes, activation, current_min_pct,
hold_horizon, live_exit_policy=live_exit_policy,
)
except Exception:
logger.exception("Portfolio simulation failed")
@@ -2754,6 +2913,7 @@ async def run_backtest(
},
"portfolio_monitor": portfolio_monitor_report,
"holdout": holdout_report,
"min_rr_sweep": min_rr_sweep_report,
"signal_eval": _signal_evaluation(collected),
"signal_eval_note": (
"Cross-sectional rank-IC of price-only signals vs the forward "