research(backtest): breakeven-stop exit test, rejected
Pre-registered test of raising the stop to entry once a trade is +1R or +2R, on top of the 3x ATR trail. be_1r fails the frozen rule (train worse on all three graded metrics, test drawdown worse); be_2r changes 2 of 480 trades because the trail already reaches entry near +2R. The production exit stays. Breakeven exits get their own breakeven_stop label. They follow live re-entry semantics (only initial-stop exits lock re-entry), and an ungraded be_1r_gated diagnostic arm sends them through the post-stop gate reset instead. The trail and breakeven ratchet share one helper, so breakeven also applies on a next_open fill bar. breakeven_r is validated, verdicts flag passes that are only ties, and the graded capacity comes from the shadow book config. Every graded number and the parity row are unchanged from the first run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4876f9ffdd
commit
3545b483d8
7 files changed
+144938
-24
No files matched your search
@@ -920,6 +920,150 @@ class TestSimulatePortfolio:
|
||||
assert sim["exit_reasons"] == {"low20": 1}
|
||||
assert sim["worst_trade_r"] == pytest.approx(-0.25)
|
||||
|
||||
def test_breakeven_stop_lifts_stop_to_entry_after_k_r(self):
|
||||
# Entry 100, stop 95 → 1R = 5. The 106 close clears +1R, so from the next
|
||||
# bar the stop sits at 100; the 101 close (low 100) takes it out flat.
|
||||
# Too few bars for an ATR, so the trail never moves — only breakeven does.
|
||||
closes = [100.0, 103.0, 106.0, 104.0, 101.0, 99.0, 97.0, 96.0]
|
||||
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||
cand = _sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=130.0)
|
||||
|
||||
control = bt._simulate_portfolio([cand], prices, None, "atr_trail3", 30)
|
||||
be1 = bt._simulate_portfolio(
|
||||
[cand], prices, None, "atr_trail3", 30, include_trades=True, breakeven_r=1.0
|
||||
)
|
||||
be2 = bt._simulate_portfolio([cand], prices, None, "atr_trail3", 30, breakeven_r=2.0)
|
||||
|
||||
assert "breakeven_exits" not in control
|
||||
assert control["exit_reasons"] == {"stop": 1}
|
||||
assert be1["exit_reasons"] == {"breakeven_stop": 1}
|
||||
assert be1["breakeven_exits"] == 1
|
||||
assert be1["trade_details"][0]["fill"] == pytest.approx(100.0)
|
||||
assert be1["trade_details"][0]["exit_date"] == date.fromordinal(self.ORD + 4).isoformat()
|
||||
# +2R needs a 110 close, never reached: identical to control.
|
||||
assert be2["breakeven_exits"] == 0
|
||||
assert be2["final_equity"] == control["final_equity"]
|
||||
|
||||
def test_breakeven_exit_locks_reentry_only_when_gated(self):
|
||||
# Same path; AAA qualifies again on the breakeven exit day. Live only locks
|
||||
# re-entry after an initial stop, so by default it is bought back that
|
||||
# session. Gated, the exit opens a post-stop episode that never resets.
|
||||
closes = [100.0, 103.0, 106.0, 104.0, 101.0, 99.0, 97.0, 96.0]
|
||||
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||
cands = [
|
||||
_sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=130.0),
|
||||
_sim_cand("AAA", self.ORD + 4, entry=101.0, stop=96.0, target=130.0),
|
||||
]
|
||||
|
||||
def run(gate_reset: bool) -> dict:
|
||||
return bt._simulate_portfolio(
|
||||
cands, prices, None, "atr_trail3", 30,
|
||||
post_stop_reentry_fn=lambda *_: None,
|
||||
breakeven_r=1.0,
|
||||
breakeven_gate_reset=gate_reset,
|
||||
)
|
||||
|
||||
live, gated = run(False), run(True)
|
||||
# Live: the re-buy at 101 later hits its own initial stop.
|
||||
assert live["exit_reasons"] == {"breakeven_stop": 1, "stop": 1}
|
||||
assert live["breakeven_same_session_reentries"] == 1
|
||||
assert gated["exit_reasons"] == {"breakeven_stop": 1}
|
||||
assert gated["breakeven_same_session_reentries"] == 0
|
||||
assert gated["post_stop_events"] == 1
|
||||
|
||||
def test_breakeven_applies_on_the_next_open_fill_bar(self):
|
||||
# Flat warm-up gives ATR 4: fill at the 100 open, stop 94, 1R = 6. The fill
|
||||
# bar closes at 107 (> +1R), so the next bar's 99.5 low takes the trade out
|
||||
# at entry — not a session later, after the next bar's own ratchet.
|
||||
n = 42
|
||||
opens, highs, lows, closes = [100.0] * n, [102.0] * n, [98.0] * n, [100.0] * n
|
||||
opens[40], highs[40], lows[40], closes[40] = 100.0, 108.0, 99.0, 107.0
|
||||
opens[41], highs[41], lows[41], closes[41] = 104.0, 105.0, 99.5, 103.0
|
||||
ords = list(range(self.ORD, self.ORD + n))
|
||||
prices = {"AAA": (ords, opens, highs, lows, closes, [1_000_000] * n)}
|
||||
cand = _sim_cand("AAA", self.ORD + 39, entry=100.0, stop=95.0, target=130.0)
|
||||
|
||||
sim = bt._simulate_portfolio(
|
||||
[cand], prices, None, "atr_trail3", 30,
|
||||
fill_mode=bt.FILL_MODE_NEXT_OPEN,
|
||||
cost_per_side=0.0,
|
||||
include_trades=True,
|
||||
breakeven_r=1.0,
|
||||
)
|
||||
|
||||
trade = sim["trade_details"][0]
|
||||
assert trade["initial_stop"] == pytest.approx(94.0)
|
||||
assert trade["reason"] == "breakeven_stop"
|
||||
assert trade["fill"] == pytest.approx(100.0)
|
||||
assert trade["exit_date"] == date.fromordinal(self.ORD + 41).isoformat()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("policy", "kwargs"),
|
||||
[
|
||||
("atr_trail3", {"breakeven_r": 0.0}),
|
||||
("atr_trail3", {"breakeven_r": -1.0}),
|
||||
("atr_trail3", {"breakeven_r": float("nan")}),
|
||||
("hold", {"breakeven_r": 1.0}),
|
||||
("atr_trail3", {"breakeven_gate_reset": True}),
|
||||
],
|
||||
)
|
||||
def test_breakeven_rejects_settings_it_cannot_honor(self, policy, kwargs):
|
||||
prices = {"AAA": _sim_prices(self.ORD, [100.0, 101.0])}
|
||||
cand = _sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=130.0)
|
||||
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.
|
||||
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] = []
|
||||
real_simulate = bt._simulate_portfolio
|
||||
|
||||
def spy(*args, **kwargs):
|
||||
capacities.append(kwargs["max_positions"])
|
||||
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,
|
||||
)
|
||||
|
||||
assert report["capacity"] == 3
|
||||
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
|
||||
# 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"]]
|
||||
|
||||
def test_breakeven_checks_fail_on_missing_metrics(self):
|
||||
control = {"sharpe": 1.5, "cagr_pct": 40.0, "max_drawdown_pct": 20.0}
|
||||
assert bt._breakeven_checks(dict(control), control) == {
|
||||
"sharpe": True, "cagr": True, "max_drawdown": True,
|
||||
}
|
||||
worse_dd = {**control, "max_drawdown_pct": 20.1}
|
||||
assert bt._breakeven_checks(worse_dd, control)["max_drawdown"] is False
|
||||
assert bt._breakeven_checks({**control, "cagr_pct": None}, control)["cagr"] is False
|
||||
|
||||
def test_nothing_qualified_returns_none(self):
|
||||
assert bt._simulate_portfolio([], {}, None, "hold", 30) is None
|
||||
|
||||
|
||||
Reference in new issue
Block a user