diff --git a/runtime/exit_manager.py b/runtime/exit_manager.py index cac9db2..7ffb17b 100644 --- a/runtime/exit_manager.py +++ b/runtime/exit_manager.py @@ -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 diff --git a/strategy/exits.py b/strategy/exits.py index 87c2dd3..02bff1a 100644 --- a/strategy/exits.py +++ b/strategy/exits.py @@ -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) diff --git a/tests/unit/test_exit_fail_fast.py b/tests/unit/test_exit_fail_fast.py new file mode 100644 index 0000000..c8abcfb --- /dev/null +++ b/tests/unit/test_exit_fail_fast.py @@ -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()