feat: judgement-model (v2) paper trader on the Alpaca "Momentum V2" account

A gradient-boosted model scores burst / micro-pullback / whole-dollar signals on
squeeze stocks from tick data, and only its top-scored regular-hours signals
are traded. Validated in data/ticksim before any live use:
  - v1 on a fresh 600-day sample: real ranking (AUC 0.63), thin edge. FAIL.
  - v2 (trained on Jun-Jul of samples 1+2), pre-registered, on 500 NEW Aug-Sep
    symbol-days: +1.11%/trade after the spread (day-block 90% lo +0.54%),
    n=332 over 38 days. PASS. It held at 2 s latency and +0.2% extra cost.

- judge/engine.py: the validated signals, execution gates and features, as one
  shared source.
- judge/model.py: trains and freezes v2 (cutoff = the training top 20%).
- judge/decide.py: pure decision logic.
- judge/replay.py: a live-style replay of recorded days. Parity: 40 of 41 research
  trades matched with identical model scores.
- judge/live.py: the paper trader. Watchlist from the snapshot scan with
  backfill, SIP trade/quote websocket, decisions each second, a limit at the ask
  with a broker OTO stop (cancelled if unfilled in 3 s), trail 6% after +1R,
  20-min time stop, flat by 15:55. $20 risk and $1,000 max notional per trade.
  Everything is journalled and the day's ticks are kept for paper-vs-simulation
  review. Weekends and holidays exit.
- Runs from the workstation (Task Scheduler, weekdays 09:10 ET) with the
  research venv. Paper only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3S5EFKThFVrjmN4HHsHMN
This commit is contained in:
momentum devandClaude Opus 5.5 committed 2026-09-28 18:25:26 -05:00
1 parent 1cc15f5ea7
commit f2c7e52c79
8 files changed
+873

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
"""Learned-judgement scalping strategy (v2): a gradient-boosted model scores burst / micro-pullback /
whole-dollar signals on squeeze stocks from tick data, and only the top-scored ones are traded.
Research and validation: data/ticksim (PREREG_v2.md, results.md). Paper trial first; never live money
without a matched paper-vs-simulation review."""
+81
View File
@@ -0,0 +1,81 @@
"""Pure decision logic: trades/quotes up to `now` -> the scored, tradeable signals.
Shared by the live paper trader and the replay/parity check."""
from __future__ import annotations
from dataclasses import dataclass, field
import numpy as np
import pandas as pd
from judge import engine
from judge.model import score
@dataclass
class DayContext:
symbol: str
date: str # YYYY-MM-DD
prev_close: float
gap: float | None # today's open vs prior close (known after 09:30)
med20: float | None # median daily volume over the prior 20 sessions
windows: list = field(default_factory=list)
@dataclass
class Decision:
setup: str
ts: object # signal time (exact crossing trade for micropb)
sp: float # signal price
stop: float
p: float
take: bool
reason: str
feats: dict
def window_trades(T: pd.DataFrame, ctx: DayContext) -> pd.DataFrame:
"""Trades inside the model's data windows. Features and bars must see exactly what training saw."""
if T is None or T.empty:
return T
m = np.zeros(len(T), dtype=bool)
for s, e in ctx.windows:
m |= (T.t.values >= np.datetime64(s)) & (T.t.values < np.datetime64(e))
return T[m].reset_index(drop=True)
def scan(T: pd.DataFrame, Q: pd.DataFrame, ctx: DayContext, art: dict, now=None, after=None,
include_partial: bool = True) -> list[Decision]:
"""Every signal known by `now` (and newer than `after`), scored.
T and Q hold ALL buffered trades/quotes; windowing happens here. `now` defaults to the last trade."""
Tw = window_trades(T, ctx)
if Tw is None or len(Tw) < 50 or Q is None or len(Q) < 50:
return []
now = np.datetime64(now) if now is not None else Tw.t.values[-1]
Tw = Tw[Tw.t.values <= now].reset_index(drop=True)
b_all = engine.bars5(Tw)
closed = b_all[b_all.tc.values <= now].reset_index(drop=True)
tt = Tw.t.values; tp = Tw.p.values.astype(float)
out = []
for setup in engine.SETUPS:
P = dict(engine.EXEC, setup=setup)
bars = b_all if (setup == "micropb" and include_partial) else closed
for sig in engine.signals(bars, P):
ts, sp, stop = sig[0], float(sig[1]), float(sig[2])
if len(sig) > 3 and sig[3] == "cross":
ts = engine.cross_time(tt, tp, ts, sp)
if ts is None:
continue
if ts > now or (after is not None and ts <= np.datetime64(after)):
continue
tsn = pd.Timestamp(ts)
if tsn.time() < pd.Timestamp("09:30").time(): # the v2 rule trades regular hours only
continue
f = engine.feats_at(Tw, Q, ts, ctx.prev_close)
if f is None:
continue
f = engine.finish_features(f, setup, ctx.gap, ctx.med20)
p = score(art, f)
take = p >= art["cutoff"]
out.append(Decision(setup, ts, sp, stop, p, take, "score>=cutoff" if take else "score<cutoff", f))
out.sort(key=lambda d: d.ts)
return out
+179
View File
@@ -0,0 +1,179 @@
"""Judgement-model engine: signals, features and data windows. This is the single source shared by the
research simulator (data/ticksim) and the live paper trader (judge/live.py).
It is a verbatim port of data/ticksim/sim.py (signals, execution checks) and data/ticksim/ml_dataset.py
(features) as validated by PREREG_v2 on 2026-09-28. Changing anything here changes the model's inputs, so
retrain and re-validate after any edit.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
TICK = 0.01
# the execution config the v2 model was trained and tested under (ml_dataset.EXEC)
EXEC = dict(setup="burst", lat_ms=300, max_spread=0.03, max_chase=0.01, lookback_s=120, vol_mult=4.0,
burst_pct=0.02, stop_lookback_s=30, min_stop_pct=0.01, max_stop_pct=0.08, run_pct=0.05, run_s=60,
max_pb=3, lvl_stop_pct=0.015, target_r=0.0, trail_after_r=1.0, trail_pct=0.06, hold_s=1200,
cooldown_s=30, max_trades=6, price_min=1.0, price_max=20.0)
SETUPS = ("burst", "micropb", "dollar")
FEATS = ["ret_10s", "ret_30s", "ret_60s", "ret_300s", "vol_surge_10s", "vol_surge_60s", "trades_10s", "trades_60s",
"rv_60s", "off_hod", "vs_vwap", "gain_prev", "up_from_5m_low", "spread", "imb", "ask_size_usd", "min_since_4am",
"rth", "price", "cumvol_vs_med", "gap_known", "setup_i"]
SETUP_I = {"burst": 0, "micropb": 1, "dollar": 2}
# ----------------------------------------------------------------------------- data windows
def windows(session_date: str, first20_ts: pd.Timestamp | None) -> list[list[pd.Timestamp]]:
"""The tick windows the model was trained on: 09:30-10:30, plus [first +20% minute - 2 min, +60 min]."""
w = [(pd.Timestamp(f"{session_date} 09:30"), pd.Timestamp(f"{session_date} 10:30"))]
if first20_ts is not None:
w.append((max(first20_ts - pd.Timedelta(minutes=2), pd.Timestamp(f"{session_date} 04:00")),
first20_ts + pd.Timedelta(minutes=60)))
w.sort()
merged = [list(w[0])]
for s, e in w[1:]:
if s <= merged[-1][1]:
merged[-1][1] = max(merged[-1][1], e)
else:
merged.append([s, e])
return merged
def in_windows(ts, ws) -> bool:
return any(s <= ts < e for s, e in ws)
# ----------------------------------------------------------------------------- bars + signals
def bars5(T: pd.DataFrame) -> pd.DataFrame:
b = T.set_index("t").resample("5s").agg(o=("p", "first"), h=("p", "max"), l=("p", "min"), c=("p", "last"),
v=("s", "sum"))
b = b.dropna(subset=["c"])
b["tc"] = b.index + pd.Timedelta(seconds=5)
return b.reset_index()
def signals(b: pd.DataFrame, P: dict) -> list[tuple]:
"""[(t, signal_price, stop[, 'cross'])]. burst/dollar: t = the bar close. micropb: t = the breakout bar START
(resolve the exact crossing trade with cross_time)."""
out = []
n = len(b)
if n < 30:
return out
c = b.c.values; h = b.h.values; l = b.l.values; o = b.o.values; v = b.v.values.astype(float)
t = b.t.values; tc = b.tc.values
med = pd.Series(v).rolling(24, min_periods=6).median().shift(1).values
lb = max(1, P["lookback_s"] // 5)
if P["setup"] == "burst":
prior_hi = pd.Series(h).rolling(lb, min_periods=1).max().shift(1).values
c30 = pd.Series(c).shift(6).values
for i in range(24, n):
if (h[i] > prior_hi[i] and med[i] > 0 and v[i] >= P["vol_mult"] * med[i] and c30[i] > 0
and c[i] / c30[i] - 1 >= P["burst_pct"]):
k = max(0, i - P["stop_lookback_s"] // 5)
out.append((tc[i], c[i], float(l[k:i + 1].min())))
elif P["setup"] == "micropb":
rs = max(1, P["run_s"] // 5)
i = rs
while i < n - 2:
lo = l[i - rs:i + 1].min(); hi = h[i - rs:i + 1].max()
if lo > 0 and hi / lo - 1 >= P["run_pct"] and h[i] == hi:
j = i + 1; pb_low = None
while j < n and j - i <= P["max_pb"]:
if h[j] > hi:
break
if c[j] < o[j] or c[j] < c[j - 1]:
pb_low = l[j] if pb_low is None else min(pb_low, l[j])
if hi - pb_low > 0.5 * (hi - lo):
pb_low = None; break
if j + 1 < n and h[j + 1] > h[j]:
out.append((t[j + 1], float(h[j] + TICK), float(pb_low - TICK), "cross"))
i = j + 1
break
j += 1
i += 1
elif P["setup"] == "dollar":
for i in range(24, n):
for step in (1.0, 0.5):
lvl = np.floor(c[i] / step) * step
if lvl > 0 and c[i - 1] < lvl <= h[i] and med[i] > 0 and v[i] >= P["vol_mult"] * med[i]:
out.append((tc[i], c[i], lvl * (1 - P["lvl_stop_pct"])))
break
return out
def cross_time(tt: np.ndarray, tp: np.ndarray, bar_start, level: float):
"""The first trade at/above `level` inside the 5-s bar starting at bar_start (no look-ahead)."""
k0 = np.searchsorted(tt, bar_start, side="left")
k1 = np.searchsorted(tt, bar_start + np.timedelta64(5, "s"), side="left")
hit = np.nonzero(tp[k0:k1] >= level)[0]
return tt[k0 + hit[0]] if len(hit) else None
def entry_check(bid: float, ask: float, sp: float, stop: float, P: dict = EXEC):
"""Execution gates from the simulator. Returns (entry, stop) or None."""
if bid <= 0 or ask <= 0 or ask < bid:
return None
mid = (bid + ask) / 2
if (ask - bid) / mid > P["max_spread"] or ask > sp * (1 + P["max_chase"]):
return None
if not (P["price_min"] <= sp <= P["price_max"]):
return None
entry = ask
stop = min(stop, entry * (1 - P["min_stop_pct"]))
if (entry - stop) / entry > P["max_stop_pct"] or stop >= entry:
return None
return entry, stop
# ----------------------------------------------------------------------------- features
def feats_at(T: pd.DataFrame, Q: pd.DataFrame, ts, prev_close: float) -> dict | None:
"""Features from trades/quotes strictly before ts (verbatim from ml_dataset.feats_at)."""
tt = T.t.values; k = np.searchsorted(tt, ts, side="left")
if k < 20:
return None
p = T.p.values[:k].astype(float); s = T.s.values[:k].astype(float); t = tt[:k]
last = p[-1]
def back(sec):
return np.searchsorted(t, ts - np.timedelta64(sec, "s"), side="left")
f = {}
for sec in (10, 30, 60, 300):
j = back(sec)
f[f"ret_{sec}s"] = last / p[j] - 1 if j < k and p[j] > 0 else 0.0
f[f"vol_{sec}s"] = s[j:].sum()
f[f"trades_{sec}s"] = k - j
j120 = back(120); j300 = back(300)
base = s[j300:j120].sum() / 180.0 if j120 > j300 else np.nan
f["vol_surge_10s"] = (f["vol_10s"] / 10.0) / base if base and base > 0 else np.nan
f["vol_surge_60s"] = (f["vol_60s"] / 60.0) / base if base and base > 0 else np.nan
j60 = back(60)
lr = np.diff(np.log(p[j60:])) if k - j60 > 3 else np.array([0.0])
f["rv_60s"] = float(np.std(lr)) * np.sqrt(max(1, len(lr)))
hod = p.max(); f["off_hod"] = last / hod - 1
vw = (p * s).sum() / s.sum() if s.sum() else last; f["vs_vwap"] = last / vw - 1
f["gain_prev"] = last / prev_close - 1
lo300 = p[j300:].min() if k > j300 else last; f["up_from_5m_low"] = last / lo300 - 1
qt = Q.t.values; qi = np.searchsorted(qt, ts, side="right") - 1
if qi >= 0:
bp, ap = float(Q.bp.iat[qi]), float(Q.ap.iat[qi]); bs, as_ = float(Q.bs.iat[qi]), float(Q["as"].iat[qi])
mid = (bp + ap) / 2
f["spread"] = (ap - bp) / mid if mid > 0 else np.nan
f["imb"] = bs / (bs + as_) if bs + as_ > 0 else np.nan
f["ask_size_usd"] = as_ * ap
tsn = pd.Timestamp(ts)
f["min_since_4am"] = (tsn - tsn.normalize() - pd.Timedelta(hours=4)).total_seconds() / 60
f["rth"] = float(tsn.time() >= pd.Timestamp("09:30").time())
f["price"] = last
f["cum_vol"] = s.sum()
return f
def finish_features(f: dict, setup: str, gap: float | None, med20: float | None) -> dict:
"""The derived features (ml_fresh.prep): setup code, gap only after 09:30, cumulative volume vs the 20-day median."""
f = dict(f)
f["setup_i"] = SETUP_I[setup]
f["gap_known"] = gap if (f.get("rth") == 1.0 and gap is not None) else np.nan
f["cumvol_vs_med"] = f["cum_vol"] / med20 if med20 else np.nan
return f
+422
View File
@@ -0,0 +1,422 @@
"""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"
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
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.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)
# --------------------------------------------------------------------- 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
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] = 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)
# --------------------------------------------------------------------- exits
async def exit_pos(self, pz: Pos, reason: str, last: float):
try:
if pz.leg_id:
await asyncio.to_thread(self.client.cancel_order, pz.leg_id)
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]); return
self.j.log("exit", symbol=pz.symbol, reason=reason, last=last, entry=pz.entry, r_est=(last - 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 manage_loop(self):
tick = 0
while True:
n = now_et(); tick += 1
for sym, pz in list(self.pos.items()):
bk = self.books[sym]
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 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:
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
except Exception as e: # noqa: BLE001
self.j.log("error", where="replace", symbol=sym, err=str(e)[:200])
if tick % 5 == 0 and pz.leg_id:
try:
leg = await asyncio.to_thread(self._order, pz.leg_id)
if leg.get("status") == "filled":
self.j.log("exit", symbol=sym, reason="stop", price=float(leg.get("filled_avg_price") or 0),
entry=pz.entry, r_est=(float(leg.get("filled_avg_price") or 0) - pz.entry) / pz.risk)
self.pos.pop(sym, None)
bk.cooldown_until = n + pd.Timedelta(seconds=engine.EXEC["cooldown_s"])
except Exception: # noqa: BLE001
pass
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.manage_loop()
for t in tasks:
t.cancel()
self.save_ticks()
self.j.log("stop", books=len(self.books))
if __name__ == "__main__":
asyncio.run(Trader().run())
+68
View File
@@ -0,0 +1,68 @@
"""Train + freeze the v2 judgement model exactly as validated in data/ticksim/PREREG_v2.md.
usage: python -m judge.model -> writes data/judge/model_v2.joblib (+ .json metadata)
"""
from __future__ import annotations
import json
from pathlib import Path
import joblib
import numpy as np
import pandas as pd
from sklearn.ensemble import HistGradientBoostingClassifier
from judge.engine import FEATS, SETUP_I
ROOT = Path(__file__).resolve().parent.parent
TICKSIM = ROOT / "data" / "ticksim"
OUT = ROOT / "data" / "judge"
PARAMS = dict(max_iter=200, learning_rate=0.05, max_leaf_nodes=15, min_samples_leaf=40, l2_regularization=1.0,
random_state=0)
def _prep(df: pd.DataFrame) -> pd.DataFrame:
daily = pd.read_pickle(ROOT / "data" / "ross" / "daily_all.pkl")
med = daily.sort_values("date").groupby("symbol").v.transform(lambda s: s.shift(1).rolling(20, min_periods=5).median())
daily = daily.assign(med20=med)[["symbol", "date", "gap", "med20"]]
if "gap" not in df:
df = df.merge(daily, on=["symbol", "date"], how="left")
df["cumvol_vs_med"] = df.cum_vol / df.med20
df["setup_i"] = df.setup.map(SETUP_I)
df["gap_known"] = np.where(df.rth == 1, df.gap, np.nan)
return df
def train() -> dict:
s1 = _prep(pd.read_pickle(TICKSIM / "ml_data.pkl"))
s2 = _prep(pd.read_pickle(TICKSIM / "ml_fresh_data.pkl"))
tr = pd.concat([s1, s2], ignore_index=True)
tr = tr[tr.date < "2026-08-01"]
m = HistGradientBoostingClassifier(**PARAMS).fit(tr[FEATS], (tr.r > 0).astype(int))
cutoff = float(np.quantile(m.predict_proba(tr[tr.rth == 1][FEATS])[:, 1], 0.8))
meta = dict(version="v2", trained_on="samples 1+2, signals dated < 2026-08-01", n_train=int(len(tr)),
cutoff=cutoff, features=FEATS, params=PARAMS, validation="PREREG_v2 PASS: sample-3 Aug-Sep RTH "
"top20% n=332 +1.11%/trade (day-block lo +0.54%)")
return dict(model=m, **meta)
def save(art: dict) -> Path:
OUT.mkdir(parents=True, exist_ok=True)
p = OUT / "model_v2.joblib"
joblib.dump(art, p)
(OUT / "model_v2.json").write_text(json.dumps({k: v for k, v in art.items() if k != "model"}, indent=2, default=str))
return p
def load(path: Path | None = None) -> dict:
return joblib.load(path or OUT / "model_v2.joblib")
def score(art: dict, feats: dict) -> float:
x = pd.DataFrame([{k: feats.get(k, np.nan) for k in art["features"]}])
return float(art["model"].predict_proba(x)[:, 1][0])
if __name__ == "__main__":
a = train()
print("saved", save(a), "cutoff", round(a["cutoff"], 4), "n_train", a["n_train"])
+79
View File
@@ -0,0 +1,79 @@
"""Parity check: feed recorded tick days through judge.decide in live-like 1-second steps and compare the
scored signals with the research pipeline (data/ticksim/ml_s3_data.pkl + the v2 model).
usage: python -m judge.replay [n_days]
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
from judge import engine
from judge.decide import DayContext, scan
from judge.model import load, ROOT
TICKSIM = ROOT / "data" / "ticksim"
def ctx_for(path: Path, d: dict) -> DayContext:
date, sym = path.stem.split("_", 1)
daily = _daily()
row = daily[(daily.symbol == sym) & (daily.date == date)]
gap = float(row.gap.iloc[0]) if len(row) else None
med20 = float(row.med20.iloc[0]) if len(row) and pd.notna(row.med20.iloc[0]) else None
ws = [[pd.Timestamp(s), pd.Timestamp(e)] for s, e in d["windows"]]
return DayContext(sym, date, float(d["prev_close"]), gap, med20, ws)
_D = None
def _daily():
global _D
if _D is None:
daily = pd.read_pickle(ROOT / "data" / "ross" / "daily_all.pkl")
med = daily.sort_values("date").groupby("symbol").v.transform(lambda s: s.shift(1).rolling(20, min_periods=5).median())
_D = daily.assign(med20=med)[["symbol", "date", "gap", "med20"]]
return _D
def replay_day(path: Path, art: dict, step_s: int = 1) -> list:
d = pd.read_pickle(path)
T = d["trades"].sort_values("t").reset_index(drop=True)
Q = d["quotes"].sort_values("t").reset_index(drop=True)
Q = Q[(Q.ap > 0) & (Q.bp > 0) & (Q.ap >= Q.bp)].reset_index(drop=True)
ctx = ctx_for(path, d)
start = max(pd.Timestamp(f"{ctx.date} 09:30"), T.t.min())
end = T.t.max()
seen = {}
now = start
last = None
while now <= end:
k = np.searchsorted(T.t.values, np.datetime64(now), side="right")
kq = np.searchsorted(Q.t.values, np.datetime64(now), side="right")
for dcs in scan(T.iloc[:k], Q.iloc[:kq], ctx, art, now=now, after=last):
key = (dcs.setup, str(dcs.ts))
if key not in seen:
seen[key] = dcs
last = now - pd.Timedelta(seconds=10) # re-check a short overlap (partial-bar signals)
now += pd.Timedelta(seconds=step_s)
return list(seen.values())
if __name__ == "__main__":
n = int(sys.argv[1]) if len(sys.argv) > 1 else 3
art = load()
research = pd.read_pickle(TICKSIM / "ml_s3_data.pkl")
files = sorted(TICKSIM.glob("ticks/*.pkl"))
s3 = set(research.date + "_" + research.symbol)
files = [f for f in files if f.stem in s3][:n]
for f in files:
date, sym = f.stem.split("_", 1)
live = replay_day(f, art, step_s=5)
r = research[(research.date == date) & (research.symbol == sym) & (research.rth == 1)]
live_take = sorted((x.setup, round(x.p, 4)) for x in live if x.take)
print(f"{f.stem}: live-style signals {len(live)} taken {len(live_take)} | research executed RTH trades {len(r)}")
print(" live taken:", live_take[:8])
+10
View File
@@ -0,0 +1,10 @@
@echo off
rem Paper trial of the v2 judgement model (Alpaca paper "Momentum V2"). Started by Task Scheduler at 08:10 local
rem (09:10 ET); the process exits by itself after 16:00 ET. Log: data\judge\logs\live_YYYY-MM-DD.log
setlocal
cd /d "%~dp0.."
set PYTHONIOENCODING=utf-8
if not exist data\judge\logs mkdir data\judge\logs
for /f %%i in ('powershell -NoProfile -Command "Get-Date -Format yyyy-MM-dd"') do set RUNDATE=%%i
data\ross\.venv\Scripts\python.exe -m judge.live >> "data\judge\logs\live_%RUNDATE%.log" 2>&1
exit /b %ERRORLEVEL%
+30
View File
@@ -0,0 +1,30 @@
"""judge.engine: data windows, execution gates and exact-cross timing (the look-ahead fix)."""
import numpy as np
import pandas as pd
from judge import engine
def test_windows_merge_open_and_squeeze_window():
ws = engine.windows("2026-09-28", pd.Timestamp("2026-09-28 10:00"))
assert ws == [[pd.Timestamp("2026-09-28 09:30"), pd.Timestamp("2026-09-28 11:00")]]
ws = engine.windows("2026-09-28", pd.Timestamp("2026-09-28 06:03"))
assert ws[0] == [pd.Timestamp("2026-09-28 06:01"), pd.Timestamp("2026-09-28 07:03")]
assert engine.in_windows(pd.Timestamp("2026-09-28 09:45"), ws) and not engine.in_windows(pd.Timestamp("2026-09-28 08:00"), ws)
def test_entry_check_gates():
assert engine.entry_check(4.99, 5.00, 5.00, 4.80) == (5.00, 4.80)
assert engine.entry_check(4.70, 5.00, 5.00, 4.80) is None # spread 6% > 3%
assert engine.entry_check(5.09, 5.10, 5.00, 4.80) is None # ask > signal price + 1% chase
assert engine.entry_check(4.99, 5.00, 5.00, 4.00) is None # stop 20% away > 8% max
entry, stop = engine.entry_check(4.99, 5.00, 5.00, 4.99) # stop inside 1% -> widened
assert stop == 5.00 * (1 - engine.EXEC["min_stop_pct"])
def test_cross_time_uses_the_first_trade_through_the_level_not_the_bar_start():
base = np.datetime64("2026-09-28T09:40:00")
tt = np.array([base + np.timedelta64(ms, "ms") for ms in (0, 1500, 3200, 4100)])
tp = np.array([4.95, 4.99, 5.01, 5.05])
assert engine.cross_time(tt, tp, base, 5.00) == base + np.timedelta64(3200, "ms")
assert engine.cross_time(tt, tp, base, 6.00) is None