fix: judge paper trader tracks replaced stop orders

On 2026-09-30 PWCM's stop was moved, and Alpaca's replace returns a NEW order id.
The trader kept the dead id, so reconcile treated the stop as gone and tried to
arm another one (refused with 403, shares held) about 20 times in 80 s, and the
trail exit could not sell because it cancelled the stale id.

- Adopt the id returned by replace. Reconcile follows replaced_by, and before
  arming it adopts any working sell stop already at the broker.
- Exits cancel every open order on the symbol before close_position.
- If the price is already through the stop, close instead of arming a stop the
  broker would reject.
- tests/unit/test_judge_live_exits.py covers these cases and the 2026-09-29 ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
This commit is contained in:
momentum devandClaude Opus 5.5 committed 2026-09-30 15:58:52 -05:00
1 parent 347031e2bb
commit f3c8be11b6
2 files changed
+157 -7

No files matched your search

+44 -7
View File
@@ -406,7 +406,24 @@ class Trader:
# a partial fill whose parent was cancelled takes its OTO stop leg down with it: re-arm a plain stop
await self._arm_stop(pz)
async def _arm_stop(self, pz: Pos):
async def _open_sell_stop(self, sym):
"""Id of a working sell stop for this symbol at the broker, if any (top level or an OTO leg)."""
orders = await asyncio.to_thread(self.client.get_orders, "open", 50, True, [sym])
for o in orders:
for x in [o, *(o.get("legs") or [])]:
if x.get("side") == "sell" and x.get("type") == "stop" and x.get("status") in ("new", "accepted", "held", "pending_new", "partially_filled"):
return x
return None
async def _arm_stop(self, pz: Pos, last: float | None = None):
if last is not None and pz.stop >= last - 0.01:
# already through the stop: the broker would reject a stop above the market, so get out now
self.j.log("stop_breached", symbol=pz.symbol, stop=round(pz.stop, 2), last=last)
try:
await asyncio.to_thread(self.client.close_position, pz.symbol)
except Exception as e: # noqa: BLE001
self.j.log("error", where="close_breached", symbol=pz.symbol, err=str(e)[:300])
return
try:
o = await asyncio.to_thread(self.client.submit_order, pz.symbol, pz.qty, "sell", "stop", "day", None,
round(pz.stop, 2), None, None, f"jv2s-{pz.symbol}-{now_et():%H%M%S}")
@@ -434,6 +451,11 @@ class Trader:
if pz.leg_id:
try:
leg = await asyncio.to_thread(self._order, pz.leg_id)
for _ in range(5): # a moved stop is a new order: follow the replacement chain
if leg.get("status") != "replaced" or not leg.get("replaced_by"):
break
pz.leg_id = leg["replaced_by"]
leg = await asyncio.to_thread(self._order, pz.leg_id)
except Exception: # noqa: BLE001
leg = None
if leg and leg.get("status") == "filled":
@@ -447,7 +469,14 @@ class Trader:
if held != pz.qty:
self.j.log("qty_sync", symbol=pz.symbol, ours=pz.qty, broker=held); pz.qty = held
if leg is None or leg.get("status") in ("canceled", "expired", "rejected", "replaced", "done_for_day"):
await self._arm_stop(pz)
try:
working = await self._open_sell_stop(pz.symbol)
except Exception: # noqa: BLE001
working = None
if working: # a stop we lost track of already holds the shares: adopt it, don't double up
pz.leg_id = working["id"]
else:
await self._arm_stop(pz, last)
return False
# --------------------------------------------------------------------- exits
@@ -457,10 +486,16 @@ class Trader:
if await self._reconcile(pz, last): # the broker stop may already have closed it
return
try:
if pz.leg_id:
# cancel every working order on the symbol (the stop may have been replaced under a new id);
# otherwise its shares stay reserved and close_position is refused
try:
orders = await asyncio.to_thread(self.client.get_orders, "open", 50, True, [pz.symbol])
except Exception: # noqa: BLE001
orders = [{"id": pz.leg_id}] if pz.leg_id else []
for o in orders:
try:
await asyncio.to_thread(self.client.cancel_order, pz.leg_id)
except Exception: # noqa: BLE001 (leg filled/cancelled in the meantime: close_position decides)
await asyncio.to_thread(self.client.cancel_order, o["id"])
except Exception: # noqa: BLE001 (filled/cancelled in the meantime: close_position decides)
pass
await asyncio.to_thread(self.client.close_position, pz.symbol)
except Exception as e: # noqa: BLE001
@@ -496,8 +531,10 @@ class Trader:
await self.exit_pos(pz, "trail", last); continue
if new > pz.stop + 0.01 and pz.leg_id:
try:
await asyncio.to_thread(self.client.replace_order, pz.leg_id, stop_price=round(new, 2))
self.j.log("stop_move", symbol=sym, frm=pz.stop, to=round(new, 2)); pz.stop = new
o = await asyncio.to_thread(self.client.replace_order, pz.leg_id, stop_price=round(new, 2))
if o and o.get("id"):
pz.leg_id = o["id"] # Alpaca's replace returns a new order; the old id is dead
self.j.log("stop_move", symbol=sym, frm=pz.stop, to=round(new, 2), leg_id=pz.leg_id); pz.stop = new
except Exception as e: # noqa: BLE001
self.j.log("error", where="replace", symbol=sym, err=str(e)[:200])
await self._reconcile(pz, last)
+113
View File
@@ -0,0 +1,113 @@
"""judge.live exit/stop bookkeeping against a fake broker. Each case is a failure seen on the paper account:
2026-09-29 BKYI (stop already filled -> endless 422 exit retries), MSGY (partial fill left unprotected);
2026-09-30 PWCM (a moved stop is a new order id; the stale id made the trader double-arm and block its own exit)."""
import asyncio
import pandas as pd
import pytest
pytest.importorskip("websockets")
from judge import live # noqa: E402
from judge.live import Pos, Trader # noqa: E402
class J:
def __init__(self):
self.rows = []
def log(self, kind, **kw):
self.rows.append((kind, kw))
class Broker:
def __init__(self, orders=None, open_orders=None, held=33, submit_ok=True):
self.orders, self.open, self.held, self.submit_ok = orders or {}, open_orders or [], held, submit_ok
self.subs, self.cancelled, self.closed = [], [], []
def _trading(self, method, path, params=None):
return self.orders[path.split("/")[-1]]
def get_orders(self, status, limit, nested, symbols):
return self.open
def get_positions(self, fresh=False):
return [{"symbol": "X", "qty": str(self.held)}] if self.held else []
def submit_order(self, *a):
self.subs.append(a)
if not self.submit_ok:
raise RuntimeError("403 insufficient qty")
return {"id": "armed"}
def replace_order(self, oid, stop_price=None):
return {"id": "new2"}
def cancel_order(self, oid):
self.cancelled.append(oid)
def close_position(self, sym):
self.closed.append(sym)
def trader(broker, leg="old"):
t = Trader.__new__(Trader)
t.j, t.client, t.books = J(), broker, {}
pz = Pos("X", 33, 1.30, 1.20, 0.10, live.now_et(), "o", leg, 1.30, "burst", 0.4)
t.pos = {"X": pz}
return t, pz
def test_filled_stop_is_recorded_as_exit():
b = Broker({"old": {"status": "filled", "filled_avg_price": "1.21"}}, held=0)
t, pz = trader(b)
asyncio.run(t.exit_pos(pz, "trail", 1.25))
assert "X" not in t.pos and t.j.rows[-1][1]["reason"] == "stop"
def test_cancelled_leg_with_shares_rearms_a_stop():
t, pz = trader(Broker({"old": {"status": "canceled"}}))
assert asyncio.run(t._reconcile(pz, 1.28)) is False
assert pz.leg_id == "armed" and t.client.subs
def test_replaced_leg_follows_the_chain():
b = Broker({"old": {"status": "replaced", "replaced_by": "new1"}, "new1": {"status": "new"}}, submit_ok=False)
t, pz = trader(b)
asyncio.run(t._reconcile(pz, 1.28))
assert pz.leg_id == "new1" and not b.subs
def test_working_stop_at_broker_is_adopted_not_doubled():
legs = [{"id": "legX", "side": "sell", "type": "stop", "status": "new"}]
b = Broker({"old": {"status": "canceled"}}, [{"id": "p", "side": "buy", "type": "limit", "legs": legs}], submit_ok=False)
t, pz = trader(b)
asyncio.run(t._reconcile(pz, 1.28))
assert pz.leg_id == "legX" and not b.subs
def test_price_through_stop_closes_instead_of_arming():
b = Broker({"old": {"status": "canceled"}})
t, pz = trader(b)
asyncio.run(t._reconcile(pz, 1.19))
assert b.closed == ["X"] and not b.subs
def test_exit_cancels_every_open_order_first():
opens = [{"id": "a", "side": "sell", "type": "stop", "status": "new"}, {"id": "b", "side": "sell", "type": "stop", "status": "new"}]
b = Broker({"old": {"status": "new"}}, opens)
t, pz = trader(b)
asyncio.run(t.exit_pos(pz, "trail", 1.36))
assert b.cancelled == ["a", "b"] and b.closed == ["X"] and "X" not in t.pos
def test_earlier_today_counts_orders_and_last_fill(tmp_path, monkeypatch):
(tmp_path / "journal").mkdir()
(tmp_path / "journal" / "2026-09-30.jsonl").write_text(
'{"kind":"order","symbol":"X"}\n{"kind":"order","symbol":"X"}\n'
'{"kind":"fill","symbol":"X","stop":1.2,"at":"2026-09-30 09:40:00"}\nnot json\n', encoding="utf-8")
monkeypatch.setattr(live, "OUT", tmp_path)
t = Trader.__new__(Trader)
t.day = "2026-09-30"
e = t._earlier_today()
assert e["orders"] == {"X": 2} and e["fills"]["X"]["stop"] == 1.2
assert pd.Timestamp(e["fills"]["X"]["at"]).hour == 9