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)