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:
1 parent
347031e2bb
commit
f3c8be11b6
2 files changed
+157
-7
No files matched your search
+44
-7
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user