feat: require gate reset before post-stop reentry

This commit is contained in:
2026-07-17 19:30:40 +02:00
parent 1a6f82bf6d
commit 5155d00d9e
18 changed files with 577 additions and 278 deletions
+141 -41
View File
@@ -94,7 +94,6 @@ from app.services.scoring_service import (
compute_technical_from_arrays,
)
from app.services.sr_service import detect_gate_target_ladder, detect_sr_levels
from app.services.trade_policy import REENTRY_LOCKDOWN_SESSIONS
logger = logging.getLogger(__name__)
@@ -103,6 +102,7 @@ KEY_REPORT = "backtest_report"
WEEKLY_BACKTEST_CADENCE = "weekly"
DAILY_BACKTEST_CADENCE = "daily"
DEFAULT_BACKTEST_CADENCE = WEEKLY_BACKTEST_CADENCE
PRODUCTION_REENTRY_POLICY = "gate_reset"
BACKTEST_CADENCE_SESSIONS = {
WEEKLY_BACKTEST_CADENCE: 5,
DAILY_BACKTEST_CADENCE: 1,
@@ -1429,6 +1429,72 @@ LIVE_EXIT_MODE_TO_SIM = {
}
def _make_gate_reset_reentry_fn(
candidates: list[dict],
prices: dict[str, tuple],
*,
cadence: str,
qualified_fn: Callable[[dict], bool] | None = None,
ranking_key: str = PRODUCTION_PERCENTILE_KEY,
) -> Callable[[str, int, dict, Any], dict | None]:
"""Build the production post-stop gate-reset callback.
Missing candidates count as a gate failure only on dates on which that
ticker was actually evaluated at the selected replay cadence. This keeps a
weekly backtest from treating the four non-evaluation sessions between two
weekly observations as false gate exits.
"""
cadence = validate_backtest_cadence(cadence)
if qualified_fn is None:
def _default_qualified(candidate: dict) -> bool:
return bool(candidate.get("qualified"))
qualified_fn = _default_qualified
evaluation_ords: dict[str, set[int]] = {}
step_sessions = backtest_step_sessions(cadence)
for symbol, columns in prices.items():
ordinals = columns[0]
evaluation_ords[symbol] = {
int(ordinals[index])
for index in range(MIN_LOOKBACK - 1, len(ordinals) - HORIZON, step_sessions)
}
qualified_by_symbol_date: dict[tuple[str, int], dict] = {}
for candidate in candidates:
if candidate.get("direction") != "long" or not qualified_fn(candidate):
continue
key = (
str(candidate["symbol"]),
date.fromisoformat(str(candidate["date"])).toordinal(),
)
previous = qualified_by_symbol_date.get(key)
if previous is None or float(candidate.get(ranking_key) or 0.0) > float(
previous.get(ranking_key) or 0.0
):
qualified_by_symbol_date[key] = candidate
def _gate_reset(
symbol: str,
asof_ord: int,
state: dict,
_bar: Any,
) -> dict | None:
if asof_ord not in evaluation_ords.get(symbol, set()):
return None
candidate = qualified_by_symbol_date.get((symbol, asof_ord))
if candidate is None:
state["gate_went_unqualified"] = True
return None
if not state.get("gate_went_unqualified"):
return None
emitted = dict(candidate)
emitted["_reentry_reason"] = "gate_failed_then_requalified"
return emitted
return _gate_reset
def _simulate_portfolio(
candidates: list[dict],
prices: dict[str, tuple],
@@ -2325,35 +2391,35 @@ PORTFOLIO_MONITOR_STRATEGIES: tuple[dict, ...] = (
"exit_policy": "hold",
},
{
"strategy": "production_live_no_lockdown",
"label": "Live setup + 3x ATR trail (no re-entry lockdown)",
"strategy": "production_live_immediate",
"label": "Live setup + 3x ATR trail (immediate re-entry)",
"description": (
"Exact live activation, ordering, and Admin exit policy, with only "
"the post-stop re-entry lockdown disabled as the comparison baseline."
"the post-stop gate reset disabled as the comparison baseline."
),
"entry_variant": "residual80_highvol_blend80_20_fixed10",
"exit_policy": "atr_trail3",
"reentry_lockdown_sessions": 0,
"reentry_policy": "immediate",
"use_live_config": True,
"comparison_arm": "live_no_lockdown",
"comparison_arm": "live_immediate",
},
{
"strategy": PRODUCTION_PORTFOLIO_STRATEGY,
"label": "Production: residual/high-vol 80/20 + 3x ATR trail + 5-session lockdown",
"label": "Production: residual/high-vol 80/20 + 3x ATR trail + gate reset",
"description": (
"The live strategy: production activation gate and Admin exit policy "
"as currently configured, 80/20 residual/high-vol rank, and a "
"five-session re-entry lockdown after an initial-stop exit."
"as currently configured, 80/20 residual/high-vol rank, and re-entry "
"only after the gate fails and later qualifies again."
),
"entry_variant": "residual80_highvol_blend80_20_fixed10",
"exit_policy": "atr_trail3",
"reentry_lockdown_sessions": REENTRY_LOCKDOWN_SESSIONS,
"reentry_policy": PRODUCTION_REENTRY_POLICY,
# The production row replays what the platform actually does right now:
# the live qualification flag (runtime Admin activation settings) and the
# live Admin exit policy, instead of the frozen research-variant gate.
"use_live_config": True,
"is_production": True,
"comparison_arm": "live_lockdown_5",
"comparison_arm": "live_gate_reset",
},
)
@@ -2456,6 +2522,7 @@ def _min_rr_sweep(
threshold: float,
hold_days: int,
live_exit_policy: dict | None = None,
cadence: str = DEFAULT_BACKTEST_CADENCE,
) -> dict:
"""Portfolio economics of the production book at each R:R floor.
@@ -2472,9 +2539,7 @@ def _min_rr_sweep(
exit_policy = str(strategy["exit_policy"])
row_hold_days = hold_days
trail_multiplier = ATR_TRAIL_MULTIPLIER
reentry_lockdown_sessions = int(
strategy.get("reentry_lockdown_sessions", 0)
)
reentry_policy = str(strategy.get("reentry_policy", "immediate"))
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"
@@ -2514,7 +2579,19 @@ def _min_rr_sweep(
max_positions=int(entry_cfg["max_positions"]),
risk_per_trade=float(entry_cfg["risk_per_trade"]),
atr_trail_multiplier=trail_multiplier,
reentry_cooldown_sessions=reentry_lockdown_sessions,
post_stop_reentry_fn=(
_make_gate_reset_reentry_fn(
candidates,
prices,
cadence=cadence,
qualified_fn=qualified_fn,
ranking_key=str(
entry_cfg.get("ranking_key") or entry_cfg["percentile_key"]
),
)
if reentry_policy == "gate_reset"
else None
),
start_date=sweep_start,
)
if sim is None:
@@ -2539,7 +2616,7 @@ def _min_rr_sweep(
"live_qualified_setups": live_qualified,
"reproduces_production_gate": reproduces,
"exit_policy": exit_policy,
"reentry_lockdown_sessions": reentry_lockdown_sessions,
"reentry_policy": reentry_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,
@@ -2573,6 +2650,7 @@ def _holdout_evaluation(
hold_days: int,
split: date,
live_exit_policy: dict | None = None,
cadence: str = DEFAULT_BACKTEST_CADENCE,
) -> dict:
"""The production strategy simulated on entries BEFORE the split (train) and
on entries ON/AFTER it (test), as separate books.
@@ -2595,9 +2673,7 @@ def _holdout_evaluation(
exit_policy = str(strategy["exit_policy"])
row_hold_days = hold_days
trail_multiplier = ATR_TRAIL_MULTIPLIER
reentry_lockdown_sessions = int(
strategy.get("reentry_lockdown_sessions", 0)
)
reentry_policy = str(strategy.get("reentry_policy", "immediate"))
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"
@@ -2610,6 +2686,20 @@ def _holdout_evaluation(
None if strategy.get("use_live_config")
else lambda c, config=entry_cfg: _qualifies_strategy_variant(c, config)
)
ranking_key = str(
entry_cfg.get("ranking_key") or entry_cfg["percentile_key"]
)
post_stop_reentry_fn = (
_make_gate_reset_reentry_fn(
candidates,
prices,
cadence=cadence,
qualified_fn=qualified_fn,
ranking_key=ranking_key,
)
if reentry_policy == "gate_reset"
else None
)
rows: list[dict] = []
for window, start, end in (
@@ -2623,11 +2713,11 @@ def _holdout_evaluation(
exit_policy,
row_hold_days,
qualified_fn=qualified_fn,
ranking_key=str(entry_cfg.get("ranking_key") or entry_cfg["percentile_key"]),
ranking_key=ranking_key,
max_positions=int(entry_cfg["max_positions"]),
risk_per_trade=float(entry_cfg["risk_per_trade"]),
atr_trail_multiplier=trail_multiplier,
reentry_cooldown_sessions=reentry_lockdown_sessions,
post_stop_reentry_fn=post_stop_reentry_fn,
start_date=start,
end_date=end,
include_curve=True,
@@ -2639,7 +2729,7 @@ def _holdout_evaluation(
return {
"split_date": split.isoformat(),
"strategy": strategy["strategy"],
"reentry_lockdown_sessions": reentry_lockdown_sessions,
"reentry_policy": reentry_policy,
"rows": rows,
"note": (
"Train = entries before the split; test = entries on/after it. The two "
@@ -2655,6 +2745,7 @@ def _portfolio_monitor(
_spy_closes: dict[date, float] | None,
hold_days: int,
live_exit_policy: dict | None = None,
cadence: str = DEFAULT_BACKTEST_CADENCE,
) -> dict:
latest_ord = max((max(cols[0]) for cols in prices.values() if cols[0]), default=None)
rows: list[dict] = []
@@ -2672,9 +2763,7 @@ def _portfolio_monitor(
# policy. The overlay opts into this deliberately so only ordering
# changes relative to the production row.
use_live = bool(strategy.get("use_live_config"))
reentry_lockdown_sessions = int(
strategy.get("reentry_lockdown_sessions", 0)
)
reentry_policy = str(strategy.get("reentry_policy", "immediate"))
exit_policy = str(strategy["exit_policy"])
row_hold_days = hold_days
trail_multiplier = ATR_TRAIL_MULTIPLIER
@@ -2690,6 +2779,17 @@ def _portfolio_monitor(
None if use_live
else lambda c, config=entry_cfg: _qualifies_strategy_variant(c, config)
)
post_stop_reentry_fn = (
_make_gate_reset_reentry_fn(
candidates,
prices,
cadence=cadence,
qualified_fn=qualified_fn,
ranking_key=ranking_key,
)
if reentry_policy == "gate_reset"
else None
)
for lookback in PORTFOLIO_MONITOR_LOOKBACKS:
start = _lookback_start(latest_ord, lookback["days"])
sim = _simulate_portfolio(
@@ -2703,7 +2803,7 @@ def _portfolio_monitor(
max_positions=int(entry_cfg["max_positions"]),
risk_per_trade=float(entry_cfg["risk_per_trade"]),
atr_trail_multiplier=trail_multiplier,
reentry_cooldown_sessions=reentry_lockdown_sessions,
post_stop_reentry_fn=post_stop_reentry_fn,
start_date=start,
include_curve=True,
)
@@ -2719,7 +2819,7 @@ def _portfolio_monitor(
"ranking_key": ranking_key,
"exit_policy": exit_policy,
"live_exit_mode": live_exit_mode,
"reentry_lockdown_sessions": reentry_lockdown_sessions,
"reentry_policy": reentry_policy,
"lookback": lookback["lookback"],
"lookback_label": lookback["label"],
**sim,
@@ -2733,9 +2833,7 @@ def _portfolio_monitor(
"description": s["description"],
"is_production": bool(s.get("is_production")),
"comparison_arm": s.get("comparison_arm"),
"reentry_lockdown_sessions": int(
s.get("reentry_lockdown_sessions", 0)
),
"reentry_policy": str(s.get("reentry_policy", "immediate")),
}
for s in strategies
],
@@ -2749,7 +2847,7 @@ def _portfolio_monitor(
"The structural overlay appears only in its explicit research arm and changes "
"ordering, not production qualification. Local snapshot backtests remain the "
"research surface for broad variant sweeps. The production row applies the "
"same five-session post-initial-stop re-entry lockdown as the live setup list."
"same post-initial-stop gate-reset rule as the live setup list."
),
}
@@ -2758,7 +2856,7 @@ def _production_cadence_comparison(
monitor: dict | None,
cadence: str,
) -> dict | None:
"""Compact full-history live/no-lockdown vs live/5-session comparison."""
"""Compact full-history live/immediate vs live/gate-reset comparison."""
if not monitor:
return None
arms: list[dict] = []
@@ -2773,23 +2871,23 @@ def _production_cadence_comparison(
}
arm_name = (
"prod_live_setup"
if comparison_arm == "live_no_lockdown"
else "cooldown_5"
if comparison_arm == "live_immediate"
else "gate_reset"
)
compact["arm"] = f"{arm_name}_{cadence}"
compact["entry_cadence"] = cadence
arms.append(compact)
if not arms:
return None
arms.sort(key=lambda row: int(row.get("reentry_lockdown_sessions", 0)))
arms.sort(key=lambda row: row.get("reentry_policy") != "immediate")
return {
"entry_cadence": cadence,
"lookback": "all",
"arms": arms,
"note": (
"Both arms use the exact same live gate, ordering, Admin exit policy, "
"fees, and candidate cadence. Only the five-session post-stop "
"re-entry lockdown changes."
"fees, and candidate cadence. Only the post-stop gate-reset rule "
"changes."
),
}
@@ -3002,8 +3100,8 @@ def _build_recommendation(report: dict) -> dict:
if production_row is not None:
headline = (
"Production baseline: residual/high-vol 80/20 entry rank with a "
"3x ATR trailing exit, 30-trading-day max hold, and 5-session "
"re-entry lockdown after an initial stop."
"3x ATR trailing exit, 30-trading-day max hold, and re-entry only "
"after the gate fails and later qualifies again."
)
if (
production_row.get("cagr_pct") is not None
@@ -3357,17 +3455,19 @@ async def run_backtest(
portfolio_monitor_report = _portfolio_monitor(
candidates, price_columns, spy_closes, hold_horizon,
live_exit_policy=live_exit_policy,
cadence=cadence,
)
split = _holdout_split()
if split is not None:
holdout_report = _holdout_evaluation(
candidates, price_columns, spy_closes, hold_horizon, split,
live_exit_policy=live_exit_policy,
cadence=cadence,
)
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,
hold_horizon, live_exit_policy=live_exit_policy, cadence=cadence,
)
except Exception:
logger.exception("Portfolio simulation failed")
@@ -3390,7 +3490,7 @@ async def run_backtest(
"target_model": target_model,
"target_model_label": BACKTEST_TARGET_MODELS[target_model],
"is_production_target_model": target_model == PRODUCTION_GTL_TARGET_MODEL,
"production_reentry_lockdown_sessions": REENTRY_LOCKDOWN_SESSIONS,
"production_reentry_policy": PRODUCTION_REENTRY_POLICY,
},
"activation": activation,
"overall_qualified": _bucket_stats(qualified),