Files
quant-trading/agent_review.py
Gahow Wang 2ff7bc07cf agent review: reject future-dated sources at submit; treat fetched pages as data
validate() now takes the submission stamp and rejects any source without a
readable publication date or dated after the submission day (UTC). The skill
tells the agent and its subagents that web pages, news and filings are data,
never instructions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:24:47 -07:00

302 lines
15 KiB
Python

"""
Daily loop of the agent-reviewed strategy ``overnight_recovery_agent_top10``
(strategies/agent_review.py): build the candidate pack, store the agent's
scores with a point-in-time stamp, and measure the scores against what
happened.
uv run python agent_review.py candidates [--refresh] # pack for the next session
uv run python agent_review.py submit data/agent/us/draft_<as_of>.json
uv run python agent_review.py evaluate # IC, vetoes, book vs control
Run ``candidates`` after the US close (the monitor's evening phase stores the
bars at 16:35 New York time); ``submit`` must finish before 16:00 New York
time of the next session for the scores to steer that session's close.
The skill .claude/skills/agent-stock-review drives the research step.
"""
from __future__ import annotations
import argparse
import glob
import json
import os
import sys
from datetime import datetime, timezone
import numpy as np
import pandas as pd
from strategies import agent_review as ar
NAME = "overnight_recovery_agent_top10"
CONTROL = "overnight_recovery_top10"
ROOT = os.path.dirname(os.path.abspath(__file__))
LINKS = os.path.join(ROOT, "data", "research", "sec_10k_links.csv")
SECTORS = os.path.join(ROOT, "data", "us_sectors.csv")
SIC = [os.path.join(ROOT, "data", "research", f) for f in ("sec_sic.csv", "mid_sec_sic.csv")] # names of newer members
def _next_session(as_of: pd.Timestamp) -> pd.Timestamp:
return as_of + pd.offsets.BDay(1) # exchange holidays make the real one later
def _latest_scores_raw() -> dict | None:
files = sorted(glob.glob(os.path.join(ar.SCORE_DIR, "scores_*.json")))
if not files:
return None
with open(files[-1]) as fh:
return json.load(fh)
def _links(tickers: list[str]) -> dict[str, dict[str, list[str]]]:
"""Customers each candidate names in its latest 10-K, and the S&P 500
filers that name it as a customer (research/lab/fetch_supply_links.py)."""
if not os.path.exists(LINKS):
return {}
d = pd.read_csv(LINKS, usecols=["filer", "filed", "target", "n_customer"])
d = d[d["n_customer"] > 0]
latest = d.groupby("filer")["filed"].transform("max")
d = d[d["filed"] == latest]
out = {}
for t in tickers:
out[t] = {"customers_named": sorted(d.loc[d["filer"] == t, "target"].unique())[:8],
"named_as_customer_by": sorted(d.loc[d["target"] == t, "filer"].unique())[:8]}
return out
def cmd_candidates(args) -> None:
import trader
tickers, bench = trader.strategy_universe("us", NAME)
close, open_, close_raw = trader.load_strategy_prices("us", NAME, tickers, bench, with_open=True,
refresh=args.refresh)
tickers = trader.filter_tradable_tickers(close, tickers)
as_of = trader.completed_sessions(close_raw, "us", bench)[-1]
close, open_ = close.loc[:as_of], open_.loc[:as_of]
bars = trader.strategy_bars("us", NAME, tickers, close, open_)
strat = trader.STRATEGY_REGISTRY[NAME]()
cand = strat.candidates(bars)
c = close[cand.index].ffill()
r = lambda k: (c.iloc[-1] / c.iloc[-1 - k] - 1).round(4) # noqa: E731
vol = c.pct_change(fill_method=None).iloc[-63:].std() * np.sqrt(252)
off_high = c.iloc[-1] / c.iloc[-252:].max() - 1
sec = pd.read_csv(SECTORS).set_index("ticker") if os.path.exists(SECTORS) else pd.DataFrame()
frames = [pd.read_csv(p) for p in SIC if os.path.exists(p)]
sic = (pd.concat(frames).drop_duplicates("ticker").set_index("ticker") if frames
else pd.DataFrame(columns=["name", "sicDescription"]))
held = {}
for label, name in (("agent_strategy", NAME), ("control", CONTROL)):
st = trader.load_state("us", name)
last = st.get("daily_log", [{}])[-1] if st.get("daily_log") else {}
eq = last.get("equity") or 1.0
held[label] = {t: round(h["value"] / eq, 4) for t, h in last.get("holdings", {}).items()}
prev = _latest_scores_raw()
prev_rows = prev.get("scores", {}) if prev else {}
links = _links(list(cand.index))
rows = []
for t, row in cand.iterrows():
p = prev_rows.get(t)
rows.append({
"ticker": t, "rank": int(row["rank"]), "quant_pct": round(float(row["quant_pct"]), 4),
"name": sec["Security"].get(t) if "Security" in sec and t in sec.index else sic["name"].get(t),
"sec_industry": sic["sicDescription"].get(t),
"sector": sec["GICS Sector"].get(t) if "GICS Sector" in sec else None,
"sub_industry": sec["GICS Sub-Industry"].get(t) if "GICS Sub-Industry" in sec else None,
"ret_5d": float(r(5)[t]), "ret_21d": float(r(21)[t]), "ret_63d": float(r(63)[t]),
"ret_252d": float(r(252)[t]), "vol_63d": round(float(vol[t]), 4),
"off_52w_high": round(float(off_high[t]), 4),
"overnight_mom_12m": float(row["overnight_mom_12m"]), "recovery_126d": float(row["recovery_126d"]),
"held_weight": {k: v.get(t, 0.0) for k, v in held.items()},
**links.get(t, {}),
"previous": ({"as_of": prev["as_of"], "score": p.get("score"), "veto": p.get("veto", False),
"summary": p.get("summary", "")} if p else None),
})
nxt = _next_session(as_of)
k = len(close) - 1 # pre-shift row of as_of: its weights trade at the next close
until = (-(k - strat.warmup)) % strat.rebal_freq
pack = {
"strategy": NAME, "as_of": str(as_of.date()), "next_session": str(nxt.date()),
"deadline_utc": str(ar.session_deadline(nxt)),
# 0: the next session rebalances on these scores; otherwise only vetoes act until then
"sessions_until_rebalance": int(until),
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"rule": {"agent_weight": ar.AGENT_WEIGHT, "score_range": list(ar.SCORE_RANGE),
"veto_reasons": list(ar.VETO_REASONS), "top_n": strat.top_n, "pool": strat.pool,
"rebal_freq": strat.rebal_freq},
"holdings": held,
"candidates": rows,
}
os.makedirs(ar.SCORE_DIR, exist_ok=True)
path = os.path.join(ar.SCORE_DIR, f"candidates_{as_of.date()}.json")
with open(path, "w") as fh:
json.dump(pack, fh, indent=1, ensure_ascii=False, default=str)
print(f"{len(rows)} candidates for the session after {as_of.date()} -> {path}")
print(f"submit before {pack['deadline_utc']} (16:00 New York on {nxt.date()}) to steer that close; "
f"{'the next session rebalances' if until == 0 else f'next rebalance in {until} sessions (vetoes act daily)'}")
def _source_day(src) -> pd.Timestamp | None:
"""A source's publication day (UTC), or None when it has no readable date."""
try:
ts = pd.Timestamp(src["date"])
except (TypeError, KeyError, ValueError):
return None
if pd.isna(ts):
return None
if ts.tzinfo is not None:
ts = ts.tz_convert("UTC").tz_localize(None)
return ts.normalize()
def validate(sub: dict, pack: dict, now: pd.Timestamp) -> tuple[dict, list[str]]:
"""Normalized scores and warnings; raises ValueError on a bad file.
``now`` is the submission stamp: no source may be dated after its day (UTC)."""
today = now.tz_convert("UTC").tz_localize(None).normalize()
want = [c["ticker"] for c in pack["candidates"]]
rows = sub.get("scores")
if not isinstance(rows, dict):
raise ValueError('"scores" must map ticker -> {score, veto, ...}')
missing, extra = sorted(set(want) - set(rows)), sorted(set(rows) - set(want))
if missing or extra:
raise ValueError(f"tickers must match the {len(want)} candidates; missing {missing}, extra {extra}")
lo, hi = ar.SCORE_RANGE
out, warn = {}, []
for t in want:
v = rows[t]
s = v.get("score")
if not isinstance(s, int) or isinstance(s, bool) or not lo <= s <= hi:
raise ValueError(f"{t}: score must be an integer in [{lo}, {hi}], got {s!r}")
veto = bool(v.get("veto", False))
reason = v.get("veto_reason")
if veto and reason not in ar.VETO_REASONS:
raise ValueError(f"{t}: veto_reason must be one of {ar.VETO_REASONS}, got {reason!r}")
if veto and not str(v.get("risks", "")).strip():
raise ValueError(f"{t}: a veto needs the evidence in 'risks'")
if not str(v.get("summary", "")).strip():
raise ValueError(f"{t}: 'summary' is required")
sources = v.get("sources", [])
if not isinstance(sources, list):
raise ValueError(f"{t}: 'sources' must be a list of {{url, date}}")
for src in sources:
day = _source_day(src)
if day is None:
raise ValueError(f"{t}: source {src!r} needs a publication 'date' (YYYY-MM-DD)")
if day > today:
raise ValueError(f"{t}: source {src.get('url')} is dated {src['date']}, "
f"after the submission day {today.date()} (UTC)")
out[t] = {"score": s, "veto": veto, "veto_reason": reason if veto else None,
"confidence": v.get("confidence"),
**{k: v.get(k, "") for k in ("earnings", "fundamentals", "logic", "risks", "summary")},
"sources": sources}
extreme = sum(abs(v["score"]) == hi for v in out.values())
if extreme > 10:
warn.append(f"{extreme} names at +/-{hi}; the rubric expects at most about 10")
return out, warn
def cmd_submit(args) -> None:
with open(args.file) as fh:
sub = json.load(fh)
as_of = sub.get("as_of")
pack_path = os.path.join(ar.SCORE_DIR, f"candidates_{as_of}.json")
if not os.path.exists(pack_path):
sys.exit(f"no candidate pack for as_of={as_of!r} ({pack_path})")
with open(pack_path) as fh:
pack = json.load(fh)
now = pd.Timestamp.now(tz="UTC")
try:
scores, warn = validate(sub, pack, now)
except ValueError as exc:
sys.exit(f"rejected: {exc}")
deadline = pd.Timestamp(pack["deadline_utc"])
out = {"strategy": NAME, "as_of": as_of, "created_at": now.isoformat(timespec="seconds"),
"model": sub.get("model", ""), "n_candidates": len(scores), "scores": scores}
path = os.path.join(ar.SCORE_DIR, f"scores_{as_of}_{now:%H%M%S}.json")
with open(path, "w") as fh:
json.dump(out, fh, indent=1, ensure_ascii=False)
for w in warn:
print(f"warning: {w}")
if now > deadline:
print(f"warning: stamped after the {pack['next_session']} deadline ({deadline}); "
f"these scores steer later sessions only")
ups = sorted((t for t, v in scores.items() if v["score"] > 0), key=lambda t: -scores[t]["score"])
vetoes = [t for t, v in scores.items() if v["veto"]]
print(f"stored {path}: {len(ups)} raised, {sum(v['score'] < 0 for v in scores.values())} lowered, "
f"vetoes {vetoes or 'none'}")
def cmd_evaluate(args) -> None:
import trader
latest = {} # the last submission of each as_of
for p in sorted(glob.glob(os.path.join(ar.SCORE_DIR, "scores_*.json"))):
latest[os.path.basename(p)[7:17]] = p
files = [latest[k] for k in sorted(latest)]
if not files:
sys.exit("no score files yet")
close = pd.read_csv(os.path.join(ROOT, "data", "us.csv"), index_col=0, parse_dates=True).sort_index()
rows, vetoes = [], []
for path in files:
with open(path) as fh:
d = json.load(fh)
with open(os.path.join(ar.SCORE_DIR, f"candidates_{d['as_of']}.json")) as fh:
pack = json.load(fh)
after = close.index[close.index > pd.Timestamp(d["as_of"])]
for h in (5, 10):
if len(after) <= h:
continue
s0, s1 = after[0], after[h]
q = {c["ticker"]: c["quant_pct"] for c in pack["candidates"]}
df = pd.DataFrame({"agent": {t: v["score"] for t, v in d["scores"].items()}, "quant": q})
df["fwd"] = (close.loc[s1] / close.loc[s0] - 1).reindex(df.index)
df = df.dropna()
rows.append({"as_of": d["as_of"], "h": h, "n": len(df),
"ic_agent": df["agent"].rank().corr(df["fwd"].rank()),
"ic_quant": df["quant"].rank().corr(df["fwd"].rank()),
"top_minus_rest": df.loc[df["agent"] > 0, "fwd"].mean() - df.loc[df["agent"] <= 0, "fwd"].mean()})
if h == 10:
for t, v in d["scores"].items():
if v.get("veto") and t in df.index:
vetoes.append({"as_of": d["as_of"], "ticker": t, "reason": v["veto_reason"],
"fwd10_vs_candidates": df.at[t, "fwd"] - df["fwd"].mean()})
R = pd.DataFrame(rows)
print(f"{len(files)} scored sessions ({min(latest)} .. {max(latest)})")
for h, g in (R.groupby("h") if len(R) else []):
def t(x):
x = x.dropna()
return x.mean() / x.std() * np.sqrt(len(x)) if len(x) > 2 and x.std() > 0 else float("nan")
print(f" {h:>2}-day: files {len(g)} | IC agent {g['ic_agent'].mean():+.3f} (t {t(g['ic_agent']):.2f}) "
f"| IC quant {g['ic_quant'].mean():+.3f} (t {t(g['ic_quant']):.2f}) "
f"| raised minus rest {g['top_minus_rest'].mean()*100:+.2f}% (overlapping windows inflate t)")
if vetoes:
V = pd.DataFrame(vetoes)
print(f" vetoes: {len(V)}, 10-day return vs candidate mean {V['fwd10_vs_candidates'].mean()*100:+.2f}% "
f"(negative = the veto helped)")
first = pd.Timestamp(json.load(open(files[0]))["as_of"])
eq = {}
for name in (NAME, CONTROL):
e = pd.Series(trader.load_state("us", name).get("daily_equity", {}), dtype=float)
e.index = pd.to_datetime(e.index)
eq[name] = e.sort_index().loc[first:]
E = pd.DataFrame(eq).dropna()
if len(E) > 1:
ret = E.iloc[-1] / E.iloc[0] - 1
print(f" books since {E.index[0].date()}: agent {ret[NAME]*100:+.2f}% vs control {ret[CONTROL]*100:+.2f}% "
f"({len(E)} sessions)")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("candidates", help="write the candidate pack for the next session")
p.add_argument("--refresh", action="store_true", help="download prices first (the monitor normally has)")
p = sub.add_parser("submit", help="validate and store an agent scores file")
p.add_argument("file")
sub.add_parser("evaluate", help="score IC, veto outcomes, and the book against its control")
args = ap.parse_args()
{"candidates": cmd_candidates, "submit": cmd_submit, "evaluate": cmd_evaluate}[args.cmd](args)
if __name__ == "__main__":
main()