Exit manager: trail a breakeven-protected position UP via a synthetic R (open-only)

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 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Opus 4.8 committed 2026-06-29 20:32:11 -04:00
1 parent b5909bc793
commit 3076d22375
3 files changed
+83 -5

No files matched your search

+23 -5
View File
@@ -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)
+8
View File
@@ -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:
+52
View File
@@ -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:")