feat: fail-fast exit for breakouts that fail inside their first minute (off by default)

At the first exit-thread pass >= TRADING_EXIT_FAIL_FAST_SECONDS after the fill,
flatten if price has already traded TRADING_EXIT_FAIL_FAST_R x R below entry.
It is checked once per position. A position first seen after its window (a
restart or a carry) is never judged late. 0 seconds = off (the default).

Evidence, from tick data on 1,093 live-band v1 ORB breaks (Jun-Sep 2026) with
outcomes under the current exit rules:
  - 60 s / 0.3R: +0.060R per trade Jun-Jul (90% CI +0.015..+0.102) and +0.062R
    Aug-Sep (+0.029..+0.094). It fired on 29% of trades and cut 22 runners.
  - Every threshold from -0.15R to -0.4R at 60 s was positive in both halves,
    a plateau rather than one fitted cell.
  - On our own 194 real fills (10-s bars): +0.015R. Same sign, not yet
    significant.

It is deliberately narrow. The same study found that later "bail out below
+1R" rules kill runners: the LLM trade-manager pilot, the hybrid bailout, and
orderflow H1 (Jul 17). The first minute is the one window where failure tells
you something. Tape-flow features (aggressor buy share, print rate, big
prints, quote imbalance, spread) separated nothing (AUC 0.44-0.59, not stable
across halves).

Live-only: it needs sub-minute prices, so simulate_exit (minute bars) does not
model it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3S5EFKThFVrjmN4HHsHMN
This commit is contained in:
momentum devandClaude Opus 5.5 committed 2026-09-26 19:20:17 -05:00
1 parent 3d0ee52214
commit 53725854f4
3 files changed
+172 -2

No files matched your search

+57 -2
View File
@@ -29,6 +29,14 @@ logger = logging.getLogger(__name__)
_FETCH_FAILED = object()
@dataclass
class _FailFast:
entry_ts: object # UTC-naive fill time
risk: float # entry - the stop seen at first sight (the original 1R)
low: float # lowest price sampled since first sight
done: bool = False
@dataclass
class _Tracked:
entry_price: float
@@ -42,12 +50,12 @@ def _is_active(cfg: ExitConfig) -> bool:
or cfg.trail_mode != TRAIL_NONE
or cfg.scale_out_r or cfg.first_red_exit
or cfg.profit_lock_tiers or cfg.catastrophe_pct
or cfg.enforce_stop_grace_passes))
or cfg.enforce_stop_grace_passes or cfg.fail_fast_seconds))
class LiveExitManager:
def __init__(self, client, store, bars_fn, cfg: ExitConfig | None = None,
session_id=None, mode: EventMode = EventMode.PAPER):
session_id=None, mode: EventMode = EventMode.PAPER, now_fn=None):
self.client = client
self.store = store
self.bars_fn = bars_fn # (symbol) -> today's RTH minute bars DataFrame
@@ -58,6 +66,8 @@ class LiveExitManager:
self._naked_passes: dict[str, int] = {} # consecutive passes a held position had no live stop
self._open_anchored: set[str] = set() # breakeven carries re-anchored to today's opening range
self._empty_snapshot_streak = 0 # consecutive empty-but-successful order snapshots (Alpaca can silently return [])
self._ff: dict[str, _FailFast] = {} # fail-fast state per held symbol
self._now = now_fn or (lambda: pd.Timestamp.utcnow().tz_localize(None))
# -- broker helpers ----------------------------------------------------
@@ -168,6 +178,44 @@ class LiveExitManager:
best = ts
return best
def _fail_fast(self, sym, entry, cur, cur_stop, orders, actions) -> bool:
"""Exit a breakout that failed inside its first ``fail_fast_seconds``.
Tracks the lowest price sampled since the fill (this runs every exit-thread
pass, ~1 s). At the first pass at/after the window it checks ONCE: if the low
went >= fail_fast_r * R below entry, flatten. A position first seen well after
its window (restart, carry) is never judged late. Returns True if it exited."""
st = self._ff.get(sym)
now = self._now()
if st is None:
ets = self._entry_time(sym, orders)
risk = entry - cur_stop if cur_stop is not None else 0.0
if ets is None or risk <= 0:
self._ff[sym] = _FailFast(ets, 0.0, cur, done=True)
return False
late = (now - ets).total_seconds() > self.cfg.fail_fast_seconds + 30
st = self._ff[sym] = _FailFast(ets, risk, cur, done=late)
if st.done:
return False
st.low = min(st.low, cur)
if (now - st.entry_ts).total_seconds() < self.cfg.fail_fast_seconds:
return False
st.done = True
dip_r = (entry - st.low) / st.risk
if dip_r < self.cfg.fail_fast_r:
return False
try:
if self._flatten(sym, orders):
self._emit(sym, f"fail-fast exit {sym} @~{cur:.4f}: dipped {dip_r:.2f}R in the first "
f"{self.cfg.fail_fast_seconds:g}s", "exit_fail_fast",
{"price": cur, "entry": entry, "low": st.low, "dip_r": dip_r})
actions.append(f"{sym} fail-fast exit (-{dip_r:.2f}R in {self.cfg.fail_fast_seconds:g}s)")
self._tracked.pop(sym, None)
return True
except Exception as exc: # noqa: BLE001
logger.warning("fail-fast exit failed %s: %s", sym, exc)
return False
def _emit(self, symbol: str, message: str, rule: str, state: dict) -> None:
from datetime import datetime
self.store.emit(RiskRuleTriggeredEvent(
@@ -190,6 +238,9 @@ class LiveExitManager:
if sym not in held:
self._tracked.pop(sym, None)
self._open_anchored.discard(sym)
for sym in list(self._ff):
if sym not in held:
self._ff.pop(sym, None)
if not held:
return []
# fetch the orders snapshot ONCE per pass (status="all" is the heavy call;
@@ -310,6 +361,10 @@ class LiveExitManager:
if not mkt_open:
continue
if (self.cfg.fail_fast_seconds and is_long and entry > 0 and cur > 0
and self._fail_fast(sym, entry, cur, cur_stop, orders, actions)):
continue
try:
bars = self.bars_fn(sym)
except Exception: # noqa: BLE001
+16
View File
@@ -112,6 +112,18 @@ class ExitConfig:
# fraction of entry) used only until the opening range has formed, so a carry still
# ratchets up from the first pass. Only ever ratchets up. 0 = leave the stop frozen.
default_trail_r_pct: float = 0.10
# FAIL-FAST: at the first check >= fail_fast_seconds after the fill, exit at market
# if price has already traded fail_fast_r * R below entry. A breakout that fails
# inside its first minute rarely recovers into a runner: over 1,093 live-band v1
# ORB breaks (Jun-Sep 2026, tick data) exiting at +60 s after a >= 0.3R dip added
# +0.060R/trade (Jun-Jul) and +0.062R (Aug-Sep); neighbouring settings (-0.15..-0.4R)
# were positive in both halves, and it cut 22 of ~190 runners. On our own 194 real
# fills it was +0.015R (same sign, not yet significant). Only checked ONCE, early:
# the later "bail out below +1R" variants killed runners (tape study, LLM manager
# pilot, orderflow H1). Live-only: needs sub-minute prices, so simulate_exit (minute
# bars) does not model it. 0 = off.
fail_fast_seconds: float = 0.0
fail_fast_r: float = 0.3
@classmethod
def from_env(cls, env: dict | None = None) -> "ExitConfig":
@@ -148,6 +160,8 @@ class ExitConfig:
# clamp to [0, 1): a synthetic risk distance must be a positive fraction of
# entry below it; >=1 or negative would put the synthetic stop at/above entry.
default_trail_r_pct=min(max(f("TRADING_EXIT_DEFAULT_TRAIL_R_PCT", "0.10"), 0.0), 0.95),
fail_fast_seconds=max(f("TRADING_EXIT_FAIL_FAST_SECONDS", "0"), 0.0),
fail_fast_r=max(f("TRADING_EXIT_FAIL_FAST_R", "0.3"), 0.0),
)
def describe(self) -> str:
@@ -174,6 +188,8 @@ class ExitConfig:
parts.append(f"enforce-stop({self.enforce_stop_grace_passes}p)")
if self.default_trail_r_pct:
parts.append(f"synth-trail {self.default_trail_r_pct:.0%}")
if self.fail_fast_seconds:
parts.append(f"fail-fast -{self.fail_fast_r:g}R@{self.fail_fast_seconds:g}s")
return ", ".join(parts)
+99
View File
@@ -0,0 +1,99 @@
"""Fail-fast: a breakout that dips >= fail_fast_r * R inside its first window is exited once."""
import pandas as pd
from runtime.exit_manager import LiveExitManager
from strategy.exits import ExitConfig
FILL = pd.Timestamp("2026-09-28 13:40:00") # UTC-naive, as _entry_time returns
class _Store:
def __init__(self):
self.events = []
def emit(self, e):
self.events.append(e)
class _Broker:
def __init__(self):
self.price = 10.0
self.closed = []
self.canceled = []
def get_positions(self):
return [{"symbol": "AAA", "avg_entry_price": "10.0", "qty": "100", "side": "long",
"current_price": str(self.price)}]
def get_orders(self, status="all", limit=500, nested=True, symbols=None):
return [{"id": "buy1", "symbol": "AAA", "side": "buy", "filled_qty": "100",
"filled_at": FILL.isoformat() + "Z", "status": "filled",
"legs": [{"id": "stop1", "symbol": "AAA", "type": "stop", "side": "sell",
"stop_price": "9.0", "status": "held"}]}]
def get_clock(self):
return {"is_open": True}
def cancel_order(self, oid):
self.canceled.append(oid)
def close_position(self, symbol, qty=None, percentage=None):
self.closed.append(symbol)
return {"id": "c"}
def _mgr(broker, clock, seconds=60.0, r=0.3):
cfg = ExitConfig(catastrophe_pct=0.0, enforce_stop_grace_passes=0, fail_fast_seconds=seconds,
fail_fast_r=r, default_trail_r_pct=0.0)
return LiveExitManager(broker, _Store(), lambda s: pd.DataFrame(), cfg=cfg,
session_id="t", now_fn=lambda: clock["now"])
def _run(prices_by_second, seconds=60.0, first_seen=5):
b = _Broker(); clock = {"now": FILL}
m = _mgr(b, clock, seconds)
for s, px in prices_by_second:
if s < first_seen:
continue
clock["now"] = FILL + pd.Timedelta(seconds=s); b.price = px
m.manage()
return b, m
def test_deep_early_dip_exits_once_at_the_window():
# R = 1.00 (entry 10, stop 9); dips to 9.65 (-0.35R) at 20 s, recovers, checked at 60 s
b, m = _run([(5, 10.0), (20, 9.65), (40, 9.9), (59, 10.02), (60, 10.05), (61, 10.1)])
assert b.closed == ["AAA"]
def test_shallow_dip_holds():
b, _ = _run([(5, 10.0), (20, 9.8), (60, 9.95), (75, 9.9)]) # -0.2R only
assert b.closed == []
def test_no_exit_before_the_window_ends():
b, _ = _run([(5, 10.0), (20, 9.5), (59, 9.5)])
assert b.closed == []
def test_position_first_seen_late_is_never_judged():
b, _ = _run([(200, 9.5), (201, 9.4)], first_seen=200)
assert b.closed == []
def test_off_by_default_does_nothing():
cfg = ExitConfig()
assert cfg.fail_fast_seconds == 0.0
b = _Broker(); b.price = 9.0
clock = {"now": FILL + pd.Timedelta(seconds=90)}
LiveExitManager(b, _Store(), lambda s: pd.DataFrame(), cfg=ExitConfig(catastrophe_pct=0.0,
enforce_stop_grace_passes=0, default_trail_r_pct=0.0),
now_fn=lambda: clock["now"]).manage()
assert b.closed == []
def test_env_parsing():
cfg = ExitConfig.from_env({"TRADING_EXIT_FAIL_FAST_SECONDS": "60", "TRADING_EXIT_FAIL_FAST_R": "0.3"})
assert cfg.fail_fast_seconds == 60.0 and cfg.fail_fast_r == 0.3
assert "fail-fast -0.3R@60s" in cfg.describe()