fix(backtest): address the second review of the breakeven and book-config work
- The breakeven study grades at the shadow book's capacity AND risk per trade, and labels each with where it came from. A missing setting row now reads "shadow_book default" instead of crediting the setting; the committed report said shadow_book_capacity though the snapshot has no such row. - _production_book_config reads strategies through the same _portfolio_monitor_strategies() hook as the monitor. - breakeven_gate_reset is rejected when there is no post-stop episode or cooldown for it to start, and the study skips its diagnostic arm for a book without one, so a production config change cannot abort the run. - identical_to_control is renamed ties_control_on_graded_metrics and needs real values: be_2r ties on every graded metric but still changed 2 trades. - _BookConfig drops an unused field and hashes by identity; the min-R:R sweep swaps its gate in with dataclasses.replace instead of passing it. - New tests cover the parity guard's success path, the no-config fallback and the gate-reset check. The breakeven report is regenerated: every row, diagnostic row and the parity check are identical; only the labels, the risk fields and the renamed flag changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7a9b3ad38b
commit
231372fb3a
5 files changed
+168
-88
No files matched your search
@@ -1005,6 +1005,8 @@ class TestSimulatePortfolio:
|
||||
("atr_trail3", {"breakeven_r": float("nan")}),
|
||||
("hold", {"breakeven_r": 1.0}),
|
||||
("atr_trail3", {"breakeven_gate_reset": True}),
|
||||
# Gated, but nothing to gate: no post-stop episode and no cooldown.
|
||||
("atr_trail3", {"breakeven_r": 1.0, "breakeven_gate_reset": True}),
|
||||
],
|
||||
)
|
||||
def test_breakeven_rejects_settings_it_cannot_honor(self, policy, kwargs):
|
||||
@@ -1013,47 +1015,85 @@ class TestSimulatePortfolio:
|
||||
with pytest.raises(ValueError):
|
||||
bt._simulate_portfolio([cand], prices, None, policy, 30, **kwargs)
|
||||
|
||||
def test_breakeven_study_uses_live_capacity_and_flags_ties(self, monkeypatch):
|
||||
# One trade per window: AAA before the split, BBB after it. be_2r never
|
||||
# triggers (+2R needs a 110 close), so it ties control in both windows.
|
||||
LIVE_EXIT = {"mode": "atr_trailing", "hold_days": 30, "atr_multiplier": 3.0}
|
||||
|
||||
def _split_book(self, monkeypatch, per_window: int = 1) -> tuple[list[dict], dict]:
|
||||
"""``per_window`` trades on each side of the split, one symbol each, 50
|
||||
days apart. be_2r never triggers (+2R needs a 110 close), so it ties
|
||||
control in both windows. Three per window spans the >0.25y CAGR needs."""
|
||||
closes = [100.0, 103.0, 106.0, 104.0, 101.0, 99.0, 97.0, 96.0]
|
||||
prices = {
|
||||
"AAA": _sim_prices(self.ORD, closes),
|
||||
"BBB": _sim_prices(self.ORD + 10, closes),
|
||||
}
|
||||
cands = [
|
||||
_sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=130.0),
|
||||
_sim_cand("BBB", self.ORD + 10, entry=100.0, stop=95.0, target=130.0),
|
||||
]
|
||||
monkeypatch.setattr(bt, "BREAKEVEN_STUDY_SPLIT", date.fromordinal(self.ORD + 10))
|
||||
capacities: list[int] = []
|
||||
split = self.ORD + 50 * per_window
|
||||
prices, cands = {}, []
|
||||
for side, first in (("A", self.ORD), ("B", split)):
|
||||
for k in range(per_window):
|
||||
sym, day = f"{side}{k}", first + 50 * k
|
||||
prices[sym] = _sim_prices(day, closes)
|
||||
cands.append(_sim_cand(sym, day, entry=100.0, stop=95.0, target=130.0))
|
||||
monkeypatch.setattr(bt, "BREAKEVEN_STUDY_SPLIT", date.fromordinal(split))
|
||||
return cands, prices
|
||||
|
||||
def test_breakeven_study_grades_the_live_book_and_flags_ties(self, monkeypatch):
|
||||
cands, prices = self._split_book(monkeypatch, per_window=3)
|
||||
sizes: list[tuple[int, float]] = []
|
||||
real_simulate = bt._simulate_portfolio
|
||||
|
||||
def spy(*args, **kwargs):
|
||||
capacities.append(kwargs["max_positions"])
|
||||
sizes.append((kwargs["max_positions"], kwargs["risk_per_trade"]))
|
||||
return real_simulate(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(bt, "_simulate_portfolio", spy)
|
||||
|
||||
report = bt._breakeven_exit_study(
|
||||
cands, prices, None, 30,
|
||||
live_exit_policy={"mode": "atr_trailing", "hold_days": 30, "atr_multiplier": 3.0},
|
||||
live_capacity=3,
|
||||
live_exit_policy=self.LIVE_EXIT,
|
||||
live_book={
|
||||
"capacity": 3, "risk_per_trade": 0.02,
|
||||
"capacity_source": "shadow_book_capacity",
|
||||
"risk_source": "shadow_book default",
|
||||
},
|
||||
)
|
||||
|
||||
assert report["capacity"] == 3
|
||||
assert (report["capacity"], report["risk_per_trade"]) == (3, 0.02)
|
||||
assert report["capacity_source"] == "shadow_book_capacity"
|
||||
assert report["verdicts"]["be_2r"]["identical_to_control"] is True
|
||||
assert report["verdicts"]["be_1r"]["identical_to_control"] is False
|
||||
assert report["risk_source"] == "shadow_book default"
|
||||
assert report["verdicts"]["be_2r"]["ties_control_on_graded_metrics"] is True
|
||||
assert report["verdicts"]["be_1r"]["ties_control_on_graded_metrics"] is False
|
||||
# No monitor row to match, so nothing can pass.
|
||||
assert report["parity"]["ok"] is False
|
||||
assert not any(v["passes"] for v in report["verdicts"].values())
|
||||
assert {r["arm"] for r in report["diagnostic_rows"]} == {"be_1r_gated"}
|
||||
assert all(r["breakeven_gate_reset"] for r in report["diagnostic_rows"])
|
||||
# 3 graded arms + 1 diagnostic arm x 3 windows at capacity 3, then the
|
||||
# parity row at the monitor's capacity.
|
||||
assert capacities[:12] == [3] * 12
|
||||
assert capacities[12:] == [report["parity"]["capacity"]]
|
||||
# 3 graded arms + 1 diagnostic arm x 3 windows on the live book, then the
|
||||
# parity row on the monitor's.
|
||||
assert sizes[:12] == [(3, 0.02)] * 12
|
||||
assert sizes[12:] == [
|
||||
(report["parity"]["capacity"], report["parity"]["risk_per_trade"])
|
||||
]
|
||||
|
||||
def test_breakeven_study_without_live_config_says_so(self, monkeypatch):
|
||||
cands, prices = self._split_book(monkeypatch)
|
||||
report = bt._breakeven_exit_study(cands, prices, None, 30, live_exit_policy=self.LIVE_EXIT)
|
||||
assert (report["capacity"], report["capacity_source"]) == (
|
||||
bt.SIM_MAX_POSITIONS, "SIM_MAX_POSITIONS",
|
||||
)
|
||||
assert report["risk_source"] == "entry variant"
|
||||
|
||||
def test_breakeven_parity_finds_the_monitor_production_row(self, monkeypatch):
|
||||
# The guard's success path: the study's control at the monitor's capacity
|
||||
# must match the monitor's production "all" row, and then a tie can pass.
|
||||
cands, prices = self._split_book(monkeypatch, per_window=3)
|
||||
monitor = bt._portfolio_monitor(cands, prices, None, 30, live_exit_policy=self.LIVE_EXIT)
|
||||
|
||||
report = bt._breakeven_exit_study(
|
||||
cands, prices, None, 30, live_exit_policy=self.LIVE_EXIT, monitor_report=monitor,
|
||||
)
|
||||
|
||||
parity = report["parity"]
|
||||
assert parity["monitor_production_all"] is not None
|
||||
assert parity["study_control"] == parity["monitor_production_all"]
|
||||
assert parity["ok"] is True
|
||||
assert report["verdicts"]["be_2r"]["passes"] is True
|
||||
assert report["verdicts"]["be_2r"]["ties_control_on_graded_metrics"] is True
|
||||
|
||||
def test_breakeven_checks_fail_on_missing_metrics(self):
|
||||
control = {"sharpe": 1.5, "cagr_pct": 40.0, "max_drawdown_pct": 20.0}
|
||||
|
||||
Reference in new issue
Block a user