From 231372fb3a27330cab5be0479f682c320994aba2 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Sat, 10 Oct 2026 21:41:44 +0200 Subject: [PATCH] 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 --- app/services/backtest_service.py | 128 +++++++++++------- docs/research/breakeven-stop.md | 25 ++-- .../residual-normalization-findings.md | 4 + reports/backtest-20261009-breakeven-stop.json | 13 +- tests/unit/test_backtest_service.py | 86 ++++++++---- 5 files changed, 168 insertions(+), 88 deletions(-) diff --git a/app/services/backtest_service.py b/app/services/backtest_service.py index dcfca57..3c87542 100644 --- a/app/services/backtest_service.py +++ b/app/services/backtest_service.py @@ -51,7 +51,7 @@ import statistics from collections import defaultdict from collections.abc import Callable from concurrent.futures import ProcessPoolExecutor -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from datetime import date, datetime, timezone from types import SimpleNamespace from typing import Any @@ -2365,6 +2365,10 @@ def _simulate_portfolio( raise ValueError("breakeven_r only applies to the ATR-trail exit policies") if breakeven_gate_reset and breakeven_r is None: raise ValueError("breakeven_gate_reset needs breakeven_r") + if breakeven_gate_reset and post_stop_reentry_fn is None and reentry_cooldown_sessions <= 0: + raise ValueError( + "breakeven_gate_reset needs post_stop_reentry_fn or reentry_cooldown_sessions" + ) clamp_lo, clamp_hi = float(vol_clamp[0]), float(vol_clamp[1]) if clamp_lo <= 0 or clamp_hi < clamp_lo: raise ValueError("vol_clamp must satisfy 0 < lo <= hi") @@ -3535,13 +3539,13 @@ def _entry_variant_config(variant: str) -> dict | None: return next((cfg for cfg in STRATEGY_VARIANTS if cfg["variant"] == variant), None) -@dataclass(frozen=True) +# eq=False keeps identity hashing: the fields hold dicts and callables. +@dataclass(frozen=True, eq=False) class _BookConfig: """How one ``PORTFOLIO_MONITOR_STRATEGIES`` entry is replayed. The monitor and every production-book study resolve it here, so they cannot drift apart.""" strategy: dict - entry_cfg: dict exit_policy: str live_exit_mode: str | None hold_days: int @@ -3557,17 +3561,16 @@ class _BookConfig: candidates: list[dict], prices: dict[str, tuple], cadence: str, - qualified_fn: Callable[[dict], bool] | None, ) -> Callable[[str, int, dict, Any], dict | None] | None: - """The gate-reset callback for this book, built on ``qualified_fn`` (a - sweep passes its own gate), or None for immediate re-entry.""" + """The gate-reset callback on this book's own gate, or None for immediate + re-entry. A sweep that changes the gate replaces ``qualified_fn`` first.""" if self.reentry_policy != "gate_reset": return None return _make_gate_reset_reentry_fn( candidates, prices, cadence=cadence, - qualified_fn=qualified_fn, + qualified_fn=self.qualified_fn, ranking_key=self.ranking_key, ) @@ -3594,7 +3597,6 @@ def _strategy_book_config( ) return _BookConfig( strategy=strategy, - entry_cfg=entry_cfg, exit_policy=exit_policy, live_exit_mode=live_exit_mode, hold_days=row_hold_days, @@ -3618,7 +3620,7 @@ def _production_book_config( hold_days: int, live_exit_policy: dict | None ) -> _BookConfig | None: strategy = next( - (s for s in PORTFOLIO_MONITOR_STRATEGIES if s.get("is_production")), None + (s for s in _portfolio_monitor_strategies() if s.get("is_production")), None ) if strategy is None: return None @@ -3758,9 +3760,9 @@ def _min_rr_sweep( max_positions=book.max_positions, risk_per_trade=book.risk_per_trade, atr_trail_multiplier=book.trail_multiplier, - post_stop_reentry_fn=book.post_stop_reentry_fn( - candidates, prices, cadence, qualified_fn - ), + post_stop_reentry_fn=replace( + book, qualified_fn=qualified_fn + ).post_stop_reentry_fn(candidates, prices, cadence), start_date=sweep_start, ) if sim is None: @@ -3832,9 +3834,7 @@ def _holdout_evaluation( book = _production_book_config(hold_days, live_exit_policy) if book is None: return {} - post_stop_reentry_fn = book.post_stop_reentry_fn( - candidates, prices, cadence, book.qualified_fn - ) + post_stop_reentry_fn = book.post_stop_reentry_fn(candidates, prices, cadence) rows: list[dict] = [] for window, start, end in ( @@ -3875,7 +3875,7 @@ def _holdout_evaluation( # Pre-registered 2026-10-09 — docs/research/breakeven-stop.md. Two trigger levels, -# no sweep; graded on both halves of a fixed split at the live book capacity. +# no sweep; graded on both halves of a fixed split at the live book's capacity and risk. BREAKEVEN_STUDY_ARMS: tuple[tuple[str, float | None], ...] = ( ("control", None), ("be_1r", 1.0), @@ -3918,15 +3918,16 @@ def _breakeven_exit_study( live_exit_policy: dict | None = None, cadence: str = DEFAULT_BACKTEST_CADENCE, monitor_report: dict | None = None, - live_capacity: int | None = None, + live_book: dict | None = None, ) -> dict: """Production book with a breakeven stop added to the ATR trail, vs control. Mirrors ``_holdout_evaluation``'s production configuration and changes only - ``breakeven_r`` and, for every graded row, capacity: ``live_capacity`` (the - shadow book's setting) when the caller could read it, else - ``SIM_MAX_POSITIONS``, rather than the monitor's 10. A capacity-10 control on - full history must reproduce the monitor's production row, or the study is not + ``breakeven_r`` and, for every graded row, the book's size: capacity and risk + per trade from ``live_book`` (the shadow book's config, with where each value + came from) when the caller could read it, else ``SIM_MAX_POSITIONS`` and the + entry variant's risk. A control at the monitor's capacity and risk on full + history must reproduce the monitor's production row, or the study is not grading the production book. """ book = _production_book_config(hold_days, live_exit_policy) @@ -3936,13 +3937,12 @@ def _breakeven_exit_study( return { "error": f"live exit maps to {book.exit_policy!r}; the study needs atr_trail3" } - post_stop_reentry_fn = book.post_stop_reentry_fn( - candidates, prices, cadence, book.qualified_fn - ) + post_stop_reentry_fn = book.post_stop_reentry_fn(candidates, prices, cadence) def run( breakeven_r: float | None, max_positions: int, + risk_per_trade: float, start: date | None = None, end: date | None = None, gate_reset: bool = False, @@ -3956,7 +3956,7 @@ def _breakeven_exit_study( qualified_fn=book.qualified_fn, ranking_key=book.ranking_key, max_positions=max_positions, - risk_per_trade=book.risk_per_trade, + risk_per_trade=risk_per_trade, atr_trail_multiplier=book.trail_multiplier, post_stop_reentry_fn=post_stop_reentry_fn, start_date=start, @@ -3973,23 +3973,31 @@ def _breakeven_exit_study( sim["trades_5r_plus"] = sum(1 for r in rs if r >= 5.0) return sim - capacity = int(live_capacity) if live_capacity is not None else SIM_MAX_POSITIONS + if live_book is not None: + capacity, risk = int(live_book["capacity"]), float(live_book["risk_per_trade"]) + capacity_source, risk_source = live_book["capacity_source"], live_book["risk_source"] + else: + capacity, risk = SIM_MAX_POSITIONS, book.risk_per_trade + capacity_source, risk_source = "SIM_MAX_POSITIONS", "entry variant" split = BREAKEVEN_STUDY_SPLIT windows = (("full", None, None), ("train", None, split), ("test", split, None)) rows: list[dict] = [] for arm, breakeven_r in BREAKEVEN_STUDY_ARMS: for window, start, end in windows: - sim = run(breakeven_r, capacity, start, end) + sim = run(breakeven_r, capacity, risk, start, end) if sim is not None: rows.append({"arm": arm, "window": window, **sim}) diagnostic_rows: list[dict] = [] - for arm, breakeven_r in BREAKEVEN_STUDY_DIAGNOSTIC_ARMS: + # Gating needs a post-stop episode to start; a book without one has nothing + # for the diagnostic to measure. + diagnostic_arms = BREAKEVEN_STUDY_DIAGNOSTIC_ARMS if post_stop_reentry_fn else () + for arm, breakeven_r in diagnostic_arms: for window, start, end in windows: - sim = run(breakeven_r, capacity, start, end, gate_reset=True) + sim = run(breakeven_r, capacity, risk, start, end, gate_reset=True) if sim is not None: diagnostic_rows.append({"arm": arm, "window": window, **sim}) - parity_row = run(None, book.max_positions) + parity_row = run(None, book.max_positions, book.risk_per_trade) monitor_row = next( ( r for r in (monitor_report or {}).get("runs") or [] @@ -4006,12 +4014,15 @@ def _breakeven_exit_study( def row(arm: str, window: str) -> dict | None: return next((r for r in rows if r["arm"] == arm and r["window"] == window), None) - def same_as_control(arm: str, window: str) -> bool: + def ties_control(arm: str, window: str) -> bool: treated, control = row(arm, window), row("control", window) return ( treated is not None and control is not None - and all(treated.get(k) == control.get(k) for k in _BREAKEVEN_GRADED_KEYS) + and all( + treated.get(k) is not None and treated.get(k) == control.get(k) + for k in _BREAKEVEN_GRADED_KEYS + ) ) verdicts: dict[str, dict] = {} @@ -4028,9 +4039,10 @@ def _breakeven_exit_study( all(verdict[w].values()) for w in ("train", "test") ) # The frozen rule passes ties. An arm that matches control on every - # graded metric in both windows is the control exit, not a finding. - verdict["identical_to_control"] = all( - same_as_control(arm, w) for w in ("train", "test") + # graded metric in both windows has found nothing, even if a few of its + # trades differ. + verdict["ties_control_on_graded_metrics"] = all( + ties_control(arm, w) for w in ("train", "test") ) verdicts[arm] = verdict @@ -4038,9 +4050,9 @@ def _breakeven_exit_study( "protocol": "docs/research/breakeven-stop.md", "split_date": split.isoformat(), "capacity": capacity, - "capacity_source": ( - "shadow_book_capacity" if live_capacity is not None else "SIM_MAX_POSITIONS" - ), + "capacity_source": capacity_source, + "risk_per_trade": risk, + "risk_source": risk_source, "cadence": cadence, "exit_policy": book.exit_policy, "atr_trail_multiplier": book.trail_multiplier, @@ -4049,6 +4061,7 @@ def _breakeven_exit_study( "parity": { "ok": parity_ok, "capacity": book.max_positions, + "risk_per_trade": book.risk_per_trade, "study_control": ( {k: parity_row.get(k) for k in _BREAKEVEN_PARITY_KEYS} if parity_row else None ), @@ -4062,7 +4075,7 @@ def _breakeven_exit_study( "note": ( "Graded on train and test only: an arm passes when Sharpe and CAGR are " "no lower and max drawdown no higher than control in both windows; " - "identical_to_control marks a pass that is only ties. Win rate and avg R " + "ties_control_on_graded_metrics marks a pass that is only ties. Win rate and avg R " "are reported, not graded. diagnostic_rows were added after the first run " "and are never graded. A pass earns forward paper validation, not a " "production change." @@ -4085,9 +4098,7 @@ def _portfolio_monitor( book = _strategy_book_config(strategy, hold_days, live_exit_policy) if book is None: continue - post_stop_reentry_fn = book.post_stop_reentry_fn( - candidates, prices, cadence, book.qualified_fn - ) + post_stop_reentry_fn = book.post_stop_reentry_fn(candidates, prices, cadence) for lookback in PORTFOLIO_MONITOR_LOOKBACKS: start = _lookback_start(latest_ord, lookback["days"]) sim = _simulate_portfolio( @@ -4805,23 +4816,42 @@ async def run_backtest( hold_horizon, live_exit_policy=live_exit_policy, cadence=cadence, ) if _breakeven_study_enabled(): - live_capacity: int | None = None + live_book: dict | None = None try: - from app.services.shadow_book_service import get_config + from app.services import shadow_book_service as shadow_book - live_capacity = int((await get_config(db))["capacity"]) + shadow_config = await shadow_book.get_config(db) + stored = await settings_store.get_map( + db, [shadow_book.KEY_CAPACITY, shadow_book.KEY_RISK_PCT] + ) + + def _source(key: str) -> str: + # get_config falls back to its default for a missing or + # unreadable row; say so rather than credit the setting. + try: + float(stored[key]) + except (KeyError, TypeError, ValueError): + return "shadow_book default" + return key + + live_book = { + "capacity": int(shadow_config["capacity"]), + "risk_per_trade": float(shadow_config["risk_pct"]) / 100.0, + "capacity_source": _source(shadow_book.KEY_CAPACITY), + "risk_source": _source(shadow_book.KEY_RISK_PCT), + } except Exception: logger.exception( - "Shadow book capacity load failed; breakeven study uses " - "SIM_MAX_POSITIONS" + "Shadow book config load failed; breakeven study uses " + "SIM_MAX_POSITIONS and the entry variant's risk" ) - await _rollback_quietly(db, "shadow book capacity load") + await _rollback_quietly(db, "shadow book config load") breakeven_study_report = _breakeven_exit_study( candidates, price_columns, spy_closes, hold_horizon, live_exit_policy=live_exit_policy, cadence=cadence, monitor_report=portfolio_monitor_report, - live_capacity=live_capacity, + live_book=live_book, ) except Exception: logger.exception("Portfolio simulation failed") diff --git a/docs/research/breakeven-stop.md b/docs/research/breakeven-stop.md index 777a53c..16b1fdc 100644 --- a/docs/research/breakeven-stop.md +++ b/docs/research/breakeven-stop.md @@ -75,9 +75,11 @@ BACKTEST_BREAKEVEN_STUDY=1 python scripts/run_backtest_snapshot.py \ --out reports/backtest-20261009-breakeven-stop.json ``` -The graded capacity is read from the snapshot's `shadow_book_capacity` setting. -`prod.sqlite` has no such row, so the shadow book's default of 15 applies; the -report records it as `capacity` / `capacity_source`. +The graded capacity and risk per trade come from the shadow book's config in the +database the run reads (`shadow_book_capacity`, `shadow_book_risk_pct`). +`prod.sqlite` has neither row, so the shadow book's defaults apply: 15 +positions and 1% risk. The report records each value with its source +(`capacity_source`, `risk_source`), which reads `shadow_book default` here. ## Result — no change; `be_1r` fails, `be_2r` passes only by being the production exit @@ -201,11 +203,12 @@ and live still agree. - `breakeven_r` must be positive and finite, and is rejected for exit policies without an ATR trail. Before, a non-positive value moved the stop to entry on the first bar, and other policies ignored the setting silently. -- Each verdict now records `identical_to_control`, which is true for `be_2r`. A - pass that is only ties can no longer read as a finding. -- The graded capacity comes from the shadow book's capacity config (the - `shadow_book_capacity` setting in the database the run reads, or its default - of 15 when the row is missing) instead of the `SIM_MAX_POSITIONS` constant. On - a snapshot that is the snapshot's copy of the setting, not the current live - value ([Reproducing](#reproducing)). `capacity_source` reads - `shadow_book_capacity` in both cases. +- Each verdict now records `ties_control_on_graded_metrics`, which is true for + `be_2r`. A pass that is only ties can no longer read as a finding. The flag + compares the graded metrics only: `be_2r` still changed 2 trades. +- The graded capacity and risk per trade come from the shadow book's config + instead of the `SIM_MAX_POSITIONS` constant and the entry variant's fixed 1%. + On a snapshot that is the snapshot's copy of the settings, not the current + live values, and a missing row is labeled as the default it fell back to + ([Reproducing](#reproducing)). In this run both are defaults, equal to the + values the first run used. diff --git a/docs/research/residual-normalization-findings.md b/docs/research/residual-normalization-findings.md index 037bcd7..4c74332 100644 --- a/docs/research/residual-normalization-findings.md +++ b/docs/research/residual-normalization-findings.md @@ -55,6 +55,10 @@ long-only, gated, capital-constrained production book. calendar overlap, and an end-to-end screen check. Ruff passed for the runner and its tests. The existing Python runtime was usable outside the filesystem sandbox; no environment replacement was needed. +- A 2026-10-10 code review added four tests (missing prices, file release, + paired-coverage guard, source ids) with the runner fixes they cover. The + report was regenerated with the fixed runner; its screen results are + byte-identical to the original run. **Interpretation and decision** diff --git a/reports/backtest-20261009-breakeven-stop.json b/reports/backtest-20261009-breakeven-stop.json index 2d8422d..9707d95 100644 --- a/reports/backtest-20261009-breakeven-stop.json +++ b/reports/backtest-20261009-breakeven-stop.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-10-10T07:30:27.484621+00:00", + "generated_at": "2026-10-10T19:39:30.155792+00:00", "tickers": 506, "rank_only_tickers": 0, "candidates": 1011248, @@ -143382,7 +143382,9 @@ "protocol": "docs/research/breakeven-stop.md", "split_date": "2024-07-01", "capacity": 15, - "capacity_source": "shadow_book_capacity", + "capacity_source": "shadow_book default", + "risk_per_trade": 0.01, + "risk_source": "shadow_book default", "cadence": "daily", "exit_policy": "atr_trail3", "atr_trail_multiplier": 3.0, @@ -143391,6 +143393,7 @@ "parity": { "ok": true, "capacity": 10, + "risk_per_trade": 0.01, "study_control": { "sharpe": 1.84, "cagr_pct": 49.8, @@ -143986,7 +143989,7 @@ "max_drawdown": false }, "passes": false, - "identical_to_control": false + "ties_control_on_graded_metrics": false }, "be_2r": { "train": { @@ -144000,7 +144003,7 @@ "max_drawdown": true }, "passes": true, - "identical_to_control": true + "ties_control_on_graded_metrics": true } }, "diagnostic_rows": [ @@ -144199,7 +144202,7 @@ "trades_5r_plus": 10 } ], - "note": "Graded on train and test only: an arm passes when Sharpe and CAGR are no lower and max drawdown no higher than control in both windows; identical_to_control marks a pass that is only ties. Win rate and avg R are reported, not graded. diagnostic_rows were added after the first run and are never graded. A pass earns forward paper validation, not a production change." + "note": "Graded on train and test only: an arm passes when Sharpe and CAGR are no lower and max drawdown no higher than control in both windows; ties_control_on_graded_metrics marks a pass that is only ties. Win rate and avg R are reported, not graded. diagnostic_rows were added after the first run and are never graded. A pass earns forward paper validation, not a production change." }, "recommendation": { "headline": "Production baseline: residual/high-vol 80/20 entry rank with a 3x ATR trailing exit, 30-trading-day max hold, and re-entry only after the gate fails and later qualifies again.", diff --git a/tests/unit/test_backtest_service.py b/tests/unit/test_backtest_service.py index b2d4c26..273aa30 100644 --- a/tests/unit/test_backtest_service.py +++ b/tests/unit/test_backtest_service.py @@ -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}