Files
quant-trading/agent_review.py
Gahow Wang 0e1e0a202c research: timestamped Yahoo analyst snapshots and a forward-only EPS-revision overlay
collect archives Yahoo's earnings/revenue estimates, EPS trend, EPS
revisions and price targets as immutable snapshots; a snapshot is usable
only after its created_at, so the vendor's 30/90-day history never reaches
earlier bars. evaluate replays overnight_recovery_top10 with an EPS-revision
re-rank of the quant top 50 on the same ledger and costs and returns NEEDS
EVIDENCE until 252 active sessions from 20 collection dates exist. The
agent's candidate pack now carries each name's analyst context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:16:19 -07:00

312 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] [--analyst-refresh]
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)
from research.lab.analyst_estimates import collect, candidate_context
if args.analyst_refresh:
collect(list(cand.index))
generated_at = datetime.now(timezone.utc).isoformat()
analyst = candidate_context(list(cand.index), generated_at)
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, {}),
"analyst_estimates": analyst.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": generated_at,
"analyst_estimates": {"covered": len(analyst),
"eligible": sum(r["eligible"] for r in analyst.values()),
"note": "Known only after snapshot collection; stale fiscal metadata is excluded from the research overlay."},
"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.add_argument("--analyst-refresh", action="store_true", help="archive current Yahoo analyst estimates before writing the pack")
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()