On 2026-10-02 the trader died 5 times (13:15-15:05 ET). It left no traceback, and each death came within the task's 5-min re-fire window, which fits its console window being closed (as on 2026-09-29) while the PC was in use. Every relaunch popped a new window. - Task Scheduler now launches it with pythonw: no console window to close. Under pythonw, stdout/stderr go to data/judge/logs/live_<date>.log, so a real crash still leaves its traceback. - A relaunched trader re-loads every symbol today's earlier runs were watching (from the journal's "add" rows). It used to know only the names still passing the scanner, so it stopped deciding on the others and never saved their ticks; the review then had nothing to compare CABO and APLX against. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
143 lines
5.5 KiB
Python
143 lines
5.5 KiB
Python
"""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
|
|
|
|
|
|
def test_soft_stop_walks_trades_in_order():
|
|
pz = Pos("X", 100, 10.0, 9.5, 0.5, live.now_et(), "o", "leg", 10.0, "burst", 0.4)
|
|
assert Trader._soft_stop_hit(pz, [10.1, 9.8, 9.6]) is None # above the stop
|
|
assert Trader._soft_stop_hit(pz, [9.7, 9.5]) == "soft_stop" # a print at the stop
|
|
pz = Pos("X", 100, 10.0, 9.5, 0.5, live.now_et(), "o", "leg", 10.0, "burst", 0.4)
|
|
assert Trader._soft_stop_hit(pz, [10.6, 12.0, 11.5]) is None # +1R reached; trail 6% under 12.0 = 11.28
|
|
assert Trader._soft_stop_hit(pz, [11.25]) == "soft_trail"
|
|
pz = Pos("X", 100, 10.0, 9.5, 0.5, live.now_et(), "o", "leg", 10.0, "burst", 0.4)
|
|
assert Trader._soft_stop_hit(pz, [9.4, 12.0]) == "soft_stop" # order matters: the dip came first
|
|
|
|
|
|
def test_restart_rewatches_symbols_from_earlier_runs(tmp_path, monkeypatch):
|
|
(tmp_path / "journal").mkdir()
|
|
(tmp_path / "journal" / "2026-10-02.jsonl").write_text(
|
|
'{"kind":"add","symbol":"CABO","prev_close":11.5}\n{"kind":"add","symbol":"APLX","prev_close":7.2}\n',
|
|
encoding="utf-8")
|
|
monkeypatch.setattr(live, "OUT", tmp_path)
|
|
t = Trader.__new__(Trader)
|
|
t.day, t.books, t.j = "2026-10-02", {}, J()
|
|
t.earlier = t._earlier_today()
|
|
added = []
|
|
|
|
async def fake_add(sym, pc):
|
|
added.append((sym, pc)); t.books[sym] = object()
|
|
t.add_symbol = fake_add
|
|
asyncio.run(t.rewatch())
|
|
assert added == [("CABO", 11.5), ("APLX", 7.2)]
|