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
620 lines
31 KiB
Python
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())
|