From 3076d22375b8cde86525326c9b466f9391a01c63 Mon Sep 17 00:00:00 2001 From: momentum dev Date: Mon, 29 Jun 2026 20:27:48 -0400 Subject: [PATCH] Exit manager: trail a breakeven-protected position UP via a synthetic R (open-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KEEL was left at a frozen breakeven stop because its original R was lost — the bracket stop leg was invalid (the limit filled below the ORB-low, so the stop sat ABOVE entry and Alpaca cancelled it, which is what left it naked). The manager skipped any position already at breakeven+, so it never trailed: a winner could only scratch or run unprotected-above-breakeven. Now a position first seen at breakeven+ with no real risk distance is tracked on a SYNTHETIC R (ExitConfig.default_trail_r_pct, default 10% of entry, env TRADING_EXIT_DEFAULT_TRAIL_R_PCT), so the SAME tested +0.25R step-trail / profit-lock ratchet the broker stop UP as it rises (only ever up, never below the live stop). default_trail_r_pct=0 keeps the old frozen behaviour; positions with a real sub-entry stop are unchanged. TRAILING is gated on the session being OPEN (one broker-clock check per pass): after hours there are no new highs to trail under AND a resting GTC stop is often not replaceable ("accepted" status -> Alpaca 422), so attempting it just churns. The stop stays protective and ratchets up at the open. 4 tests added (synthetic-R trails up; disabled=frozen; closed=no-attempt); 10/10 exit tests green. Live now (after-hours): KEEL is protected at breakeven $5.56; the loop holds that stop without churning and will trail it up once the session opens. Co-Authored-By: Claude Opus 4.8 --- runtime/exit_manager.py | 28 ++++++++++++++---- strategy/exits.py | 8 +++++ tests/unit/test_exit_manager.py | 52 +++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 5 deletions(-) diff --git a/runtime/exit_manager.py b/runtime/exit_manager.py index 2b1173e..9a3c209 100644 --- a/runtime/exit_manager.py +++ b/runtime/exit_manager.py @@ -177,6 +177,7 @@ class LiveExitManager: # reuse it for every position's stop-leg + entry-time lookups), scoped to # the held symbols so a busy day's order volume can't truncate the legs. orders = self._all_orders(held.keys()) + mkt_open = self._market_open() # once per pass — gates flatten-vs-rest + trailing actions: list[str] = [] for sym, p in held.items(): @@ -195,7 +196,7 @@ class LiveExitManager: self.cfg.catastrophe_risk_mult)): loss_pct = (entry - cur) / entry * 100 try: - if self._market_open(): + if mkt_open: self._flatten(sym, orders) self._emit(sym, f"CATASTROPHE exit {sym} @~{cur:.4f} " f"({loss_pct:.1f}% below entry)", "exit_catastrophe", @@ -230,7 +231,7 @@ class LiveExitManager: self._naked_passes[sym] = n if n >= grace: try: - if self._market_open(): + if mkt_open: # session open: a market flatten fills — the safe exit. self._flatten(sym, orders) self._emit(sym, f"naked-stop enforced exit {sym} " @@ -255,6 +256,13 @@ class LiveExitManager: continue # don't run trail logic on a naked position self._naked_passes.pop(sym, None) # has a live stop + # TRAILING runs only while the session is OPEN: after-hours there are no new + # highs to trail under, and a resting GTC stop is often not replaceable yet + # ("accepted" status -> Alpaca 422 on replace), so attempting it just churns. + # The stop stays protective; it ratchets up at the open. + if not mkt_open: + continue + try: bars = self.bars_fn(sym) except Exception: # noqa: BLE001 @@ -262,12 +270,22 @@ class LiveExitManager: if bars is None or len(bars) == 0: continue if sym not in self._tracked: - if cur_stop is None or cur_stop >= entry: - continue # protected at breakeven+; broker stop handles it (no trail-from-init) + if cur_stop is None: + continue # naked — handled by the enforcement above # trail from the ACTUAL entry fill time (so high-water/R reflect # the move since entry, not since we first noticed the position) entry_ts = self._entry_time(sym, orders) or _last_ts(bars) - self._tracked[sym] = _Tracked(entry, cur_stop, entry_ts, cur_stop) + if cur_stop >= entry: + # already at BREAKEVEN+ with no real R to trail from (the original + # stop was stripped/invalid). Trail on a SYNTHETIC R so the step-trail + # / profit-lock still ratchet the broker stop UP as it rises (only ever + # up, never below the live breakeven stop). 0 = leave it frozen. + if not self.cfg.default_trail_r_pct: + continue + init = entry * (1.0 - self.cfg.default_trail_r_pct) + self._tracked[sym] = _Tracked(entry, init, entry_ts, cur_stop) + else: + self._tracked[sym] = _Tracked(entry, cur_stop, entry_ts, cur_stop) tr = self._tracked[sym] since = _bars_since(bars, tr.entry_ts) diff --git a/strategy/exits.py b/strategy/exits.py index 1c96a5a..bdc54aa 100644 --- a/strategy/exits.py +++ b/strategy/exits.py @@ -89,6 +89,13 @@ class ExitConfig: # filled bracket's legs can attach first). 0 = off. The preventive complement # to the catastrophe stop (which is the -X% backstop). enforce_stop_grace_passes: int = 2 + # When a held position is first seen already protected at BREAKEVEN+ but its + # original R is lost (the bracket stop was stripped or invalid — e.g. a fill below + # the ORB-low left the stop > entry), there is no real risk distance to trail from, + # so the stop would sit frozen at breakeven. Trail it on a SYNTHETIC R of this + # fraction of entry instead, so the step-trail / profit-lock ratchet it UP as the + # name rises (only ever up, never below the live breakeven stop). 0 = leave frozen. + default_trail_r_pct: float = 0.10 @classmethod def from_env(cls, env: dict | None = None) -> "ExitConfig": @@ -120,6 +127,7 @@ class ExitConfig: catastrophe_pct=f("TRADING_CATASTROPHE_STOP_PCT", "0.10"), catastrophe_risk_mult=f("TRADING_CATASTROPHE_RISK_MULT", "1.5"), enforce_stop_grace_passes=int(f("TRADING_ENFORCE_STOP_GRACE_PASSES", "2")), + default_trail_r_pct=f("TRADING_EXIT_DEFAULT_TRAIL_R_PCT", "0.10"), ) def describe(self) -> str: diff --git a/tests/unit/test_exit_manager.py b/tests/unit/test_exit_manager.py index df98276..917c6e1 100644 --- a/tests/unit/test_exit_manager.py +++ b/tests/unit/test_exit_manager.py @@ -168,6 +168,58 @@ def test_naked_underwater_rests_catastrophe_stop_when_closed(): assert broker.submitted[0]["stop_price"] == 8.1 # 9.0 * (1 - 0.10), below market +def test_breakeven_position_trails_up_on_synthetic_r(): + """A position found already at BREAKEVEN (stop == entry, original R lost) must still + trail UP as it rises — on a synthetic R — instead of sitting frozen at breakeven.""" + store = EventStore(":memory:") + pos = [{"symbol": "AAA", "avg_entry_price": "10.0", "current_price": "11.0", "qty": "100"}] + leg = {"id": "be", "symbol": "AAA", "type": "stop", "side": "sell", + "stop_price": "10.0", "status": "accepted"} # stop AT entry = breakeven + broker = _FakeBroker(pos, leg) + bars = _bars([(10.2, 10.0, 10.1), (10.6, 10.2, 10.5), (11.0, 10.6, 11.0)]) # +10% + mgr = LiveExitManager( + broker, store, lambda s: bars, + cfg=ExitConfig(trail_r_step=0.25, breakeven_at_pct=0.05, default_trail_r_pct=0.10), + session_id="t", + ) + mgr.manage() + assert broker.replaced, "breakeven position should trail up, not freeze" + new_stop = broker.replaced[-1][1] + assert 10.0 < new_stop < 11.0 # ratcheted ABOVE breakeven, below market + + +def test_no_trail_attempt_when_market_closed(): + """Trailing runs only while the session is OPEN — after-hours there are no new highs + and a resting GTC stop often isn't replaceable, so the manager leaves the stop as-is.""" + store = EventStore(":memory:") + pos = [{"symbol": "AAA", "avg_entry_price": "10.0", "current_price": "11.0", "qty": "100"}] + leg = {"id": "be", "symbol": "AAA", "type": "stop", "side": "sell", + "stop_price": "10.0", "status": "accepted"} + broker = _FakeBroker(pos, leg, market_open=False) + mgr = LiveExitManager( + broker, store, lambda s: _bars([(11.0, 10.6, 11.0)]), + cfg=ExitConfig(trail_r_step=0.25, default_trail_r_pct=0.10), session_id="t", + ) + mgr.manage() + assert broker.replaced == [] # closed -> no trail attempt (no churn) + + +def test_breakeven_position_frozen_when_disabled(): + """default_trail_r_pct=0 keeps the old behaviour — a breakeven stop is left frozen.""" + store = EventStore(":memory:") + pos = [{"symbol": "AAA", "avg_entry_price": "10.0", "current_price": "11.0", "qty": "100"}] + leg = {"id": "be", "symbol": "AAA", "type": "stop", "side": "sell", + "stop_price": "10.0", "status": "accepted"} + broker = _FakeBroker(pos, leg) + mgr = LiveExitManager( + broker, store, lambda s: _bars([(11.0, 10.6, 11.0)]), + cfg=ExitConfig(trail_r_step=0.25, default_trail_r_pct=0.0), + session_id="t", + ) + mgr.manage() + assert broker.replaced == [] # frozen at breakeven + + def test_position_with_stop_not_flattened_as_naked(): """A position WITH a live stop must never be naked-flattened.""" store = EventStore(":memory:")