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
@@ -630,6 +630,7 @@ Research-only flags, all off by default (the default report is byte-identical to
|
||||
| `BACKTEST_HOLDOUT_SPLIT=YYYY-MM-DD` | Adds a `holdout` section: train (entries before) vs test (entries on/after), as disjoint books |
|
||||
| `BACKTEST_MIN_RR_SWEEP=1` | Sweeps the activation R:R floor against portfolio Sharpe. Combine with `BACKTEST_HOLDOUT_SPLIT` to sweep out-of-sample |
|
||||
| `BACKTEST_RESEARCH_EXITS=1` | Adds the rejected take-profit exit rows to the exit comparison |
|
||||
| `BACKTEST_BREAKEVEN_STUDY=1` | Adds a `breakeven_stop_study` section: the production book with a breakeven stop at +1R / +2R vs control, graded on a fixed train/test split ([protocol](docs/research/breakeven-stop.md)) |
|
||||
| `BACKTEST_ATR_TARGET_FALLBACK=k` | Synthesizes a k×ATR target where S/R offers none |
|
||||
| `BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1` | Restricts that fallback to setups with genuinely no structure ahead |
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ Environment variables (see also run_backtest_snapshot.py):
|
||||
BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1
|
||||
BACKTEST_RESEARCH_EXITS=1
|
||||
BACKTEST_MIN_RR_SWEEP=1
|
||||
BACKTEST_BREAKEVEN_STUDY=1 # docs/research/breakeven-stop.md
|
||||
|
||||
Broad-universe signal research (local snapshots only; inert when unset):
|
||||
BACKTEST_LIQUID_BREADTH=1500 # PIT top-N by 63d median $vol, price floor
|
||||
@@ -2298,6 +2299,8 @@ def _simulate_portfolio(
|
||||
corr_lookback: int = 120,
|
||||
corr_action: str = "skip",
|
||||
corr_min_overlap: int = 60,
|
||||
breakeven_r: float | None = None,
|
||||
breakeven_gate_reset: bool = False,
|
||||
) -> dict | None:
|
||||
"""Replay the qualified setups as ONE capital-constrained book and report
|
||||
portfolio economics from the daily equity curve (return, CAGR, drawdown,
|
||||
@@ -2330,6 +2333,14 @@ def _simulate_portfolio(
|
||||
caps skip or half-size candidates whose max pairwise 120d return correlation
|
||||
with open holdings exceeds ``corr_max``.
|
||||
|
||||
``breakeven_r`` (research only, ATR-trail policies): once the highest close
|
||||
reaches entry + ``breakeven_r`` x initial risk, raise the stop to the entry
|
||||
price. Like the trail, it is active from the next bar and only moves up.
|
||||
A stop it set exits as "breakeven_stop". Live gate locks key on initial-stop
|
||||
exits only, so by default that exit, like a trailing stop, neither starts
|
||||
the cooldown nor the post-stop episode; ``breakeven_gate_reset`` makes it
|
||||
do both, as an initial stop would.
|
||||
|
||||
Returns None when there is nothing to trade. ``cost_per_side`` is charged on
|
||||
entry and exit and therefore changes both cash availability and subsequent
|
||||
position sizing.
|
||||
@@ -2347,6 +2358,13 @@ def _simulate_portfolio(
|
||||
raise ValueError("corr_action must be 'skip' or 'half_size'")
|
||||
if vol_target is not None and vol_target <= 0:
|
||||
raise ValueError("vol_target must be positive when set")
|
||||
if breakeven_r is not None:
|
||||
if not math.isfinite(breakeven_r) or breakeven_r <= 0:
|
||||
raise ValueError("breakeven_r must be positive and finite when set")
|
||||
if exit_policy not in ("atr_trail3", "atr_trail3_target"):
|
||||
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")
|
||||
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")
|
||||
@@ -2478,6 +2496,23 @@ def _simulate_portfolio(
|
||||
atr_cache[key] = None
|
||||
return atr_cache[key]
|
||||
|
||||
def _ratchet_stop(pos: dict, sym: str, idx: int, close: float) -> None:
|
||||
"""ATR-trail policies: raise the stop from this bar's close — the trail
|
||||
off the highest close, then the breakeven floor. Effective from the next
|
||||
bar; the stop never moves down."""
|
||||
pos["highest_close"] = max(pos["highest_close"], close)
|
||||
atr = _atr(sym, idx)
|
||||
if atr is not None:
|
||||
next_stop = pos["highest_close"] - atr_trail_multiplier * atr
|
||||
if next_stop < close and next_stop > pos["stop"]:
|
||||
pos["stop"] = next_stop
|
||||
pos["stop_source"] = "trail"
|
||||
if breakeven_r is not None and pos["entry"] > pos["stop"]:
|
||||
risk = pos["entry"] - pos["initial_stop"]
|
||||
if risk > 0 and pos["highest_close"] >= pos["entry"] + breakeven_r * risk:
|
||||
pos["stop"] = pos["entry"]
|
||||
pos["stop_source"] = "breakeven"
|
||||
|
||||
def _close_trade(sym: str, fill: float, reason: str) -> dict:
|
||||
nonlocal cash
|
||||
pos = positions.pop(sym)
|
||||
@@ -2520,11 +2555,12 @@ def _simulate_portfolio(
|
||||
if bar.low <= pos["stop"]:
|
||||
# Same-bar stop+target resolves as the loss (conservative, like
|
||||
# the evaluator); gap through the stop fills at the open.
|
||||
reason = (
|
||||
"trailing_stop"
|
||||
if pos["stop"] > pos["initial_stop"] + 1e-9
|
||||
else "stop"
|
||||
)
|
||||
if pos["stop_source"] == "breakeven":
|
||||
reason = "breakeven_stop"
|
||||
elif pos["stop"] > pos["initial_stop"] + 1e-9:
|
||||
reason = "trailing_stop"
|
||||
else:
|
||||
reason = "stop"
|
||||
survived_refresh = False
|
||||
if reason == "stop" and initial_stop_refresh_fn is not None:
|
||||
stop_refresh_attempts += 1
|
||||
@@ -2545,9 +2581,12 @@ def _simulate_portfolio(
|
||||
if not survived_refresh:
|
||||
fill = min(pos["stop"], bar.open)
|
||||
closed_pos = _close_trade(sym, fill, reason)
|
||||
if reason == "stop" and cooldown_sessions:
|
||||
resets_gate = reason == "stop" or (
|
||||
reason == "breakeven_stop" and breakeven_gate_reset
|
||||
)
|
||||
if resets_gate and cooldown_sessions:
|
||||
cooldown_until_index[sym] = calendar_index + cooldown_sessions
|
||||
if reason == "stop" and post_stop_reentry_fn is not None:
|
||||
if resets_gate and post_stop_reentry_fn is not None:
|
||||
post_stop_events += 1
|
||||
post_stop_states[sym] = {
|
||||
"stop_ord": o,
|
||||
@@ -2584,12 +2623,7 @@ def _simulate_portfolio(
|
||||
_close_trade(sym, bar.close, "time")
|
||||
continue
|
||||
if exit_policy in ("atr_trail3", "atr_trail3_target"):
|
||||
pos["highest_close"] = max(pos["highest_close"], bar.close)
|
||||
atr = _atr(sym, bar.idx)
|
||||
if atr is not None:
|
||||
next_stop = pos["highest_close"] - atr_trail_multiplier * atr
|
||||
if next_stop < bar.close:
|
||||
pos["stop"] = max(pos["stop"], next_stop)
|
||||
_ratchet_stop(pos, sym, bar.idx, bar.close)
|
||||
|
||||
# 2) entries — close-fill at signal close, or next-open fills of prior signals
|
||||
equity = _marked_equity()
|
||||
@@ -2725,6 +2759,7 @@ def _simulate_portfolio(
|
||||
"entry_ord": entry_ord,
|
||||
"initial_stop": stop,
|
||||
"stop": stop,
|
||||
"stop_source": "initial",
|
||||
"target": float(c["target"]) if c.get("target") else None,
|
||||
"entry_cost": entry_cost,
|
||||
"bars_held": 0,
|
||||
@@ -2744,7 +2779,6 @@ def _simulate_portfolio(
|
||||
# bars_held stays 0 on the fill day (matches close-fill cadence).
|
||||
if fill_mode == FILL_MODE_NEXT_OPEN and fill_bar is not None:
|
||||
positions[sym]["last_close"] = fill_bar.close
|
||||
positions[sym]["highest_close"] = max(entry, fill_bar.close)
|
||||
if fill_bar.low <= stop:
|
||||
fill = min(stop, fill_bar.open)
|
||||
closed = _close_trade(sym, fill, "stop")
|
||||
@@ -2765,16 +2799,7 @@ def _simulate_portfolio(
|
||||
"gate_went_unqualified": False,
|
||||
}
|
||||
elif exit_policy in ("atr_trail3", "atr_trail3_target"):
|
||||
atr = _atr(sym, fill_bar.idx)
|
||||
if atr is not None:
|
||||
next_stop = (
|
||||
positions[sym]["highest_close"]
|
||||
- atr_trail_multiplier * atr
|
||||
)
|
||||
if next_stop < fill_bar.close:
|
||||
positions[sym]["stop"] = max(
|
||||
positions[sym]["stop"], next_stop
|
||||
)
|
||||
_ratchet_stop(positions[sym], sym, fill_bar.idx, fill_bar.close)
|
||||
equity = _marked_equity()
|
||||
|
||||
for c in fill_candidates:
|
||||
@@ -3059,6 +3084,17 @@ def _simulate_portfolio(
|
||||
if max_entry_gap_pct is not None:
|
||||
result["max_entry_gap_pct"] = max_entry_gap_pct
|
||||
result["skipped_gap_cap"] = skipped_gap_cap
|
||||
if breakeven_r is not None:
|
||||
result["breakeven_r"] = breakeven_r
|
||||
result["breakeven_gate_reset"] = breakeven_gate_reset
|
||||
breakeven_exit_keys = {
|
||||
(t["symbol"], t["exit_ord"]) for t in trades if t["reason"] == "breakeven_stop"
|
||||
}
|
||||
result["breakeven_exits"] = len(breakeven_exit_keys)
|
||||
# Bought back in the session it was stopped out of breakeven.
|
||||
result["breakeven_same_session_reentries"] = sum(
|
||||
1 for t in trades if (t["symbol"], t["entry_ord"]) in breakeven_exit_keys
|
||||
)
|
||||
if curve_payload is not None:
|
||||
result["equity_curve"] = curve_payload
|
||||
if benchmark_payload is not None:
|
||||
@@ -3806,6 +3842,231 @@ 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.
|
||||
BREAKEVEN_STUDY_ARMS: tuple[tuple[str, float | None], ...] = (
|
||||
("control", None),
|
||||
("be_1r", 1.0),
|
||||
("be_2r", 2.0),
|
||||
)
|
||||
# Added 2026-10-10 after the first run, so reported but never graded: be_1r with its
|
||||
# breakeven exits starting the post-stop gate reset, as an initial stop does. The
|
||||
# graded arms follow live, where only initial-stop exits lock re-entry; the gap
|
||||
# between the two shows how much of be_1r is same-session re-buys.
|
||||
BREAKEVEN_STUDY_DIAGNOSTIC_ARMS: tuple[tuple[str, float], ...] = (("be_1r_gated", 1.0),)
|
||||
BREAKEVEN_STUDY_SPLIT = date(2024, 7, 1)
|
||||
_BREAKEVEN_PARITY_KEYS = ("sharpe", "cagr_pct", "max_drawdown_pct", "trades")
|
||||
_BREAKEVEN_GRADED_KEYS = ("sharpe", "cagr_pct", "max_drawdown_pct")
|
||||
|
||||
|
||||
def _breakeven_study_enabled() -> bool:
|
||||
return os.getenv("BACKTEST_BREAKEVEN_STUDY", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _breakeven_checks(arm: dict, control: dict) -> dict[str, bool]:
|
||||
"""The frozen pass rule for one window: no worse on Sharpe, CAGR or drawdown.
|
||||
A missing metric fails — an arm cannot pass on numbers it did not produce."""
|
||||
|
||||
def ok(key: str, better) -> bool:
|
||||
a, c = arm.get(key), control.get(key)
|
||||
return a is not None and c is not None and better(a, c)
|
||||
|
||||
return {
|
||||
"sharpe": ok("sharpe", lambda a, c: a >= c),
|
||||
"cagr": ok("cagr_pct", lambda a, c: a >= c),
|
||||
"max_drawdown": ok("max_drawdown_pct", lambda a, c: a <= c),
|
||||
}
|
||||
|
||||
|
||||
def _breakeven_exit_study(
|
||||
candidates: list[dict],
|
||||
prices: dict[str, tuple],
|
||||
_spy_closes: dict[date, float] | None,
|
||||
hold_days: int,
|
||||
live_exit_policy: dict | None = None,
|
||||
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||
monitor_report: dict | None = None,
|
||||
live_capacity: int | 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
|
||||
grading the production book.
|
||||
"""
|
||||
strategy = next(
|
||||
(s for s in PORTFOLIO_MONITOR_STRATEGIES if s.get("is_production")), None
|
||||
)
|
||||
if strategy is None:
|
||||
return {}
|
||||
entry_cfg = _entry_variant_config(str(strategy["entry_variant"]))
|
||||
if entry_cfg is None:
|
||||
return {}
|
||||
|
||||
exit_policy = str(strategy["exit_policy"])
|
||||
row_hold_days = hold_days
|
||||
trail_multiplier = ATR_TRAIL_MULTIPLIER
|
||||
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"
|
||||
)
|
||||
row_hold_days = int(live_exit_policy.get("hold_days", hold_days))
|
||||
trail_multiplier = float(
|
||||
live_exit_policy.get("atr_multiplier", ATR_TRAIL_MULTIPLIER)
|
||||
)
|
||||
if exit_policy != "atr_trail3":
|
||||
return {"error": f"live exit maps to {exit_policy!r}; the study needs atr_trail3"}
|
||||
qualified_fn = (
|
||||
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
|
||||
)
|
||||
|
||||
def run(
|
||||
breakeven_r: float | None,
|
||||
max_positions: int,
|
||||
start: date | None = None,
|
||||
end: date | None = None,
|
||||
gate_reset: bool = False,
|
||||
) -> dict | None:
|
||||
sim = _simulate_portfolio(
|
||||
candidates,
|
||||
prices,
|
||||
_spy_closes,
|
||||
exit_policy,
|
||||
row_hold_days,
|
||||
qualified_fn=qualified_fn,
|
||||
ranking_key=ranking_key,
|
||||
max_positions=max_positions,
|
||||
risk_per_trade=float(entry_cfg["risk_per_trade"]),
|
||||
atr_trail_multiplier=trail_multiplier,
|
||||
post_stop_reentry_fn=post_stop_reentry_fn,
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
include_trades=True,
|
||||
breakeven_r=breakeven_r,
|
||||
breakeven_gate_reset=gate_reset,
|
||||
)
|
||||
if sim is None:
|
||||
return None
|
||||
rs = [t["r"] for t in sim.pop("trade_details", [])]
|
||||
sim.pop("reentry_events", None)
|
||||
sim["avg_r"] = round(sum(rs) / len(rs), 3) if rs else None
|
||||
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
|
||||
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)
|
||||
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:
|
||||
for window, start, end in windows:
|
||||
sim = run(breakeven_r, capacity, start, end, gate_reset=True)
|
||||
if sim is not None:
|
||||
diagnostic_rows.append({"arm": arm, "window": window, **sim})
|
||||
|
||||
parity_row = run(None, int(entry_cfg["max_positions"]))
|
||||
monitor_row = next(
|
||||
(
|
||||
r for r in (monitor_report or {}).get("runs") or []
|
||||
if r.get("is_production") and r.get("lookback") == "all"
|
||||
),
|
||||
None,
|
||||
)
|
||||
parity_ok = (
|
||||
parity_row is not None
|
||||
and monitor_row is not None
|
||||
and all(parity_row.get(k) == monitor_row.get(k) for k in _BREAKEVEN_PARITY_KEYS)
|
||||
)
|
||||
|
||||
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:
|
||||
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)
|
||||
)
|
||||
|
||||
verdicts: dict[str, dict] = {}
|
||||
for arm, _ in BREAKEVEN_STUDY_ARMS[1:]:
|
||||
verdict: dict[str, Any] = {}
|
||||
for window in ("train", "test"):
|
||||
treated, control = row(arm, window), row("control", window)
|
||||
verdict[window] = (
|
||||
_breakeven_checks(treated, control)
|
||||
if treated is not None and control is not None
|
||||
else {"sharpe": False, "cagr": False, "max_drawdown": False}
|
||||
)
|
||||
verdict["passes"] = parity_ok and all(
|
||||
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")
|
||||
)
|
||||
verdicts[arm] = verdict
|
||||
|
||||
return {
|
||||
"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"
|
||||
),
|
||||
"cadence": cadence,
|
||||
"exit_policy": exit_policy,
|
||||
"atr_trail_multiplier": trail_multiplier,
|
||||
"hold_days": row_hold_days,
|
||||
"reentry_policy": reentry_policy,
|
||||
"parity": {
|
||||
"ok": parity_ok,
|
||||
"capacity": int(entry_cfg["max_positions"]),
|
||||
"study_control": (
|
||||
{k: parity_row.get(k) for k in _BREAKEVEN_PARITY_KEYS} if parity_row else None
|
||||
),
|
||||
"monitor_production_all": (
|
||||
{k: monitor_row.get(k) for k in _BREAKEVEN_PARITY_KEYS} if monitor_row else None
|
||||
),
|
||||
},
|
||||
"rows": rows,
|
||||
"verdicts": verdicts,
|
||||
"diagnostic_rows": diagnostic_rows,
|
||||
"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."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _portfolio_monitor(
|
||||
candidates: list[dict],
|
||||
prices: dict[str, tuple],
|
||||
@@ -4508,6 +4769,7 @@ async def run_backtest(
|
||||
portfolio_monitor_report: dict | None = None
|
||||
holdout_report: dict | None = None
|
||||
min_rr_sweep_report: dict | None = None
|
||||
breakeven_study_report: dict | None = None
|
||||
if not _signal_eval_only():
|
||||
try:
|
||||
qual_symbols = sorted({
|
||||
@@ -4572,6 +4834,25 @@ async def run_backtest(
|
||||
candidates, price_columns, spy_closes, activation, current_min_pct,
|
||||
hold_horizon, live_exit_policy=live_exit_policy, cadence=cadence,
|
||||
)
|
||||
if _breakeven_study_enabled():
|
||||
live_capacity: int | None = None
|
||||
try:
|
||||
from app.services.shadow_book_service import get_config
|
||||
|
||||
live_capacity = int((await get_config(db))["capacity"])
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Shadow book capacity load failed; breakeven study uses "
|
||||
"SIM_MAX_POSITIONS"
|
||||
)
|
||||
await _rollback_quietly(db, "shadow book capacity 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,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Portfolio simulation failed")
|
||||
# Catches the price_columns fetch loop, which has no handler of its
|
||||
@@ -4703,6 +4984,9 @@ async def run_backtest(
|
||||
"~6 months ≈ one market regime — treat as directional, not gospel."
|
||||
),
|
||||
}
|
||||
if breakeven_study_report is not None:
|
||||
# Added only when opted in, so the default report keeps its exact shape.
|
||||
report["breakeven_stop_study"] = breakeven_study_report
|
||||
report["recommendation"] = _build_recommendation(report)
|
||||
report["research_recommendation"] = _build_research_recommendation(report)
|
||||
return report
|
||||
|
||||
@@ -48,6 +48,7 @@ score, Structural S/R, the Gate Target Ladder, sentiment, fundamentals) is
|
||||
| 11 | **FIP path-smoothness** as tie-breaker/filter | Non-monotonic within the qualified set; thinning the entry stream costs more compounding than the tilt returns | **Rejected as a filter** — but see §4, it's the strongest raw signal we've measured | — |
|
||||
| 12 | **Fixed take-profit sweep** (R-multiples) | No interior optimum ever found — the best TP is "no TP" | **Rejected.** Momentum's edge lives in the right tail | `backtest_service.py:450` |
|
||||
| 13 | **Sector-residual 12-1** (`mom_12_1_sector_resid` / sector demean) as replacement for market residual | Short-window IC/A/B looked knife-edge green; deep repaired + **liquid-1500** retest: weeks 83, mild +IC **0.027** / t 1.69, **below iron bar 0.03** (FAIL). Demean already weaker | **Rejected / closed.** Keep production market residual. Do not resurrect without a new pre-registered protocol | [sector-residual-momentum.md](sector-residual-momentum.md) · `sector-resid-deep-20260719-113319.json` · history-depth supersession note |
|
||||
| 15 | **Breakeven stop** (stop to entry once the trade is +1R / +2R, on top of the 3× ATR trail) | `be_1r` fails the frozen rule: train worse on Sharpe, CAGR and drawdown, test better on return but worse on drawdown. `be_2r` changed 2 of 480 trades, because the trail already reaches entry near +2R. A post-hoc arm that gates re-entry after breakeven exits does better but still fails on drawdown | **Rejected / closed for this snapshot.** Keep the production exit. Reopen only with a new pre-registered test on unseen data | [breakeven-stop.md](breakeven-stop.md) · `backtest-20261009-breakeven-stop.json` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
# Breakeven stop — pre-registered exit test
|
||||
|
||||
**Date:** 2026-10-09
|
||||
**Question:** a trade ran roughly +10%, the 3× ATR trail still sat near entry,
|
||||
and the pullback closed it at a small loss. Would moving the stop to the entry
|
||||
price once a trade is up by k R turn trades like that into wins *without*
|
||||
costing the book its right tail?
|
||||
|
||||
Every "lock in gains sooner" exit tested so far lost money: a trail of 2× ATR or
|
||||
tighter, the target take-profit (Sharpe 2.04 → 1.47), and every fixed
|
||||
take-profit level ([research log](README.md) #1, #12; method rules 3 and 5). The
|
||||
breakeven stop is the one member of that family never run. The expectation is a
|
||||
fail — just after it triggers it acts like a tighter trail — but it is an
|
||||
untested arm, not a settled one.
|
||||
|
||||
## Protocol (frozen before the run)
|
||||
|
||||
**Data and book.** `backtest_snapshots/prod.sqlite`, daily entry cadence (the
|
||||
live scan is daily, and a rule that frees slots earlier needs daily entries to
|
||||
refill them). The production book exactly as the `portfolio_monitor` production
|
||||
row replays it — live activation gate, 80/20 residual/high-vol rank, post-stop
|
||||
gate reset, 1% risk, 20% notional cap, close fill, 0.1% cost per side — at
|
||||
**capacity 15**, the live setting (the monitor row still uses 10).
|
||||
|
||||
**Arms.** Only the exit changes.
|
||||
|
||||
| arm | rule |
|
||||
|---|---|
|
||||
| `control` | production: 1.5× ATR initial stop, 3× ATR trail on the highest close, 30-session max hold |
|
||||
| `be_1r` | control, plus: once the highest close reaches entry + 1R, the stop rises to the entry price |
|
||||
| `be_2r` | same at entry + 2R |
|
||||
|
||||
R = entry − initial stop. The trigger reads closes, like the trail, and a raised
|
||||
stop is active from the next bar; a gap through it fills at the open. The stop
|
||||
only ever moves up — the trail keeps working above entry. No other trigger
|
||||
levels are run.
|
||||
|
||||
**Windows.** Train = entries before 2024-07-01, test = entries on/after it, as
|
||||
disjoint books (`_simulate_portfolio` `end_date`/`start_date`). Full history is
|
||||
reported but not graded.
|
||||
|
||||
**Pass rule.** An arm passes only if, in **both** train and test, all three hold
|
||||
versus `control` at report precision (Sharpe 2 dp, CAGR and drawdown 1 dp):
|
||||
|
||||
- Sharpe ≥ control
|
||||
- CAGR ≥ control
|
||||
- max drawdown ≤ control
|
||||
|
||||
Win rate and average R per trade are reported but **not** graded: a breakeven
|
||||
stop raises both mechanically, and it changes trade count, so EV per trade is
|
||||
the wrong lens ([capacity findings](portfolio-capacity-bracket-findings.md#correction-2026-08-05-ev-per-trade-was-the-wrong-lens)).
|
||||
|
||||
**Parity guard.** The study also runs `control` at the monitor's capacity 10 on
|
||||
full history. It must equal the `portfolio_monitor` production `all` row
|
||||
(Sharpe, CAGR, drawdown, trades) or the study is measuring a different book and
|
||||
no row is valid.
|
||||
|
||||
**What a pass earns.** The post-2024 window has informed earlier research, so it
|
||||
is not a pristine holdout. A pass earns a forward paper trial only — no
|
||||
production change. A fail closes the question for this snapshot.
|
||||
|
||||
## Reproducing
|
||||
|
||||
`prod.sqlite` predates later ticker/trade columns, so `select(Ticker)` fails on
|
||||
it (see [survivorship-audit.md](survivorship-audit.md#caveats)). Run on a copy
|
||||
synced with `prep_survivorship_snapshot.sync_schema`, which only adds nullable
|
||||
columns and changes no data:
|
||||
|
||||
```bash
|
||||
cp backtest_snapshots/prod.sqlite /tmp/prod-synced.sqlite
|
||||
python -c "import sys; sys.path[:0] = ['.', 'scripts']; from pathlib import Path; \
|
||||
from prep_survivorship_snapshot import sync_schema; sync_schema(Path('/tmp/prod-synced.sqlite'))"
|
||||
BACKTEST_BREAKEVEN_STUDY=1 python scripts/run_backtest_snapshot.py \
|
||||
/tmp/prod-synced.sqlite --cadence daily --workers 7 --allow-spawn \
|
||||
--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`.
|
||||
|
||||
## Result — no change; `be_1r` fails, `be_2r` passes only by being the production exit
|
||||
|
||||
Report: `reports/backtest-20261009-breakeven-stop.json` (daily cadence, 1,011,248
|
||||
candidates, 5,449 qualified; 505 of 506 symbols evaluated — RHM has no stored
|
||||
bars, nothing failed). **Parity held exactly:** the capacity-10 control reproduces
|
||||
the monitor's production row (Sharpe 1.84, CAGR 49.8%, DD 18.3%, 465 trades).
|
||||
The report was regenerated on 2026-10-10 after a code review
|
||||
([corrections below](#code-review-corrections-2026-10-10)); every graded number
|
||||
is unchanged.
|
||||
|
||||
Capacity 15, production book:
|
||||
|
||||
| arm | window | Sharpe (SE) | CAGR | max DD | trades | win % | avg R | ≥5R trades | breakeven exits |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| control | train | 1.71 (0.68) | 47.3% | 17.8% | 243 | 36.6 | 0.563 | 20 | — |
|
||||
| control | test | 1.46 (0.71) | 34.3% | 16.9% | 249 | 35.3 | 0.366 | 12 | — |
|
||||
| control | full | 1.69 (0.50) | 44.3% | 18.3% | 480 | 36.0 | 0.477 | 32 | — |
|
||||
| `be_1r` | train | 1.67 | 45.3% | 18.5% | 254 | 35.0 | 0.595 | 22 | 36 |
|
||||
| `be_1r` | test | 1.71 | 43.1% | 18.1% | 250 | 34.4 | 0.455 | 10 | 46 |
|
||||
| `be_1r` | full | 1.72 | 45.7% | 18.5% | 502 | 34.9 | 0.539 | 32 | 82 |
|
||||
| `be_2r` | train | 1.71 | 47.3% | 17.8% | 243 | 36.6 | 0.563 | 20 | 1 |
|
||||
| `be_2r` | test | 1.46 | 34.3% | 16.9% | 249 | 35.3 | 0.366 | 12 | 1 |
|
||||
| `be_2r` | full | 1.69 | 44.3% | 18.3% | 480 | 36.0 | 0.477 | 32 | 2 |
|
||||
|
||||
**`be_1r`: FAIL.** Train is worse on all three graded metrics (Sharpe 1.71 → 1.67,
|
||||
CAGR 47.3% → 45.3%, DD 17.8% → 18.5%). Test is much better on return (Sharpe
|
||||
1.46 → 1.71, CAGR +8.8pp) but drawdown is still worse (16.9% → 18.1%). The
|
||||
halves pointing opposite ways is the same signature as the rejected max-hold
|
||||
arms: a regime interaction, not an edge. The verdict rests on the frozen rule;
|
||||
no significance test was run on the gaps. The per-arm Sharpe SEs (~0.7) do not
|
||||
bound them either: the arms share most of their trades, so the gap between two
|
||||
arms is far less noisy than either Sharpe on its own.
|
||||
|
||||
82 of 502 trades ended at the breakeven stop, and the book took 22 more trades
|
||||
than control. Those were not freed slots: the count cap never bound in any
|
||||
capacity-15 row (0 refusals). An earlier exit releases the ticker itself (one
|
||||
position per ticker) and the cash it held. The simulator does not count entries
|
||||
dropped or shrunk for lack of cash, so the split between the two is not
|
||||
measured. Only 4 of the 82 exits were bought back in the same session.
|
||||
|
||||
**`be_2r`: passes the rule on ties, and that pass means nothing.** It changed 2
|
||||
of 480 trades. The reason is arithmetic: the initial stop is 1.5× ATR = 1R, and
|
||||
the trail sits 3× ATR = 2R below the highest close. By the time a trade is +2R
|
||||
up, the trail has already reached entry, unless ATR has widened since entry.
|
||||
`be_2r` is therefore the production exit, with nothing to paper-trade.
|
||||
|
||||
Win rate *fell* under `be_1r` (36.0 → 34.9): an exit at entry is a small loss
|
||||
after the 0.1%/side cost, not a win. Average R rose only because trade count and
|
||||
mix changed — the reason it was not graded.
|
||||
|
||||
**Decision: keep the production exit. The breakeven question is closed for this
|
||||
snapshot.** The trade that prompted it (a roughly 10% run that ended below
|
||||
entry) was not identified, so its R is unknown. With ATR unchanged the trail
|
||||
only reaches entry at +2R; that trade either peaked below +2R of its own risk or
|
||||
saw ATR widen during the run. Either way, the rule that would have saved it
|
||||
(`be_1r`, if it reached +1R) costs the book about as much elsewhere: no reliable
|
||||
improvement in either direction.
|
||||
|
||||
**Side observation, not graded (worth its own look):** on this single daily
|
||||
path, the capacity-15 control (Sharpe 1.69, CAGR 44.3%) is *below* the
|
||||
capacity-10 production row (1.84, 49.8%) at the same 18.3% drawdown. At capacity
|
||||
10 the count cap refused **510** entries; at 15 it refused none. Those two
|
||||
counts do not measure the same thing: at 15, cash plus the 20% notional cap is
|
||||
the binding limit, and the entries it drops or shrinks are not counted. What the
|
||||
path does show is that, here, the tighter 10-slot book did better. The 2026-08-05 capacity decision
|
||||
was made across 175 start-date paths (51 better, 2 worse), so one full-history
|
||||
path disagreeing does not overturn it, but a −5.5pp gap is large enough to
|
||||
deserve its own look.
|
||||
|
||||
## Code-review corrections (2026-10-10)
|
||||
|
||||
A review of the study code found several problems. Fixing them changed no graded
|
||||
number. One of them prompted a diagnostic arm.
|
||||
|
||||
**Re-entry after a breakeven exit.** The first run labeled a breakeven exit
|
||||
`trailing_stop`, because the stop sat above the initial stop. It now has its own
|
||||
label, `breakeven_stop`, and the count is exact rather than inferred from the
|
||||
stop price (82 / 36 / 46, as before). Either label means the same thing for
|
||||
re-entry: the live lock (`trade_policy.get_reentry_gate_locks`) applies only
|
||||
after an initial-stop exit. So under `be_1r` a name stopped at breakeven can be
|
||||
bought again without a gate reset. That is how the rule would behave live, so
|
||||
the graded arms keep it. A diagnostic arm, `be_1r_gated`, sends breakeven exits
|
||||
through the post-stop gate reset as an initial stop would. It was added after
|
||||
the first run, so it is reported and never graded.
|
||||
|
||||
| arm | window | Sharpe | CAGR | max DD | trades | breakeven exits | same-session re-buys |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| control | train | 1.71 | 47.3% | 17.8% | 243 | — | — |
|
||||
| control | test | 1.46 | 34.3% | 16.9% | 249 | — | — |
|
||||
| control | full | 1.69 | 44.3% | 18.3% | 480 | — | — |
|
||||
| `be_1r` | train | 1.67 | 45.3% | 18.5% | 254 | 36 | 1 |
|
||||
| `be_1r` | test | 1.71 | 43.1% | 18.1% | 250 | 46 | 3 |
|
||||
| `be_1r` | full | 1.72 | 45.7% | 18.5% | 502 | 82 | 4 |
|
||||
| `be_1r_gated` | train | 1.72 | 47.1% | 18.0% | 253 | 38 | 0 |
|
||||
| `be_1r_gated` | test | 1.78 | 45.0% | 18.1% | 249 | 46 | 0 |
|
||||
| `be_1r_gated` | full | 1.79 | 47.7% | 18.0% | 500 | 84 | 0 |
|
||||
|
||||
The review suspected part of `be_1r`'s test-window lift came from same-session
|
||||
re-buys. It did not: there were only 4. Gating re-entry after breakeven exits
|
||||
did help `be_1r`, though: it improves Sharpe and CAGR in every window and
|
||||
drawdown in two of three. The gate changes *which* re-entries happen, not just
|
||||
whether a name is re-bought, and this is one post-hoc comparison on one path.
|
||||
The result is consistent with the post-stop re-entry finding, where a gate
|
||||
reset beat immediate re-entry ([post-stop-reentry.md](post-stop-reentry.md)),
|
||||
but it does not establish the mechanism.
|
||||
|
||||
**The decision stands.** `be_1r_gated` would also fail the frozen rule:
|
||||
drawdown is worse than control in both windows (17.8% → 18.0% train,
|
||||
16.9% → 18.1% test), and train CAGR is 47.1% against 47.3%. It was also chosen
|
||||
after seeing the data, so it earns nothing here. If the breakeven question is
|
||||
reopened, this is the arm to pre-register. It needs data the study has not
|
||||
seen, and the live gate lock extended to breakeven exits, so that the backtest
|
||||
and live still agree.
|
||||
|
||||
**Other fixes. None moves a graded number.**
|
||||
|
||||
- A breakeven stop now also applies on a `next_open` fill bar. Before, it fired
|
||||
one bar late there. The trail and breakeven rules now share one function. The
|
||||
study uses close fills, so its rows are unaffected.
|
||||
- `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.
|
||||
@@ -1,5 +1,13 @@
|
||||
# Backtest report index
|
||||
|
||||
The 2026-10-09 breakeven-stop exit test is preserved in
|
||||
`backtest-20261009-breakeven-stop.json` (regenerated 2026-10-10 after a code
|
||||
review, with no graded number changed). Its `breakeven_stop_study` section holds
|
||||
the parity guard, the graded rows and verdicts for `control`, `be_1r` and
|
||||
`be_2r`, and the ungraded `be_1r_gated` diagnostic rows. No arm passed with a
|
||||
real difference from control, so the production exit is unchanged.
|
||||
Interpretation: [`breakeven-stop.md`](../docs/research/breakeven-stop.md).
|
||||
|
||||
Reports dated 2026-07-11 or earlier are the historical production research
|
||||
record and remain untouched.
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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