Files
momentum-system/judge/review.py
T
momentum devandClaude Opus 5.5 8dc97a53f8 feat: judge paper trader runs a software stop alongside the broker stop
It sells as soon as any trade prints at or through the stop, or below the 6% trail
once +1R, walking the new prints in order exactly as the validated simulator does.
Whichever acts first wins: the broker stop or this one.
Why: Alpaca's paper engine filled triggered stops 6-60 s late on 2026-09-30 (CMCT,
PWCM; judge.review), which makes the trial look worse than the simulation for reasons
that are not the strategy's. Brokers also run no stop orders outside regular hours,
so any premarket trading needs this.
JUDGE_SOFT_STOP=0 turns it off. judge.review treats soft_stop/soft_trail as
stop/trail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
2026-10-01 07:40:24 -05:00

161 lines
6.8 KiB
Python

"""Per-trade review of a judge paper-trading day against the simulator, on the day's recorded ticks.
For every order the trader sent, it lines up what the paper account did with what the validated simulator
(data/ticksim/sim.py rules) does on the same ticks:
- filled orders: the sim exit from the actual fill (stop / 6% trail after +1R / 20-min time stop, sell at the bid
300 ms after the trigger) vs the paper exit. Flagged when R differs by > FLAG_R or the exit reason differs.
- unfilled orders: the R the sim would have made from the order's limit price (the paper-only cost of missing).
The day summary is the evidence for the paper-vs-simulation question before any real money.
usage: python -m judge.review [YYYY-MM-DD ...] (default: the latest journal with saved ticks)
writes data/judge/reviews/<date>.md
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import numpy as np
import pandas as pd
from judge import engine
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "data" / "judge"
FLAG_R = 0.3
LAT = np.timedelta64(int(engine.EXEC["lat_ms"]), "ms")
def sim_exit(T: pd.DataFrame, Q: pd.DataFrame, t0, entry: float, stop: float) -> dict | None:
"""The simulator's exit for a long entered at `entry` at time t0 with initial `stop` (sim.py exit loop)."""
if stop >= entry or T.empty or Q.empty:
return None
tt, tp = T.t.values, T.p.values
qt = Q.t.values
P = engine.EXEC
risk = entry - stop
k = np.searchsorted(tt, np.datetime64(t0), side="right")
deadline = np.datetime64(t0) + np.timedelta64(int(P["hold_s"]), "s")
hw, cur = entry, stop
reason, trig = "eod", tt[-1]
while k < len(tt):
px, tk = tp[k], tt[k]
if tk >= deadline:
reason, trig = "time", tk
break
hw = max(hw, px)
if (hw - entry) / risk >= P["trail_after_r"]:
cur = max(cur, hw * (1 - P["trail_pct"]), entry)
if px <= cur:
reason, trig = ("stop" if cur <= stop else "trail"), tk
break
k += 1
qj = np.searchsorted(qt, trig + LAT, side="right") - 1
px = float(Q.bp.iat[max(qj, 0)])
return dict(exit=px, r=(px - entry) / risk, reason=reason, at=pd.Timestamp(trig))
def load_ticks(day: str, sym: str):
f = OUT / "ticks" / f"{day}_{sym}.pkl"
if not f.exists():
return None, None
d = pd.read_pickle(f)
T = d["trades"].sort_values("t").reset_index(drop=True)
Q = d["quotes"].sort_values("t").reset_index(drop=True)
return T, Q[(Q.ap > 0) & (Q.bp > 0) & (Q.ap >= Q.bp)].reset_index(drop=True)
def review(day: str) -> tuple[pd.DataFrame, str]:
rows = []
for line in (OUT / "journal" / f"{day}.jsonl").read_text(encoding="utf-8").splitlines():
try:
rows.append(json.loads(line))
except ValueError:
pass
out, open_ = [], {}
for r in rows:
k = r.get("kind")
if k == "order":
open_[r["symbol"]] = dict(order=r, fill=None)
elif k == "unfilled" and r["symbol"] in open_:
o = open_.pop(r["symbol"])["order"]
out.append(_row(day, o, None, None))
elif k == "fill" and r["symbol"] in open_:
open_[r["symbol"]]["fill"] = r
elif k == "exit" and r["symbol"] in open_ and open_[r["symbol"]]["fill"]:
o = open_.pop(r["symbol"])
out.append(_row(day, o["order"], o["fill"], r))
df = pd.DataFrame(out)
return df, _markdown(day, df)
def _row(day, o, f, x) -> dict:
sym = o["symbol"]
T, Q = load_ticks(day, sym)
base = dict(sym=sym, setup=o.get("setup"), p=round(float(o.get("p") or 0), 3), order_at=o["at"][11:19],
limit=o.get("limit"), stop=o.get("stop"))
if f is None:
s = sim_exit(T, Q, pd.Timestamp(o["at"]), float(o["limit"]), float(o["stop"])) if T is not None else None
return {**base, "status": "unfilled", "sim_r": None if s is None else round(s["r"], 2),
"sim_reason": None if s is None else s["reason"]}
entry, stop = float(f["price"]), float(f["stop"])
risk = entry - stop
paper_px = x.get("price") if x.get("price") is not None else x.get("last")
paper_r = (float(paper_px) - entry) / risk if paper_px is not None and risk > 0 else None
s = sim_exit(T, Q, pd.Timestamp(f["at"]), entry, stop) if T is not None else None
row = {**base, "status": "filled", "fill": entry, "entry_slip": round(entry - float(o["limit"]), 4),
"paper_r": None if paper_r is None else round(paper_r, 2), "paper_reason": x.get("reason"),
"exit_at": x["at"][11:19],
"sim_r": None if s is None else round(s["r"], 2), "sim_reason": None if s is None else s["reason"],
"sim_exit_at": None if s is None else str(s["at"])[11:19]}
flags = []
if T is None:
flags.append("no ticks")
elif s and paper_r is not None:
if abs(paper_r - s["r"]) > FLAG_R:
flags.append(f"R gap {paper_r - s['r']:+.2f}")
why = (x.get("reason") or "").replace("soft_", "")
if why not in (s["reason"], "flat_at_broker") and not (why == "stop" and s["reason"] == "trail"):
flags.append(f"reason {x.get('reason')} vs sim {s['reason']}")
if abs(row["entry_slip"]) > 0.005:
flags.append(f"entry slip {row['entry_slip']:+.3f}")
row["flags"] = "; ".join(flags)
return row
def _markdown(day: str, df: pd.DataFrame) -> str:
if df.empty:
return f"# Judge paper review {day}\n\nNo orders.\n"
fl = df[df.status == "filled"]
uf = df[df.status == "unfilled"]
lines = [f"# Judge paper review {day}", "",
f"- orders {len(df)}: filled {len(fl)}, unfilled {len(uf)}",
f"- filled: paper R sum {fl.paper_r.sum():+.2f} (mean {fl.paper_r.mean():+.2f}) | "
f"sim on the same fills {fl.sim_r.sum():+.2f} (mean {fl.sim_r.mean():+.2f})" if len(fl) else "- filled: none",
f"- unfilled: the sim would have made {uf.sim_r.sum():+.2f}R on them" if len(uf) else "- unfilled: none",
f"- flagged trades: {int((fl['flags'] != '').sum()) if len(fl) else 0}", "",
_table(df.fillna("")), ""]
return "\n".join(lines)
def _table(df: pd.DataFrame) -> str:
cols = list(df.columns)
out = ["| " + " | ".join(cols) + " |", "|" + "---|" * len(cols)]
out += ["| " + " | ".join(str(v) for v in r) + " |" for r in df.itertuples(index=False)]
return "\n".join(out)
def main(argv=None) -> None:
days = (argv if argv is not None else sys.argv[1:]) or [
sorted({p.stem.split("_")[0] for p in (OUT / "ticks").glob("*.pkl")})[-1]]
(OUT / "reviews").mkdir(parents=True, exist_ok=True)
for day in days:
df, md = review(day)
(OUT / "reviews" / f"{day}.md").write_text(md, encoding="utf-8")
print(md)
if __name__ == "__main__":
main()