Files
momentum-system/judge/live.py
T
momentum devandClaude Opus 5.5 7e6f881b17 fix: judge paper trader runs windowless and re-watches earlier symbols after a restart
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
2026-10-02 16:08:55 -05:00

620 lines
31 KiB
Python

"""Live PAPER trader for the v2 judgement model (Alpaca paper account "Momentum V2").
usage: python -m judge.live (runs one session: ~09:15 ET until 16:00 ET, then exits)
env: ALPACA_V2_KEY / ALPACA_V2_SECRET (paper only), JUDGE_RISK_USD=20, JUDGE_MAX_NOTIONAL=1000,
JUDGE_MAX_OPEN=4, JUDGE_MAX_TRADES_PER_SYMBOL=6, JUDGE_SCAN_INTERVAL_S=30, JUDGE_DRY_RUN=0
It mirrors the validated simulator:
- decisions come from judge.decide.scan on tick data inside the model's windows (regular hours only);
- the entry is a limit at the ask with a broker-side stop (OTO); unfilled after 3 s it is cancelled;
- exits: the stop, a 6% trail under the high-water once +1R, a 20-min time stop, and flat by 15:55 ET.
Every scored signal, order, fill and exit goes to data/judge/journal/<date>.jsonl, and the day's ticks go to
data/judge/ticks/<date>_<sym>.pkl for a paper-vs-simulation comparison.
"""
from __future__ import annotations
import asyncio
import json
import math
import os
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from dotenv import load_dotenv # noqa: E402
load_dotenv(ROOT / ".env")
from alpaca_paper.client import AlpacaPaperClient # noqa: E402
from alpaca_paper.settings import AlpacaPaperSettings # noqa: E402
from judge import engine # noqa: E402
from judge.decide import DayContext, scan # noqa: E402
from judge.model import load as load_model # noqa: E402
from research.ingestion.snapshot_scan import SnapshotScanConfig, SnapshotScanner # noqa: E402
ET = ZoneInfo("America/New_York")
OUT = ROOT / "data" / "judge"
RISK_USD = float(os.environ.get("JUDGE_RISK_USD", "20"))
MAX_NOTIONAL = float(os.environ.get("JUDGE_MAX_NOTIONAL", "1000"))
MAX_OPEN = int(os.environ.get("JUDGE_MAX_OPEN", "4"))
MAX_TRADES_SYM = int(os.environ.get("JUDGE_MAX_TRADES_PER_SYMBOL", "6"))
SCAN_S = float(os.environ.get("JUDGE_SCAN_INTERVAL_S", "30"))
DRY = os.environ.get("JUDGE_DRY_RUN", "0") == "1"
# Software stop: sell when any trade prints at/through the stop (or the trail level), as the validated simulator does.
# Backs up the broker stop, which Alpaca's paper engine fills late in fast markets and no broker runs outside RTH.
SOFT_STOP = os.environ.get("JUDGE_SOFT_STOP", "1") == "1"
def now_et() -> pd.Timestamp:
return pd.Timestamp(datetime.now(ET).replace(tzinfo=None))
def to_et(iso: str) -> np.datetime64:
return np.datetime64(pd.Timestamp(iso).tz_convert(ET).tz_localize(None))
class Journal:
def __init__(self, day: str):
(OUT / "journal").mkdir(parents=True, exist_ok=True)
self.f = open(OUT / "journal" / f"{day}.jsonl", "a", encoding="utf-8")
def log(self, kind: str, **kw):
kw = {k: (str(v) if isinstance(v, (pd.Timestamp, np.datetime64, datetime)) else v) for k, v in kw.items()}
self.f.write(json.dumps(dict(at=str(now_et()), kind=kind, **kw), default=str) + "\n")
self.f.flush()
@dataclass
class Book:
ctx: DayContext
tt: list = field(default_factory=list); tp: list = field(default_factory=list); ts_: list = field(default_factory=list)
qt: list = field(default_factory=list); qbp: list = field(default_factory=list); qbs: list = field(default_factory=list)
qap: list = field(default_factory=list); qas: list = field(default_factory=list)
ready: bool = False
last_scan: pd.Timestamp | None = None
last_len: int = 0
seen: set = field(default_factory=set)
n_trades: int = 0
cooldown_until: pd.Timestamp | None = None
pending_stream: list = field(default_factory=list)
def T(self) -> pd.DataFrame:
return pd.DataFrame({"t": np.array(self.tt, dtype="datetime64[ns]"), "p": np.array(self.tp, dtype="float64"),
"s": np.array(self.ts_, dtype="int64")})
def Q(self) -> pd.DataFrame:
return pd.DataFrame({"t": np.array(self.qt, dtype="datetime64[ns]"), "bp": self.qbp, "bs": self.qbs,
"ap": self.qap, "as": self.qas})
def add_trade(self, t, p, s):
self.tt.append(t); self.tp.append(float(p)); self.ts_.append(int(s))
def add_quote(self, t, bp, bs, ap, as_):
if ap > 0 and bp > 0 and ap >= bp:
self.qt.append(t); self.qbp.append(float(bp)); self.qbs.append(int(bs)); self.qap.append(float(ap)); self.qas.append(int(as_))
@dataclass
class Pos:
symbol: str
qty: int
entry: float
stop: float
risk: float
fill_time: pd.Timestamp
order_id: str
leg_id: str | None
hw: float
setup: str
p: float
next_try: pd.Timestamp | None = None # throttles exit retries after a broker error
seen_n: int = -1 # index into the book's trade list already checked by the soft stop
class Trader:
def __init__(self):
key, sec = os.environ["ALPACA_V2_KEY"], os.environ["ALPACA_V2_SECRET"]
self.key, self.sec = key, sec
self.client = AlpacaPaperClient(AlpacaPaperSettings.from_env(
{"ALPACA_API_KEY": key, "ALPACA_SECRET_KEY": sec, "ALPACA_DATA_FEED": "sip",
"APCA_API_BASE_URL": "https://paper-api.alpaca.markets"}))
self.art = load_model()
self.day = now_et().strftime("%Y-%m-%d")
self.earlier = self._earlier_today()
self.j = Journal(self.day)
cfg = SnapshotScanConfig.from_env()
cfg.price_min, cfg.price_max, cfg.min_pct, cfg.max_per_sweep = 1.0, 20.0, 20.0, 10
self.scanner = SnapshotScanner(self.client, cfg)
self.books: dict[str, Book] = {}
self.pos: dict[str, Pos] = {}
self.ws = None
self.subscribed: set = set()
self.j.log("start", cutoff=self.art["cutoff"], risk_usd=RISK_USD, max_notional=MAX_NOTIONAL, dry=DRY)
def _earlier_today(self) -> dict:
"""What earlier runs of today's session did (the task relaunches the trader if it dies):
orders per symbol, so the per-symbol cap holds across restarts, and each symbol's last fill."""
out = {"orders": {}, "fills": {}, "watched": {}}
f = OUT / "journal" / f"{self.day}.jsonl"
if not f.exists():
return out
for line in f.read_text(encoding="utf-8").splitlines():
try:
r = json.loads(line)
except ValueError:
continue
if r.get("kind") == "order":
out["orders"][r["symbol"]] = out["orders"].get(r["symbol"], 0) + 1
elif r.get("kind") == "fill":
out["fills"][r["symbol"]] = r
elif r.get("kind") == "add" and r.get("prev_close"):
out["watched"][r["symbol"]] = float(r["prev_close"])
return out
async def rewatch(self):
"""Re-load every symbol an earlier run of today was watching. Without this, a relaunched trader only knew the
names still passing the scanner, so it stopped deciding on the others, and their ticks were never saved for
the paper-vs-sim review (2026-10-02: CABO, APLX)."""
for sym, pc in self.earlier.get("watched", {}).items():
if sym in self.books or len(self.books) >= 30:
continue
try:
await self.add_symbol(sym, pc)
except Exception as e: # noqa: BLE001
self.j.log("error", where="rewatch", symbol=sym, err=str(e)[:200])
async def adopt(self):
"""Take over positions left by an earlier run today, so they still get the trail, time stop and EOD flat.
A position with no known stop (no journalled fill and no working stop order) is closed rather than guessed at."""
try:
held = await asyncio.to_thread(self.client.get_positions, True)
except Exception as e: # noqa: BLE001
self.j.log("error", where="adopt", err=str(e)[:300]); return
for x in held:
sym, qty, avg = x["symbol"], int(float(x["qty"])), float(x["avg_entry_price"])
if qty <= 0:
continue
fill = self.earlier["fills"].get(sym)
try:
orders = await asyncio.to_thread(self.client.get_orders, "open", 50, True, [sym])
except Exception: # noqa: BLE001
orders = []
stops = [o for o in orders if o.get("side") == "sell" and o.get("type") == "stop"]
for o in orders:
stops += [lg for lg in (o.get("legs") or []) if lg.get("side") == "sell" and lg.get("type") == "stop"
and lg.get("status") in ("new", "accepted", "held", "pending_new")]
stop = float(stops[0]["stop_price"]) if stops else (float(fill["stop"]) if fill else None)
if stop is None:
self.j.log("adopt_close", symbol=sym, qty=qty, why="no known stop")
try:
await asyncio.to_thread(self.client.close_position, sym)
except Exception as e: # noqa: BLE001
self.j.log("error", where="adopt_close", symbol=sym, err=str(e)[:300])
continue
base = float(fill["stop"]) if fill else stop # R is measured from the original stop, not a trailed one
risk = avg - base if avg > base else 0.03 * avg
fill_time = pd.Timestamp(fill["at"]) if fill else now_et()
try:
await self.add_symbol(sym, float(x.get("lastday_price") or avg))
except Exception as e: # noqa: BLE001
self.j.log("error", where="adopt_add", symbol=sym, err=str(e)[:300])
pz = Pos(sym, qty, avg, stop, risk, fill_time, "", stops[0]["id"] if stops else None, avg,
fill.get("setup", "?") if fill else "?", float(fill.get("p") or 0) if fill else 0.0)
bk = self.books.get(sym)
if bk and bk.tt: # high-water since the fill, from the backfilled tape
k = np.searchsorted(np.array(bk.tt, dtype="datetime64[ns]"), np.datetime64(fill_time), side="left")
if bk.tp[k:]:
pz.hw = max(avg, max(bk.tp[k:]))
self.pos[sym] = pz
self.j.log("adopt", symbol=sym, qty=qty, entry=avg, stop=stop, leg_id=pz.leg_id, hw=pz.hw, fill_time=fill_time)
await self._reconcile(pz, bk.tp[-1] if bk and bk.tp else avg)
# --------------------------------------------------------------------- discovery + backfill
def _paged(self, path, key, sym, start_utc, end_utc):
rows, tok = [], None
while True:
p = self.client._data("GET", path, params={"symbols": sym, "start": start_utc, "end": end_utc, "limit": 10000,
"feed": "sip", "page_token": tok})
rows += (p.get(key) or {}).get(sym, [])
tok = p.get("next_page_token")
if not tok:
return rows
def _context(self, sym: str, prev_close: float) -> DayContext:
"""first +20% minute (-> windows), today's open gap and the 20-day median daily volume."""
d = self.day
mb = self.client.get_minute_bars([sym], start_iso=f"{d}T08:00:00Z",
end_iso=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), feed="sip").get(sym) or []
first20 = None; open_px = None
for b in mb:
t = pd.Timestamp(b["t"]).tz_convert(ET).tz_localize(None)
if first20 is None and b["h"] >= prev_close * 1.20:
first20 = t.floor("min")
if open_px is None and t.time() >= pd.Timestamp("09:30").time():
open_px = b["o"]
start = (pd.Timestamp(d) - pd.Timedelta(days=45)).strftime("%Y-%m-%d")
daily = self.client.get_daily_bars([sym], start_iso=f"{start}T00:00:00Z", feed="sip").get(sym) or []
vols = [b["v"] for b in daily if b["t"][:10] < d][-20:]
med20 = float(np.median(vols)) if len(vols) >= 5 else None
gap = (open_px / prev_close - 1) if open_px else None
return DayContext(sym, d, prev_close, gap, med20, engine.windows(d, first20))
async def add_symbol(self, sym: str, prev_close: float):
if sym in self.books:
return
ctx = await asyncio.to_thread(self._context, sym, prev_close)
bk = Book(ctx); self.books[sym] = bk
bk.n_trades = self.earlier["orders"].get(sym, 0)
await self.subscribe([sym])
start = min(s for s, _ in ctx.windows)
s_utc = start.tz_localize(ET).tz_convert("UTC").strftime("%Y-%m-%dT%H:%M:%SZ")
e_utc = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
tr = await asyncio.to_thread(self._paged, "/v2/stocks/trades", "trades", sym, s_utc, e_utc)
qu = await asyncio.to_thread(self._paged, "/v2/stocks/quotes", "quotes", sym, s_utc, e_utc)
for x in tr:
bk.add_trade(to_et(x["t"]), x["p"], x["s"])
for x in qu:
bk.add_quote(to_et(x["t"]), x["bp"], x["bs"], x["ap"], x["as"])
last_t = bk.tt[-1] if bk.tt else np.datetime64("1970-01-01")
last_q = bk.qt[-1] if bk.qt else np.datetime64("1970-01-01")
for kind, row in bk.pending_stream: # stream data that arrived during the backfill
if kind == "t" and row[0] > last_t:
bk.add_trade(*row)
elif kind == "q" and row[0] > last_q:
bk.add_quote(*row)
bk.pending_stream.clear(); bk.ready = True
bk.last_scan = now_et() - pd.Timedelta(seconds=10)
self.j.log("add", symbol=sym, prev_close=prev_close, gap=ctx.gap, med20=ctx.med20,
windows=[[str(a), str(b)] for a, b in ctx.windows], backfill_trades=len(tr), backfill_quotes=len(qu))
async def scan_loop(self):
while True:
n = now_et()
if n.time() >= pd.Timestamp("15:30").time():
return
if n.time() >= pd.Timestamp("09:15").time():
try:
res = await asyncio.to_thread(self.scanner.sweep, datetime.now(timezone.utc), set(self.books))
for h in res.hits:
if h.symbol not in self.books and len(self.books) < 30:
await self.add_symbol(h.symbol, h.prev_close)
except Exception as e: # noqa: BLE001
self.j.log("error", where="scan", err=str(e)[:300])
await asyncio.sleep(SCAN_S)
# --------------------------------------------------------------------- streaming
async def subscribe(self, syms):
new = [s for s in syms if s not in self.subscribed]
if not new:
return
self.subscribed.update(new)
if self.ws is not None:
await self.ws.send(json.dumps({"action": "subscribe", "trades": new, "quotes": new}))
async def stream_loop(self):
import websockets
url = "wss://stream.data.alpaca.markets/v2/sip"
while True:
try:
async with websockets.connect(url, max_size=2 ** 24, ping_interval=20) as ws:
await ws.send(json.dumps({"action": "auth", "key": self.key, "secret": self.sec}))
self.ws = ws
if self.subscribed:
s = sorted(self.subscribed)
await ws.send(json.dumps({"action": "subscribe", "trades": s, "quotes": s}))
self.j.log("stream", status="connected", symbols=len(self.subscribed))
async for raw in ws:
for m in json.loads(raw):
T = m.get("T"); sym = m.get("S")
bk = self.books.get(sym)
if bk is None:
continue
if T == "t":
row = (to_et(m["t"]), m["p"], m["s"])
(bk.add_trade(*row) if bk.ready else bk.pending_stream.append(("t", row)))
elif T == "q":
row = (to_et(m["t"]), m["bp"], m["bs"], m["ap"], m["as"])
(bk.add_quote(*row) if bk.ready else bk.pending_stream.append(("q", row)))
elif T in ("error", "subscription"):
self.j.log("stream", msg=m)
except Exception as e: # noqa: BLE001
self.ws = None
self.j.log("error", where="stream", err=str(e)[:300])
await asyncio.sleep(3)
# --------------------------------------------------------------------- decisions + entries
async def decide_loop(self):
while True:
n = now_et()
if n.time() >= pd.Timestamp("15:50").time():
return
for sym, bk in list(self.books.items()):
if not bk.ready or len(bk.tt) == bk.last_len:
continue
if not (n.time() >= pd.Timestamp("09:30").time() and engine.in_windows(n, bk.ctx.windows)):
bk.last_len = len(bk.tt); continue
bk.last_len = len(bk.tt)
try:
decs = await asyncio.to_thread(scan, bk.T(), bk.Q(), bk.ctx, self.art, n,
bk.last_scan - pd.Timedelta(seconds=10))
except Exception as e: # noqa: BLE001
self.j.log("error", where="scan_signals", symbol=sym, err=str(e)[:300]); continue
bk.last_scan = n
for d in decs:
key = (d.setup, str(d.ts))
if key in bk.seen:
continue
bk.seen.add(key)
age = (n - pd.Timestamp(d.ts)).total_seconds()
self.j.log("signal", symbol=sym, setup=d.setup, ts=d.ts, sp=d.sp, stop=d.stop, p=round(d.p, 4),
take=d.take, age_s=age, feats={k: (None if isinstance(v, float) and math.isnan(v) else v)
for k, v in d.feats.items()})
if d.take and age <= 5:
await self.enter(sym, bk, d)
await asyncio.sleep(1.0)
def _order(self, oid):
return self.client._trading("GET", f"/orders/{oid}", params={"nested": "true"})
async def enter(self, sym, bk, d):
why = None
if sym in self.pos:
why = "already in position"
elif len(self.pos) >= MAX_OPEN:
why = "max open positions"
elif bk.n_trades >= MAX_TRADES_SYM:
why = "max trades for symbol"
elif bk.cooldown_until is not None and now_et() < bk.cooldown_until:
why = "cooldown"
if why:
self.j.log("skip", symbol=sym, setup=d.setup, why=why); return
if not bk.qt:
return
bid, ask = bk.qbp[-1], bk.qap[-1]
chk = engine.entry_check(bid, ask, d.sp, d.stop)
if chk is None:
self.j.log("skip", symbol=sym, setup=d.setup, why="entry_check (spread/chase/stop)", bid=bid, ask=ask, sp=d.sp); return
entry, stop = chk
qty = int(min(RISK_USD / (entry - stop), MAX_NOTIONAL / entry))
if qty < 1:
self.j.log("skip", symbol=sym, why="qty<1"); return
bk.n_trades += 1
coid = f"jv2-{sym}-{now_et():%H%M%S}"
self.j.log("order", symbol=sym, setup=d.setup, qty=qty, limit=round(entry, 2), stop=round(stop, 2), p=d.p, coid=coid)
if DRY:
return
try:
o = await asyncio.to_thread(self.client.submit_order, sym, qty, "buy", "limit", "day", round(entry, 2), None,
round(stop, 2), None, coid)
except Exception as e: # noqa: BLE001
self.j.log("error", where="submit", symbol=sym, err=str(e)[:300]); return
oid = o.get("id")
filled = None
for _ in range(12):
await asyncio.sleep(0.25)
try:
st = await asyncio.to_thread(self._order, oid)
except Exception: # noqa: BLE001
continue
if st.get("status") == "filled":
filled = st; break
if filled is None:
try:
await asyncio.to_thread(self.client.cancel_order, oid)
st = await asyncio.to_thread(self._order, oid)
if float(st.get("filled_qty") or 0) > 0:
filled = st
except Exception: # noqa: BLE001
pass
if filled is None:
self.j.log("unfilled", symbol=sym, coid=coid); return
fq = int(float(filled["filled_qty"])); fpx = float(filled["filled_avg_price"])
legs = filled.get("legs") or []
leg_id = legs[0]["id"] if legs else None
self.pos[sym] = pz = Pos(sym, fq, fpx, stop, fpx - stop, now_et(), oid, leg_id, fpx, d.setup, d.p)
self.j.log("fill", symbol=sym, qty=fq, price=fpx, stop=stop, leg_id=leg_id, slippage_vs_ask=fpx - entry)
if filled.get("status") != "filled" or not leg_id:
# 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 _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}")
pz.leg_id = o.get("id")
self.j.log("stop_armed", symbol=pz.symbol, qty=pz.qty, stop=round(pz.stop, 2), leg_id=pz.leg_id)
except Exception as e: # noqa: BLE001
pz.leg_id = None
self.j.log("error", where="arm_stop", symbol=pz.symbol, err=str(e)[:300])
async def _broker_qty(self, sym) -> int:
ps = await asyncio.to_thread(self.client.get_positions, True)
return sum(int(float(x["qty"])) for x in ps if x.get("symbol") == sym)
def _closed(self, pz: Pos, reason: str, price: float):
self.j.log("exit", symbol=pz.symbol, reason=reason, price=price, entry=pz.entry, r_est=(price - pz.entry) / pz.risk)
self.pos.pop(pz.symbol, None)
bk = self.books.get(pz.symbol)
if bk:
bk.cooldown_until = now_et() + pd.Timedelta(seconds=engine.EXEC["cooldown_s"])
async def _reconcile(self, pz: Pos, last: float) -> bool:
"""Match our record to the broker. True if the position is gone (and has been recorded as closed).
If the stop leg is no longer working but shares remain, a fresh stop is armed."""
leg = None
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":
self._closed(pz, "stop", float(leg.get("filled_avg_price") or pz.stop)); return True
try:
held = await self._broker_qty(pz.symbol)
except Exception as e: # noqa: BLE001
self.j.log("error", where="reconcile", symbol=pz.symbol, err=str(e)[:200]); return False
if held <= 0:
self._closed(pz, "flat_at_broker", last); return True
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"):
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
async def exit_pos(self, pz: Pos, reason: str, last: float):
if pz.next_try is not None and now_et() < pz.next_try:
return
if await self._reconcile(pz, last): # the broker stop may already have closed it
return
try:
# 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, 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
self.j.log("error", where="exit", symbol=pz.symbol, err=str(e)[:300])
if not await self._reconcile(pz, last):
pz.next_try = now_et() + pd.Timedelta(seconds=5)
return
self._closed(pz, reason, last)
@staticmethod
def _soft_stop_hit(pz: Pos, prices) -> str | None:
"""Walk the new trades in order like the simulator: update the high-water, then exit if a print is at or
below the stop, or below the 6% trail once +1R. Returns 'soft_stop' / 'soft_trail' or None."""
P = engine.EXEC
for px in prices:
pz.hw = max(pz.hw, px)
level = pz.stop
if (pz.hw - pz.entry) / pz.risk >= P["trail_after_r"]:
level = max(level, pz.hw * (1 - P["trail_pct"]), pz.entry)
if px <= level:
return "soft_stop" if level <= pz.stop else "soft_trail"
return None
async def manage_loop(self):
tick = 0
while True:
n = now_et(); tick += 1
for sym, pz in list(self.pos.items()):
bk = self.books.get(sym)
if bk is None: # adopted position whose backfill failed: manage it off the entry price
bk = self.books[sym] = Book(DayContext(sym, self.day, pz.entry, None, None, []))
bk.ready = True
if tick % 5 == 0 and await self._reconcile(pz, bk.tp[-1] if bk.tp else pz.entry):
continue
k = np.searchsorted(np.array(bk.tt[-5000:], dtype="datetime64[ns]"), np.datetime64(pz.fill_time), side="left")
recent = bk.tp[-5000:][k:]
last = recent[-1] if recent else pz.entry
if SOFT_STOP:
start = max(pz.seen_n, len(bk.tp) - len(bk.tp[-5000:]) + int(k))
hit = self._soft_stop_hit(pz, bk.tp[start:])
pz.seen_n = len(bk.tp)
if hit is not None:
await self.exit_pos(pz, hit, last); continue
if recent:
pz.hw = max(pz.hw, max(recent))
if n.time() >= pd.Timestamp("15:55").time():
await self.exit_pos(pz, "eod", last); continue
if (n - pz.fill_time).total_seconds() >= engine.EXEC["hold_s"]:
await self.exit_pos(pz, "time", last); continue
if (pz.hw - pz.entry) / pz.risk >= engine.EXEC["trail_after_r"]:
new = max(pz.stop, pz.hw * (1 - engine.EXEC["trail_pct"]), pz.entry)
if new >= last:
await self.exit_pos(pz, "trail", last); continue
if new > pz.stop + 0.01 and pz.leg_id:
try:
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)
if n.time() >= pd.Timestamp("16:00").time() and not self.pos:
return
await asyncio.sleep(1.0)
def save_ticks(self):
(OUT / "ticks").mkdir(parents=True, exist_ok=True)
for sym, bk in self.books.items():
pd.to_pickle(dict(trades=bk.T(), quotes=bk.Q(), windows=bk.ctx.windows, prev_close=bk.ctx.prev_close,
gap=bk.ctx.gap, med20=bk.ctx.med20), OUT / "ticks" / f"{self.day}_{sym}.pkl")
def trading_day(self) -> bool:
"""False on weekends/holidays: the broker clock's next open is not today and the market is closed."""
try:
c = self.client.get_clock()
if c.get("is_open"):
return True
nxt = pd.Timestamp(c.get("next_open")).tz_convert(ET).strftime("%Y-%m-%d")
return nxt == self.day
except Exception: # noqa: BLE001
return now_et().weekday() < 5
async def run(self):
if not self.trading_day():
self.j.log("stop", why="not a trading day"); return
tasks = [asyncio.create_task(self.stream_loop()), asyncio.create_task(self.scan_loop()),
asyncio.create_task(self.decide_loop())]
await self.adopt()
await self.rewatch()
await self.manage_loop()
for t in tasks:
t.cancel()
self.save_ticks()
self.j.log("stop", books=len(self.books))
if __name__ == "__main__":
if sys.stdout is None or sys.stderr is None: # pythonw (no console window): keep crash output in the log
(OUT / "logs").mkdir(parents=True, exist_ok=True)
_log = open(OUT / "logs" / f"live_{now_et():%Y-%m-%d}.log", "a", encoding="utf-8", buffering=1)
sys.stdout = sys.stderr = _log
asyncio.run(Trader().run())