feat: require gate reset before post-stop reentry
This commit is contained in:
@@ -679,11 +679,51 @@ class TestSimulatePortfolio:
|
||||
assert sim["trades"] == 1
|
||||
assert callback_dates == [self.ORD + 1]
|
||||
|
||||
def test_production_monitor_applies_live_reentry_lockdown(self, monkeypatch):
|
||||
def test_gate_reset_waits_for_failed_evaluation_then_requalification(self):
|
||||
closes = [100.0] * 95
|
||||
entry_ord = self.ORD + bt.MIN_LOOKBACK - 1
|
||||
stop_ord = entry_ord + 1
|
||||
reentry_ord = entry_ord + 3
|
||||
closes[bt.MIN_LOOKBACK] = 94.0
|
||||
closes[bt.MIN_LOOKBACK + 1] = 95.0
|
||||
closes[bt.MIN_LOOKBACK + 2] = 96.0
|
||||
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||
candidates = [
|
||||
_sim_cand("AAA", entry_ord, entry=100.0, stop=95.0, target=120.0),
|
||||
# Still qualified on the stop day: this must not unlock re-entry.
|
||||
_sim_cand("AAA", stop_ord, entry=94.0, stop=89.0, target=110.0),
|
||||
# No candidate on the intervening session means the daily gate
|
||||
# failed. A fresh qualification on the next session may re-enter.
|
||||
_sim_cand("AAA", reentry_ord, entry=96.0, stop=90.0, target=115.0),
|
||||
]
|
||||
gate_reset = bt._make_gate_reset_reentry_fn(
|
||||
candidates,
|
||||
prices,
|
||||
cadence="daily",
|
||||
)
|
||||
|
||||
sim = bt._simulate_portfolio(
|
||||
candidates,
|
||||
prices,
|
||||
None,
|
||||
"hold",
|
||||
30,
|
||||
post_stop_reentry_fn=gate_reset,
|
||||
include_trades=True,
|
||||
)
|
||||
|
||||
assert sim is not None
|
||||
assert sim["post_stop_reentries"] == 1
|
||||
assert sim["trade_details"][1]["entry_date"] == date.fromordinal(
|
||||
reentry_ord
|
||||
).isoformat()
|
||||
assert sim["reentry_events"][0]["wait_sessions"] == 2
|
||||
|
||||
def test_production_monitor_applies_live_gate_reset(self, monkeypatch):
|
||||
def fake_simulator(*_args, **kwargs):
|
||||
return {
|
||||
"trades": 0,
|
||||
"applied_reentry_lockdown": kwargs.get("reentry_cooldown_sessions", 0),
|
||||
"applied_gate_reset": kwargs.get("post_stop_reentry_fn") is not None,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(bt, "_simulate_portfolio", fake_simulator)
|
||||
@@ -694,37 +734,38 @@ class TestSimulatePortfolio:
|
||||
production_rows = [
|
||||
row for row in monitor["runs"] if row["is_production"]
|
||||
]
|
||||
comparison_rows = [
|
||||
row for row in monitor["runs"] if not row["is_production"]
|
||||
immediate_rows = [
|
||||
row for row in monitor["runs"]
|
||||
if row["comparison_arm"] == "live_immediate"
|
||||
]
|
||||
|
||||
assert production_rows
|
||||
assert all(
|
||||
row["reentry_lockdown_sessions"] == bt.REENTRY_LOCKDOWN_SESSIONS
|
||||
and row["applied_reentry_lockdown"] == bt.REENTRY_LOCKDOWN_SESSIONS
|
||||
row["reentry_policy"] == "gate_reset"
|
||||
and row["applied_gate_reset"] is True
|
||||
for row in production_rows
|
||||
)
|
||||
assert comparison_rows
|
||||
assert immediate_rows
|
||||
assert all(
|
||||
row["reentry_lockdown_sessions"] == 0
|
||||
and row["applied_reentry_lockdown"] == 0
|
||||
for row in comparison_rows
|
||||
row["reentry_policy"] == "immediate"
|
||||
and row["applied_gate_reset"] is False
|
||||
for row in immediate_rows
|
||||
)
|
||||
|
||||
def test_production_cadence_comparison_names_exact_two_arms(self):
|
||||
monitor = {
|
||||
"runs": [
|
||||
{
|
||||
"comparison_arm": "live_no_lockdown",
|
||||
"comparison_arm": "live_immediate",
|
||||
"lookback": "all",
|
||||
"reentry_lockdown_sessions": 0,
|
||||
"reentry_policy": "immediate",
|
||||
"trades": 10,
|
||||
"equity_curve": [{"date": "2026-01-01", "value": 1.0}],
|
||||
},
|
||||
{
|
||||
"comparison_arm": "live_lockdown_5",
|
||||
"comparison_arm": "live_gate_reset",
|
||||
"lookback": "all",
|
||||
"reentry_lockdown_sessions": 5,
|
||||
"reentry_policy": "gate_reset",
|
||||
"trades": 8,
|
||||
"benchmark_curve": [{"date": "2026-01-01", "value": 1.0}],
|
||||
},
|
||||
@@ -736,7 +777,7 @@ class TestSimulatePortfolio:
|
||||
assert comparison is not None
|
||||
assert [row["arm"] for row in comparison["arms"]] == [
|
||||
"prod_live_setup_daily",
|
||||
"cooldown_5_daily",
|
||||
"gate_reset_daily",
|
||||
]
|
||||
assert all("equity_curve" not in row for row in comparison["arms"])
|
||||
assert all("benchmark_curve" not in row for row in comparison["arms"])
|
||||
@@ -997,7 +1038,7 @@ def test_build_recommendation_prefers_production_monitor_headline():
|
||||
})
|
||||
assert rec["headline"] is not None
|
||||
assert "3x ATR trailing exit" in rec["headline"]
|
||||
assert "5-session re-entry lockdown" in rec["headline"]
|
||||
assert "after the gate fails" in rec["headline"]
|
||||
assert any(item["topic"] == "production" for item in rec["items"])
|
||||
|
||||
|
||||
@@ -1261,10 +1302,7 @@ async def test_run_backtest_smoke(session):
|
||||
assert report["params"]["is_production_target_model"] is True
|
||||
assert report["params"]["entry_cadence"] == "weekly"
|
||||
assert report["params"]["step_sessions"] == 5
|
||||
assert (
|
||||
report["params"]["production_reentry_lockdown_sessions"]
|
||||
== bt.REENTRY_LOCKDOWN_SESSIONS
|
||||
)
|
||||
assert report["params"]["production_reentry_policy"] == "gate_reset"
|
||||
assert "net_avg_r" in report["overall_all"]
|
||||
|
||||
# ablation baseline reproduces the qualified set exactly, and every row
|
||||
|
||||
Reference in New Issue
Block a user