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:
1 parent
b5909bc793
commit
3076d22375
3 files changed
+82
-4
No files matched your search
+22
-4
@@ -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,11 +270,21 @@ 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)
|
||||
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]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:")
|
||||
|
||||
Reference in new issue
Block a user