Catastrophe stop: hard backstop for a missing/non-firing protective stop

Diagnosis (why-strategy-loses-naked-stop): the net loss is ONE naked-stop blowup
— NIVF ran to -23% with no stop that fired (-$3,737 = ~111% of the loss); ex-NIVF
the book is +$380. And the exit manager SKIPS any position without a usable stop
(line ~160), so a naked position gets zero exit management and runs to EOD.

New catastrophe stop runs FIRST and unconditionally on every held position,
before the bars fetch and before that no-stop skip: market-exit (via the robust
cancel-legs-then-close path) when price is >= catastrophe_pct below entry (def
10%, TRADING_CATASTROPHE_STOP_PCT) OR >= catastrophe_risk_mult x the intended 1R
underwater when a stop is known (def 1.5x). Independent of the broker bracket, so
a missing/failed stop can't run to -23% again. Long-only; in-profit and bad-price
are no-ops; 0 disables.

Validated: would have CAUGHT NIVF at -10% (~-$1.6k vs -$3.7k), leaves normal -1R
stops (BTBT -4.2%) alone. Pure `catastrophe_triggered` + manage() wiring +
`_is_active`/describe updated. Tests cover pct arm, risk arm, in-profit, disabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Opus 4.8 committed 2026-06-22 16:23:11 -04:00
1 parent 1be2dfd042
commit db4272fe24
3 files changed
+79 -3

No files matched your search

+26 -3
View File
@@ -20,7 +20,7 @@ import pandas as pd
from runtime.flatten import cancel_protective_and_close
from storage.event_schema import EventMode, RiskRuleTriggeredEvent
from strategy.exits import ExitConfig, TRAIL_NONE, manage_live
from strategy.exits import ExitConfig, TRAIL_NONE, catastrophe_triggered, manage_live
logger = logging.getLogger(__name__)
@@ -38,7 +38,7 @@ def _is_active(cfg: ExitConfig) -> bool:
return bool(cfg and (cfg.breakeven_at_r or cfg.breakeven_at_pct
or cfg.trail_mode != TRAIL_NONE
or cfg.scale_out_r or cfg.first_red_exit
or cfg.profit_lock_tiers))
or cfg.profit_lock_tiers or cfg.catastrophe_pct))
class LiveExitManager:
@@ -146,13 +146,36 @@ class LiveExitManager:
actions: list[str] = []
for sym, p in held.items():
entry = float(p.get("avg_entry_price") or 0.0)
cur = float(p.get("current_price") or 0.0)
# CATASTROPHE STOP — the hard backstop, FIRST and unconditional. Runs on
# EVERY held position, before the bars fetch and before the no-stop skip
# below that otherwise leaves a NAKED position (no working bracket) with
# zero exit management — the exact NIVF case that ran to -23% and was
# ~the entire net loss. Doesn't depend on bars or a live stop.
if (self.cfg.catastrophe_pct and entry > 0 and cur > 0
and str(p.get("side") or "long").lower() != "short"):
_leg0, cata_stop = self._stop_leg(sym, orders)
if catastrophe_triggered(entry, cur, cata_stop,
self.cfg.catastrophe_pct,
self.cfg.catastrophe_risk_mult):
loss_pct = (entry - cur) / entry * 100
try:
self._flatten(sym, orders)
self._tracked.pop(sym, None)
self._emit(sym, f"CATASTROPHE exit {sym} @~{cur:.4f} "
f"({loss_pct:.1f}% below entry)", "exit_catastrophe",
{"price": cur, "entry": entry, "stop": cata_stop})
actions.append(f"{sym} CATASTROPHE-exit @{cur:.2f} (-{loss_pct:.0f}%)")
except Exception as exc: # noqa: BLE001
logger.warning("catastrophe exit failed %s: %s", sym, exc)
continue
try:
bars = self.bars_fn(sym)
except Exception: # noqa: BLE001
continue
if bars is None or len(bars) == 0:
continue
entry = float(p.get("avg_entry_price") or 0.0)
if entry <= 0:
continue
if sym not in self._tracked: