feat: require gate reset before post-stop reentry
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user