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:
1 parent
1be2dfd042
commit
db4272fe24
3 files changed
+79
-3
No files matched your search
+26
-3
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user