Files
momentum-system/tests/unit/test_exit_manager.py
momentum devandClaude Fable 5 d16eb85ee9 money-path: daily self-restart + SIGTERM + positive-evidence exit gate
Plan 003 steps 1-3.
- Loop exit(0) after a SUCCESSFUL EOD flatten (TRADING_EXIT_AFTER_EOD_FLATTEN,
  default on); compose restarts fresh. Restart-churn guard: _rebuild_daily_state
  detects today's eod_flatten event and seeds _session_closed/_eod_done so a
  post-flatten restart neither re-flattens nor re-exits. SIGTERM = clean drain.
- Exit manager: _FETCH_FAILED sentinel distinguishes a failed get_orders from a
  genuinely-empty snapshot. A failed fetch skips naked-stop enforcement (catastrophe
  still exits, re-fetching); an empty-but-successful snapshot needs TWO consecutive
  reads before 'naked' is trusted (Alpaca silently returns [] for symbols=). Costs
  ~one 12s pass on the all-naked case only; isolated naked positions unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:20:37 -04:00

311 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Live exit manager: ratchet the stop UP via replace (never cancel = never naked)."""
import pandas as pd
from runtime.exit_manager import LiveExitManager
from storage.event_store import EventStore
from strategy.exits import ExitConfig, TRAIL_PRIOR_LOW
class _FakeBroker:
def __init__(self, positions, stop_leg, market_open=True):
self._positions = positions
self._stop_leg = stop_leg
self.market_open = market_open
self.replaced = []
self.closed = []
self.canceled = []
self.submitted = []
def get_positions(self):
return self._positions
def get_orders(self, status="open", limit=200, nested=True, symbols=None):
return [self._stop_leg]
def get_clock(self):
return {"is_open": self.market_open}
def submit_order(self, symbol, qty, side="buy", order_type="market",
stop_price=None, time_in_force="day", **kw):
self.submitted.append({"symbol": symbol, "qty": qty, "side": side,
"type": order_type, "stop_price": stop_price})
return {"id": "s"}
def replace_order(self, order_id, stop_price=None, **kw):
self.replaced.append((order_id, stop_price))
return {"id": order_id}
def cancel_order(self, order_id):
self.canceled.append(order_id)
def close_position(self, symbol, qty=None, percentage=None):
self.closed.append((symbol, qty))
return {"id": "c"}
def _bars(seq):
base = pd.Timestamp("2026-06-17 13:30:00")
return pd.DataFrame([
{"timestamp": base + pd.Timedelta(minutes=i), "high": h, "low": lo, "close": c}
for i, (h, lo, c) in enumerate(seq)
])
def test_manager_ratchets_stop_up_and_never_cancels():
store = EventStore(":memory:")
pos = [{"symbol": "AAA", "avg_entry_price": "10.0", "qty": "100"}]
leg = {"id": "stop1", "symbol": "AAA", "type": "stop", "side": "sell",
"stop_price": "9.0", "status": "held"}
broker = _FakeBroker(pos, leg)
bars = _bars([(10.5, 9.8, 10.4), (11.5, 10.5, 11.4), (12.2, 11.5, 12.0)])
mgr = LiveExitManager(
broker, store, lambda s: bars,
cfg=ExitConfig(breakeven_at_r=1.0, trail_mode=TRAIL_PRIOR_LOW, trail_after_r=1.0),
session_id="t",
)
mgr.manage()
assert broker.replaced, "expected the stop to be moved up"
order_id, new_stop = broker.replaced[-1]
assert order_id == "stop1" and new_stop > 9.0 # ratcheted UP
assert broker.canceled == [] # never cancels the protective leg
def test_flatten_cancels_protective_orders_then_closes():
"""A market close is rejected while the bracket legs hold the qty
(held_for_orders). _flatten must cancel the protective sells FIRST, then
liquidate — otherwise the trail/first-red exit 403s every pass forever."""
class _HeldBroker:
def __init__(self):
self.canceled = []
self.closed = []
self._held = True # qty locked by the resting protective orders
def close_position(self, symbol, qty=None, percentage=None):
if self._held:
raise RuntimeError("403 insufficient qty available for order")
self.closed.append(symbol)
return {"id": "c"}
def cancel_order(self, order_id):
self.canceled.append(order_id)
self._held = False # releasing the legs frees the shares
broker = _HeldBroker()
mgr = LiveExitManager(broker, EventStore(":memory:"), lambda s: None,
cfg=ExitConfig(trail_mode=TRAIL_PRIOR_LOW), session_id="t")
orders = [
{"symbol": "AAA", "side": "sell", "status": "held", "type": "stop", "id": "stop1"},
{"symbol": "AAA", "side": "sell", "status": "new", "type": "limit", "id": "tp1"},
{"symbol": "BBB", "side": "sell", "status": "held", "type": "stop", "id": "other"},
]
mgr._flatten("AAA", orders)
assert set(broker.canceled) == {"stop1", "tp1"} # released AAA's qty (not BBB's)
assert broker.closed == ["AAA"] # then liquidated
class _NakedBroker(_FakeBroker):
def get_orders(self, status="open", limit=200, nested=True, symbols=None):
# naked until a protective stop is rested, then reflect it (like the real
# broker) so the NEXT pass sees the stop and stops re-resting/enforcing.
return [{"id": "rested", "symbol": o["symbol"], "type": o["type"],
"side": o["side"], "stop_price": str(o["stop_price"]), "status": "accepted"}
for o in self.submitted]
def test_naked_position_flattened_after_grace_when_market_open():
"""A held position with NO live protective stop (bracket leg never attached
or got stripped) is the NIVF case — flatten it after grace WHEN THE MARKET IS OPEN
(a market exit can fill)."""
store = EventStore(":memory:")
pos = [{"symbol": "AAA", "avg_entry_price": "10.0", "current_price": "10.1", "qty": "100"}]
broker = _NakedBroker(pos, None, market_open=True)
mgr = LiveExitManager(
broker, store, lambda s: _bars([(10.2, 10.0, 10.1)]),
cfg=ExitConfig(enforce_stop_grace_passes=2, catastrophe_pct=0.10),
session_id="t",
)
# The _NakedBroker returns an EMPTY snapshot until a stop is rested, so the
# positive-evidence gate spends pass 1 CONFIRMING the empty read (Alpaca can
# silently return [] — one blip must not flatten). Naked passes then count.
mgr.manage() # pass 1: empty snapshot untrusted -> no count
assert broker.closed == []
mgr.manage() # pass 2: empty confirmed; naked pass 1/2
assert broker.closed == []
mgr.manage() # pass 3: naked pass 2/2 = grace -> flatten
assert broker.closed == [("AAA", None)]
assert broker.submitted == [] # flattened, not rest-a-stop
def test_naked_position_rests_stop_when_market_closed():
"""When the market is CLOSED a market flatten can't fill (it just churns every
pass), so enforcement RESTS a protective stop instead — at breakeven when in
profit. The next pass would see the stop and stop enforcing."""
store = EventStore(":memory:")
pos = [{"symbol": "AAA", "avg_entry_price": "10.0", "current_price": "10.4", "qty": "100"}]
broker = _NakedBroker(pos, None, market_open=False)
mgr = LiveExitManager(
broker, store, lambda s: _bars([(10.5, 10.3, 10.4)]),
cfg=ExitConfig(enforce_stop_grace_passes=2, catastrophe_pct=0.10),
session_id="t",
)
# +1 pass vs the pre-gate timing: the empty snapshot is confirmed once before
# naked passes count (positive-evidence gate). Three passes reach grace=2.
mgr.manage(); mgr.manage(); mgr.manage() # grace reached while CLOSED
assert broker.closed == [] # NOT flattened (would never fill)
assert len(broker.submitted) == 1 # rested a protective stop instead
o = broker.submitted[0]
assert o["side"] == "sell" and o["type"] == "stop" and o["qty"] == 100
assert o["stop_price"] == 10.0 # breakeven (position is in profit)
def test_naked_underwater_rests_catastrophe_stop_when_closed():
"""Closed + naked + UNDERWATER: a sell-stop can't sit above the last price, so
enforcement rests it a catastrophe distance BELOW current, not at breakeven."""
store = EventStore(":memory:")
pos = [{"symbol": "AAA", "avg_entry_price": "10.0", "current_price": "9.0", "qty": "100"}]
broker = _NakedBroker(pos, None, market_open=False)
mgr = LiveExitManager(
broker, store, lambda s: _bars([(9.1, 8.9, 9.0)]),
cfg=ExitConfig(enforce_stop_grace_passes=2, catastrophe_pct=0.10),
session_id="t",
)
mgr.manage(); mgr.manage()
assert broker.closed == [] and len(broker.submitted) == 1
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_reanchors_to_opening_range():
"""A carried breakeven position re-anchors its +0.25R trail to TODAY's opening range
(ref = open, R = first-5-bar high-low) and ladders the stop from the open — locking in
TIGHTER than the wide entry-based synthetic R would."""
store = EventStore(":memory:")
pos = [{"symbol": "AAA", "avg_entry_price": "10.0", "current_price": "10.6", "qty": "100"}]
leg = {"id": "be", "symbol": "AAA", "type": "stop", "side": "sell",
"stop_price": "10.0", "status": "accepted"}
broker = _FakeBroker(pos, leg)
# opening range (first 5 bars) ~10.0–10.3 (R≈0.3), then runs to 10.6
bars = _bars([(10.1, 10.0, 10.05), (10.2, 10.05, 10.15), (10.3, 10.1, 10.25),
(10.25, 10.15, 10.2), (10.3, 10.2, 10.28), (10.5, 10.3, 10.45),
(10.6, 10.45, 10.6)])
mgr = LiveExitManager(
broker, store, lambda s: bars,
cfg=ExitConfig(trail_r_step=0.25, default_trail_r_pct=0.10), session_id="t",
)
mgr.manage()
assert broker.replaced, "should re-anchor + trail from the opening range"
new_stop = broker.replaced[-1][1]
assert 10.0 < new_stop < 10.6 # above breakeven, below market
assert new_stop > 10.25 # tighter than the entry-synthetic R (~10.25) -> OR-anchored
assert "AAA" in mgr._open_anchored
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:")
pos = [{"symbol": "AAA", "avg_entry_price": "10.0", "current_price": "10.1", "qty": "100"}]
leg = {"id": "stop1", "symbol": "AAA", "type": "stop", "side": "sell",
"stop_price": "9.5", "status": "held"}
broker = _FakeBroker(pos, leg)
mgr = LiveExitManager(
broker, store, lambda s: _bars([(10.2, 10.0, 10.1)]),
cfg=ExitConfig(enforce_stop_grace_passes=2, catastrophe_pct=0.10),
session_id="t",
)
mgr.manage(); mgr.manage(); mgr.manage()
assert broker.closed == [] # has a stop -> never naked-flattened
def test_manager_noop_for_static_bracket():
# no active rules -> the broker OCO handles everything; manager does nothing
broker = _FakeBroker([{"symbol": "AAA", "avg_entry_price": "10", "qty": "100"}],
{"id": "s", "symbol": "AAA", "type": "stop", "side": "sell",
"stop_price": "9", "status": "held"})
mgr = LiveExitManager(broker, EventStore(":memory:"), lambda s: _bars([(11, 10, 10.9)]),
cfg=ExitConfig(target_r=2.0), session_id="t")
assert mgr.manage() == []
assert not broker.replaced and not broker.closed
def test_fresh_fill_with_no_post_entry_bars_is_left_alone():
"""Regression (2026-07-06 live): seconds after a fill there is no completed bar
with timestamp >= the entry time, and the old whole-day fallback fed manage_live
PRE-entry bars — the day's HOD read as post-entry high-water, the trail computed
as already breached, and fresh entries were market-exited within seconds (ELAB
round-tripped in 3s). With no post-entry bars the manager must do NOTHING: no
flatten, no scale-out, no stop replace — the bracket's initial stop protects."""
store = EventStore(":memory:")
pos = [{"symbol": "AAA", "avg_entry_price": "2.25", "qty": "100",
"current_price": "2.24"}]
# ran to 2.40 (HOD, > +1R past our later 2.25 entry) BEFORE the entry and pulled
# back to 2.24 — under the whole-day fallback the trail reads armed AND breached
bars = _bars([(2.20, 2.15, 2.19), (2.40, 2.19, 2.38), (2.39, 2.30, 2.35)])
leg = {"id": "stop1", "symbol": "AAA", "type": "stop", "side": "sell",
"stop_price": "2.19", "status": "held"}
# the BUY fill happened AFTER the last completed bar -> zero bars since entry
fill = {"id": "buy1", "symbol": "AAA", "side": "buy", "filled_qty": "100",
"filled_at": "2026-06-17 13:32:14"}
broker = _FakeBroker(pos, leg)
broker.get_orders = lambda **kw: [leg, fill]
mgr = LiveExitManager(
broker, store, lambda s: bars,
cfg=ExitConfig(breakeven_at_r=1.0, trail_mode=TRAIL_PRIOR_LOW, trail_after_r=1.0),
session_id="t",
)
mgr.manage()
assert broker.closed == [], "fresh fill must not be flattened before any post-entry bar"
assert broker.replaced == [], "stop must not move before any post-entry bar"
assert broker.canceled == []