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:
+30
View File
@@ -70,6 +70,13 @@ class ExitConfig:
# trade has been up gain%, the stop never sits below entry*(1+lock%) — so a
# winner can only give back down to a locked-in minimum gain.
profit_lock_tiers: list = field(default_factory=list)
# CATASTROPHE STOP — a hard market-exit backstop that does NOT depend on the
# broker bracket stop being live. Fires when price is >= catastrophe_pct below
# entry, OR (when a stop is known) >= catastrophe_risk_mult x the intended 1R
# underwater. Catches a missing/failed protective stop (NIVF ran to -23% naked,
# = the entire net loss). 0 = off.
catastrophe_pct: float = 0.10
catastrophe_risk_mult: float = 1.5
@classmethod
def from_env(cls, env: dict | None = None) -> "ExitConfig":
@@ -97,6 +104,8 @@ class ExitConfig:
scale_out_pct=f("TRADING_EXIT_SCALE_OUT_PCT", "0.5"),
first_red_exit=b("TRADING_EXIT_FIRST_RED", "0"),
profit_lock_tiers=parse_profit_tiers(v.get("TRADING_EXIT_PROFIT_TIERS", "")),
catastrophe_pct=f("TRADING_CATASTROPHE_STOP_PCT", "0.10"),
catastrophe_risk_mult=f("TRADING_CATASTROPHE_RISK_MULT", "1.5"),
)
def describe(self) -> str:
@@ -115,9 +124,30 @@ class ExitConfig:
if self.profit_lock_tiers:
parts.append("lock " + "/".join(
f"+{g * 100:g}%->{lk * 100:g}%" for g, lk in self.profit_lock_tiers))
if self.catastrophe_pct:
parts.append(f"catastrophe@-{self.catastrophe_pct:.0%}/{self.catastrophe_risk_mult:g}xR")
return ", ".join(parts)
def catastrophe_triggered(entry: float, current: float, stop: float | None,
pct: float, risk_mult: float) -> bool:
"""True if a LONG position is catastrophically underwater and must be
market-exited NOW regardless of the broker bracket — the hard backstop for a
missing or non-firing protective stop. Fires when price is >= ``pct`` below
entry, OR (when a real stop is known) >= ``risk_mult`` x the intended 1R
risk underwater. ``pct`` <= 0 disables the percentage arm."""
if not entry or not current or current <= 0 or entry <= 0:
return False
loss_frac = (entry - current) / entry # >0 = underwater (long)
if pct and pct > 0 and loss_frac >= pct:
return True
if stop is not None and stop < entry and risk_mult and risk_mult > 0:
risk = entry - stop
if risk > 0 and current <= entry - risk_mult * risk:
return True
return False
@dataclass
class ExitFill:
frac: float # fraction of the original position closed by this fill
+23
View File
@@ -10,6 +10,7 @@ from strategy.exits import (
ExitConfig,
TRAIL_PCT,
TRAIL_PRIOR_LOW,
catastrophe_triggered,
manage_live,
parse_profit_tiers,
simulate_exit,
@@ -21,6 +22,28 @@ def test_parse_profit_tiers():
assert parse_profit_tiers("") == []
def test_catastrophe_pct_arm():
# 10% pct floor, no known stop -> the only thing protecting a naked position
assert catastrophe_triggered(1.00, 0.89, None, 0.10, 1.5) is True # -11%, fires
assert catastrophe_triggered(1.00, 0.92, None, 0.10, 1.5) is False # -8%, safe
# NIVF: entry 0.9936 ran to 0.76 (-23.4%) with NO stop -> would have fired
assert catastrophe_triggered(0.9936, 0.7608, None, 0.10, 1.5) is True
def test_catastrophe_risk_arm_fires_before_pct():
# tight 5% stop: -1.5R = -7.5% < the 10% pct floor, so the risk arm catches it sooner
assert catastrophe_triggered(1.00, 0.92, 0.95, 0.10, 1.5) is True # past -1.5R (-1.6R)
assert catastrophe_triggered(1.00, 0.93, 0.95, 0.10, 1.5) is False # -1.4R, not yet
# a stop at/above entry is not a usable risk reference -> only the pct arm applies
assert catastrophe_triggered(1.00, 0.94, 1.00, 0.10, 1.5) is False
def test_catastrophe_off_and_in_profit():
assert catastrophe_triggered(1.00, 0.50, None, 0.0, 1.5) is False # pct arm disabled, no stop
assert catastrophe_triggered(1.00, 1.20, 0.95, 0.10, 1.5) is False # in profit
assert catastrophe_triggered(1.00, 0.0, None, 0.10, 1.5) is False # bad price -> no-op
def test_profit_lock_keeps_minimum_gain():
# entry 10, stop 9. price runs to +12% (11.2) then fades; +8%->lock+3% tier
# pins the stop to 10.30, so the fade exits in profit instead of at the stop.