Files
momentum-system/research/daily_report.py
T
momentum devandClaude Fable 5.1 b5b38ab798 fix: scorecard counts round trips the broker filled but the journal never closed (TNON)
On 2026-10-02 the live bot bought 7 TNON at 4.62 and the Tradier sell-stop filled at
4.30 forty-nine seconds later, before a position record existed, so no position_closed
event was written and the 80-trade scorecard silently dropped a -1R loser. The daily
equity-vs-P&L gap ($2.31 that day, cents everywhere else) is what exposed it.

load_trades now merges the journal with a cross-check of the broker's filled orders
(account_orders_updated snapshots, one row per broker order id). For any session day and
symbol with a filled buy and sell but no journaled close, reconstruct_missing synthesises
the trade from the fills: fill VWAPs for entry/exit, the sell-stop's stop price for R.
Such trades are labelled "[broker fill; journal missed]" and the daily report alerts on a
same-day miss. The paper side of the exit A/B skips the check: that store's order
snapshots run to 2.2 GB over the window and its Alpaca journal is reconciled by
alpaca_paper/sync.

Corrected test state: 16/80, mean R -0.08, realized +$0.91 (was 15/80, -0.02, +$3.15).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXxioW7HA18AY1gqnzA2HN
2026-10-08 12:58:53 -05:00

275 lines
14 KiB
Python

"""Daily after-close report: the two things that matter right now, plus the plumbing they depend on.
1. the live opening-range bot's frozen 80-trade test (research/live_test_freeze.json)
2. the judge v2 paper trial (judge/PAPER_TRIAL.md): paper vs simulator, trade by trade
plus the old-vs-new exit A/B (paper stack 5433 vs live 5434) and stack / recorder health.
ALERTS at the top list anything the owner has to act on. Runs from Task Scheduler "Momentum\\DailyReport" on
weekdays after the close; read-only everywhere (PG_SKIP_SCHEMA, plain SELECTs).
usage: python -m research.daily_report [YYYY-MM-DD]
out: data/reports/daily_<date>.md
"""
from __future__ import annotations
import json
import os
import random
import statistics as st
import sys
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
from zoneinfo import ZoneInfo
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
os.environ["PG_SKIP_SCHEMA"] = "1"
from dotenv import dotenv_values # noqa: E402
from research.live_scorecard import (bootstrap_mean_ci, config_drift, load_freeze, # noqa: E402
load_trades, verdict)
ET = ZoneInfo("America/New_York")
OUT = ROOT / "data" / "reports"
TRIAL_START = "2026-10-02"
AB_SINCE = "2026-09-14"
class Con:
"""Just enough of the research-DB interface for live_scorecard's helpers ('?' placeholders)."""
def __init__(self, port: int):
import psycopg2
u = urlparse(dotenv_values(ROOT / ".env")["DATABASE_URL"])
self.c = psycopg2.connect(host=u.hostname, port=port, user=u.username, password=u.password,
dbname=u.path[1:], connect_timeout=10)
self.c.autocommit = True
with self.c.cursor() as cur:
cur.execute("SET statement_timeout = 120000")
def execute(self, sql, params=()):
cur = self.c.cursor()
cur.execute(sql.replace("%", "%%").replace("?", "%s"), list(params))
return cur
def live_section(con, today, alerts) -> list[str]:
freeze = load_freeze()
since = freeze["frozen_on"]
trades = load_trades(con, since)
rs = [t["r"] for t in trades if t["r"] is not None]
pnl = sum(t["pnl"] for t in trades)
halts = len({ts.date() for ts, _ in con.execute(
"SELECT timestamp, message FROM events WHERE event_type='risk_rule_triggered' AND timestamp >= ? "
"AND message LIKE 'EQUITY FLOOR%'", [since]).fetchall() if 9 <= ts.hour < 16})
eq = [json.loads(r[0]).get("total_equity") for r in con.execute(
"SELECT payload_json FROM events WHERE event_type='account_summary_updated' AND timestamp >= ? "
"ORDER BY timestamp DESC LIMIT 1", [today]).fetchall()]
current = dict(con.execute("SELECT key, value FROM config_overrides").fetchall())
drift = config_drift(freeze["settings"], current)
v = verdict(rs, pnl, halts)
lines = ["## 1. Live opening-range bot: frozen 80-trade test", "",
f"- **{v}** (since {since})"]
if rs:
lo, hi = bootstrap_mean_ci(rs)
lines.append(f"- mean R {st.mean(rs):+.3f} (90% CI {lo:+.2f} .. {hi:+.2f}), win {sum(r > 0 for r in rs)}/{len(rs)}, "
f"realized P&L ${pnl:+.2f}")
if eq and eq[0]:
floor = float(freeze["settings"].get("TRADING_EQUITY_FLOOR", 215))
lines.append(f"- equity ${float(eq[0]):.2f} (floor ${floor:.0f}, headroom ${float(eq[0]) - floor:.2f})")
if float(eq[0]) - floor < 5:
alerts.append(f"Live equity ${float(eq[0]):.2f} is within $5 of the ${floor:.0f} floor.")
todays = [t for t in trades if str(t["ts"])[:10] == today]
missed_today = [t for t in todays if t.get("source") == "broker"]
if missed_today:
alerts.append("Journal missed " + ", ".join(t["symbol"] for t in missed_today)
+ " today: the broker filled a round trip with no position_closed event "
"(counted in the scorecard from the fills).")
lines.append(f"- today: {len(todays)} trade(s)" + "".join(
f"\n - {t['symbol']} {t['r']:+.2f}R ${t['pnl']:+.2f} ({t['reason']})" if t["r"] is not None else
f"\n - {t['symbol']} ${t['pnl']:+.2f} ({t['reason']})" for t in todays))
for d in freeze.get("known_deviations", []):
if not d.get("to"):
lines.append(f"- open deviation since {d['from']}: {d['what'][:140]}...")
if drift:
alerts.append("Live settings drifted from the freeze: " + ", ".join(f"{k} {a!r}->{b!r}" for k, (a, b) in drift.items()))
if v.startswith("FAIL") or v.startswith("PASS"):
alerts.append(f"Live test verdict: {v}")
return lines + [""]
def ab_section(paper, live, alerts) -> list[str]:
P, L = load_trades(paper, AB_SINCE, broker_check=False), load_trades(live, AB_SINCE)
used, diff = set(), []
for x in L:
for i, p in enumerate(P):
if i not in used and str(p["ts"])[:10] == str(x["ts"])[:10] and p["symbol"] == x["symbol"] \
and p["r"] is not None and x["r"] is not None:
used.add(i); diff.append(x["r"] - p["r"]); break
lines = ["## Exit A/B: live 12% trail vs paper old tight exits (same stock, same day)", ""]
if diff:
rng = random.Random(1)
bs = sorted(st.mean(rng.choices(diff, k=len(diff))) for _ in range(10000))
lines.append(f"- pairs {len(diff)}; live minus old {st.mean(diff):+.3f}R (90% CI {bs[500]:+.2f} .. {bs[9500]:+.2f})")
if len(diff) >= 50:
alerts.append(f"Exit A/B reached {len(diff)} pairs: decision point on the live exits (after the 80-trade test).")
return lines + [""]
def judge_section(today, alerts) -> list[str]:
from judge.review import review
jdir = ROOT / "data" / "judge"
days = sorted(p.stem for p in (jdir / "journal").glob("*.jsonl") if p.stem >= TRIAL_START)
lines = ["## 2. Judge v2 paper trial (judge/PAPER_TRIAL.md)", ""]
all_f, today_df = [], None
for d in days:
if not any((jdir / "ticks").glob(f"{d}_*.pkl")):
continue
df, md = review(d)
(jdir / "reviews").mkdir(exist_ok=True)
(jdir / "reviews" / f"{d}.md").write_text(md, encoding="utf-8")
if d == today:
today_df = df
if len(df):
all_f.append(df.assign(day=d))
import pandas as pd
allx = pd.concat(all_f, ignore_index=True) if all_f else pd.DataFrame()
fl = allx[allx.status == "filled"] if len(allx) else allx
n = len(fl)
lines.append(f"- counted trades {n}/60 (since {TRIAL_START}); orders {len(allx)}, unfilled "
f"{int((allx.status == 'unfilled').sum()) if len(allx) else 0}")
if n:
gap = (fl.paper_r - fl.sim_r).dropna().tolist()
if gap:
rng = random.Random(2)
bs = sorted(st.mean(rng.choices(gap, k=len(gap))) for _ in range(10000))
lines.append(f"- execution parity: paper minus sim {st.mean(gap):+.3f}R per trade "
f"(90% CI {bs[500]:+.2f} .. {bs[9500]:+.2f}); target >= -0.15R")
if n >= 60:
ok = st.mean(gap) >= -0.15 and bs[9500] >= 0
alerts.append(f"Judge paper trial reached {n} trades: parity {'HOLDS' if ok else 'FAILS'} "
f"({st.mean(gap):+.3f}R). Decision per judge/PAPER_TRIAL.md.")
lines.append(f"- 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}")
if today_df is not None and len(today_df):
tf = today_df[today_df.status == "filled"]
flagged = tf[tf["flags"] != ""] if len(tf) else tf
lines.append(f"- today: {len(tf)} filled, {len(today_df) - len(tf)} unfilled, {len(flagged)} flagged "
f"(details: data/judge/reviews/{today}.md)")
for r in flagged.itertuples():
lines.append(f" - {r.sym}: paper {r.paper_r} vs sim {r.sim_r} [{r.flags}]")
j = jdir / "journal" / f"{today}.jsonl"
if j.exists():
rows = [json.loads(x) for x in j.read_text(encoding="utf-8").splitlines() if x.strip()]
errs = [r for r in rows if r.get("kind") == "error"]
starts = [r for r in rows if r.get("kind") == "start"]
stopped = any(r.get("kind") == "stop" for r in rows)
lines.append(f"- trader: {len(starts)} start(s), {len(errs)} error row(s), "
f"{'ended cleanly' if stopped else 'NO clean stop row'}")
if len(errs) >= 10:
from collections import Counter
top = Counter((e.get("where"), str(e.get("err", ""))[:60]) for e in errs).most_common(1)[0]
alerts.append(f"Judge trader logged {len(errs)} errors today (top: {top[0]} x{top[1]}). Possible loop.")
if len(starts) > 1:
alerts.append(f"Judge trader restarted {len(starts) - 1} time(s) today (crash or closed window).")
elif datetime.now(ET).weekday() < 5:
alerts.append("Judge trader wrote no journal today: did it run?")
return lines + [""]
def health_section(paper, live, today, alerts) -> list[str]:
lines = ["## Health", ""]
for name, con in (("live 5434", live), ("paper 5433", paper)):
try:
first, last, n = con.execute(
"SELECT min(timestamp), max(timestamp), count(*) FROM events WHERE event_type='module_tick' "
"AND timestamp >= ? AND timestamp < ?", [f"{today} 09:30", f"{today} 16:00"]).fetchone()
if first is not None and first.strftime("%H:%M") > "09:40":
alerts.append(f"The {name} stack was down at the open: first heartbeat {first:%H:%M} ET.")
gap = con.execute(
"SELECT max(g) FROM (SELECT timestamp - lag(timestamp) OVER (ORDER BY timestamp) AS g FROM events "
"WHERE event_type='module_tick' AND timestamp >= ? AND timestamp < ?) x",
[f"{today} 09:30", f"{today} 16:00"]).fetchone()[0]
gap_min = gap.total_seconds() / 60 if gap is not None else None
lines.append(f"- {name}: {n} heartbeats in market hours, last {last}"
+ (f", longest gap {gap_min:.0f} min" if gap_min is not None else ""))
if gap_min is not None and gap_min >= 10:
alerts.append(f"The {name} stack was silent for {gap_min:.0f} min during market hours.")
if n == 0:
alerts.append(f"The {name} stack wrote no heartbeats during market hours.")
elif last and (datetime.now(ET).replace(tzinfo=None) - last).total_seconds() > 900 \
and last.strftime("%H:%M") < "15:45": # silent for 15+ min, before the close
alerts.append(f"The {name} stack went silent at {last:%H:%M} ET.")
except Exception as e: # noqa: BLE001
lines.append(f"- {name}: ERROR {str(e)[:120]}")
alerts.append(f"Could not read the {name} database: {str(e)[:80]}")
bk = Path(os.environ.get("LIVE_BACKUP_DIR", r"C:\Users\Admin\momentum-backups")) / "status.json"
if bk.exists():
b = json.loads(bk.read_text(encoding="utf-8"))
age_h = (datetime.now(ET) - datetime.fromisoformat(b["at"])).total_seconds() / 3600
lines.append(f"- live DB backup: {'ok' if b.get('ok') else 'FAILED'} {b['at'][:16]} "
f"({b.get('size_mb', '?')} MB, {len(b.get('kept', []))} kept)")
if not b.get("ok"):
alerts.append(f"Last live-DB backup FAILED: {str(b.get('error'))[:120]}")
elif age_h > 36:
alerts.append(f"Last good live-DB backup is {age_h:.0f} h old (nightly task not running?).")
else:
alerts.append("No live-DB backup status found (the Momentum LiveDbBackup task has never run).")
pw = ROOT / "data" / "prwire" / f"{today}.jsonl"
if pw.exists():
rows = [json.loads(x) for x in pw.read_text(encoding="utf-8").splitlines() if x.strip()]
items = [r for r in rows if r.get("guid")]
lines.append(f"- press-release recorder: {len(items)} items today, last seen {items[-1]['seen_utc'] if items else '-'}")
if not items:
alerts.append("The press-release recorder logged nothing today.")
else:
alerts.append("The press-release recorder has no file for today.")
return lines + [""]
def main(argv=None) -> str:
argv = sys.argv[1:] if argv is None else argv
dates = [a for a in argv if not a.startswith("--")]
today = dates[0] if dates else datetime.now(ET).strftime("%Y-%m-%d")
alerts: list[str] = []
body: list[str] = []
live = paper = None
try:
live = Con(5434)
body += live_section(live, today, alerts)
except Exception as e: # noqa: BLE001
alerts.append(f"Live database unreachable: {str(e)[:100]}")
body += judge_section(today, alerts)
try:
paper = Con(5433)
if live:
body += ab_section(paper, live, alerts)
except Exception as e: # noqa: BLE001
alerts.append(f"Paper database unreachable: {str(e)[:100]}")
if live and paper:
body += health_section(paper, live, today, alerts)
head = [f"# Daily report {today}", "", "### ALERTS" if alerts else "### No alerts: nothing needs action.", ""]
head += [f"- {a}" for a in alerts] + ([""] if alerts else [])
md = "\n".join(head + body)
OUT.mkdir(parents=True, exist_ok=True)
(OUT / f"daily_{today}.md").write_text(md, encoding="utf-8")
print(md)
if alerts and "--no-telegram" not in argv:
notify(today, alerts, body)
return md
def notify(today: str, alerts: list[str], body: list[str]) -> bool:
"""Phone push via the stack's Telegram bot (credentials in .env). Sent only when there are alerts."""
from runtime.notifier import send_telegram
env = dotenv_values(ROOT / ".env")
status = next((b for b in body if b.startswith("- **")), "").replace("*", "").lstrip("- ")
text = (f"Momentum {today}: {len(alerts)} alert(s)\n" + "\n".join(f"- {a}" for a in alerts)
+ (f"\nLive test: {status}" if status else "") + f"\nReport: data/reports/daily_{today}.md")
return send_telegram(text[:3900], token=env.get("TELEGRAM_BOT_TOKEN"), chat_id=env.get("TELEGRAM_CHAT_ID"))
if __name__ == "__main__":
main()