Files
momentum-system/validate_catalysts.py
Claude a7f2e0aa79 Sharpen catalyst labels + add validation tooling
Accuracy: news_enrichment now sends a JSON schema to Ollama's `format`
(enum-locked catalyst_type, typed fields) so decoding is constrained at
generation time, and build_prompt carries 3 few-shot examples (offering,
FDA, earnings) — the biggest accuracy lever for a small local model.
classify_headline gains use_schema (default True; False = plain json).

Validation:
- research/ingestion/catalyst_eval.py — hand-labeled fixture + pure
  score_predictions (type accuracy, is_dilutive precision/recall, sentiment
  sign). catalyst_eval.py CLI A/Bs models (--model) against the same set.
- validate_catalysts.py — joins enriched labels to each stored gapper's
  open->close / open->high outcome; pure outcome_correlation splits by
  dilution flag and bullish-score bucket. Reports coverage honestly (news is
  live-only) and writes a CSV. Run before flipping the Phase 2 flags.

Tests: schema-in-request assertions, pure eval/aggregator coverage. 212 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5HTMkaadGDnAPtXe2hWwg
2026-06-29 21:50:52 +00:00

189 lines
7.3 KiB
Python

#!/usr/bin/env python3
"""Validate catalyst labels against realized gapper outcomes — BEFORE automating.
For every stored gapper session that ALSO has an enriched news catalyst, this
joins the LLM's label (catalyst_type / sentiment / conviction / is_dilutive) to
the day's realized move (open->close % and open->high %) and reports whether the
labels actually separate winners from losers:
* do is_dilutive-flagged names fade (lower open->close) vs the rest?
* do higher bullish catalyst_score names run further?
Run this before flipping NEWS_DILUTION_VETO_ENABLED / NEWS_CATALYST_SCORE_ENABLED.
PYTHONPATH=. python validate_catalysts.py
PYTHONPATH=. python validate_catalysts.py --out /tmp/catalyst_validation.csv
IMPORTANT — coverage constraint: bars are retained indefinitely, but news is only
ingested LIVE (RSS feeds can't be replayed for past dates). So a session only has
a catalyst here if the system was running live that day. The report prints how
many sessions actually had both; if that's ~0, let Phase 1 advisory run live for a
stretch first to accumulate news, then re-run.
"""
from __future__ import annotations
import argparse
import csv
from datetime import datetime, time, timedelta
from research.ingestion.news_enrichment import catalyst_score
from research.multi_schema import open_research_db
from learn_params import load_sessions
def _session_outcome(bars) -> dict:
"""open->close % and open->high % from a session's (RTH) minute bars."""
if bars is None or len(bars) == 0:
return {"open_to_close_pct": None, "open_to_high_pct": None}
o = float(bars["open"].iloc[0])
if o <= 0:
return {"open_to_close_pct": None, "open_to_high_pct": None}
close = float(bars["close"].iloc[-1])
high = float(bars["high"].max())
return {
"open_to_close_pct": round((close - o) / o * 100.0, 3),
"open_to_high_pct": round((high - o) / o * 100.0, 3),
}
def _catalyst_for(con, symbol: str, session_date) -> dict | None:
"""Latest enriched catalyst for ``symbol`` within +-1 day of the session.
The +-1 day window ties an overnight/premarket PR to the morning's gap while
staying tight enough not to borrow a catalyst from a different week."""
lo = datetime.combine(session_date - timedelta(days=1), time(0, 0))
hi = datetime.combine(session_date + timedelta(days=1), time(0, 0))
try:
row = con.execute(
"SELECT catalyst_type, sentiment, conviction, is_dilutive, headline "
"FROM news_catalyst_cache WHERE symbol = ? "
"AND enriched_at >= ? AND enriched_at < ? "
"ORDER BY enriched_at DESC LIMIT 1",
[symbol, lo, hi],
).fetchone()
except Exception: # noqa: BLE001 — harness must never crash on a DB fault
return None
if not row:
return None
return {"catalyst_type": row[0], "sentiment": row[1], "conviction": row[2],
"is_dilutive": bool(row[3]), "headline": row[4]}
def _bucket(score) -> str:
if score is None:
return "none"
if score < 0.33:
return "low"
if score < 0.66:
return "mid"
return "high"
def _mean(xs):
return round(sum(xs) / len(xs), 3) if xs else None
def _group_stats(rows: list[dict]) -> dict:
return {
"n": len(rows),
"mean_open_to_close_pct": _mean(
[r["open_to_close_pct"] for r in rows if r.get("open_to_close_pct") is not None]),
"mean_open_to_high_pct": _mean(
[r["open_to_high_pct"] for r in rows if r.get("open_to_high_pct") is not None]),
}
def outcome_correlation(rows: list[dict]) -> dict:
"""PURE aggregation: split gapper outcomes by dilution flag and score bucket.
``rows`` items need: open_to_close_pct, open_to_high_pct, has_catalyst,
is_dilutive, catalyst_score. No DB/Ollama — unit tested directly."""
with_cat = [r for r in rows if r.get("has_catalyst")]
buckets: dict[str, list] = {}
for r in with_cat:
buckets.setdefault(_bucket(r.get("catalyst_score")), []).append(r)
return {
"total_gappers": len(rows),
"with_catalyst": len(with_cat),
"dilutive": _group_stats([r for r in with_cat if r.get("is_dilutive")]),
"non_dilutive": _group_stats([r for r in with_cat if not r.get("is_dilutive")]),
"by_score_bucket": {k: _group_stats(v) for k, v in sorted(buckets.items())},
}
def _format_report(summary: dict, n_sessions: int) -> str:
def line(label, s):
c, h = s["mean_open_to_close_pct"], s["mean_open_to_high_pct"]
c = f"{c:+6.2f}%" if c is not None else " n/a"
h = f"{h:+6.2f}%" if h is not None else " n/a"
return f" {label:<14} n={s['n']:<4} open->close {c} open->high {h}"
out = [
"Catalyst → outcome validation",
f" sessions with bars: {n_sessions} gappers: {summary['total_gappers']} "
f"with catalyst: {summary['with_catalyst']}",
]
if summary["with_catalyst"] == 0:
out.append("")
out.append(" No gapper sessions had an enriched catalyst yet. News is ingested")
out.append(" LIVE only — let Phase 1 advisory run for a stretch, then re-run this.")
return "\n".join(out)
out.append(" Dilution split:")
out.append(line("dilutive", summary["dilutive"]))
out.append(line("non-dilutive", summary["non_dilutive"]))
out.append(" Bullish catalyst_score bucket:")
for b, s in summary["by_score_bucket"].items():
out.append(line(b, s))
return "\n".join(out)
def gather_rows(con, limit_sessions: int | None = None) -> list[dict]:
sessions = load_sessions(con)
if limit_sessions:
sessions = sessions[-limit_sessions:]
rows = []
for symbol, sess, bars, _pc, _adv in sessions:
outcome = _session_outcome(bars)
cat = _catalyst_for(con, symbol, sess)
rows.append({
"symbol": symbol,
"session_date": str(sess),
**outcome,
"has_catalyst": cat is not None,
"catalyst_type": cat["catalyst_type"] if cat else None,
"is_dilutive": cat["is_dilutive"] if cat else None,
"catalyst_score": catalyst_score(cat) if cat else None,
"headline": cat["headline"] if cat else None,
})
return rows
def main(argv=None) -> int:
p = argparse.ArgumentParser(description="Validate catalyst labels vs gapper outcomes")
p.add_argument("--out", default="catalyst_validation.csv", help="CSV output path")
p.add_argument("--limit-sessions", type=int, default=None,
help="only the most recent N (symbol,date) sessions")
args = p.parse_args(argv)
con = open_research_db("market")
rows = gather_rows(con, limit_sessions=args.limit_sessions)
n_sessions = len({r["session_date"] for r in rows})
summary = outcome_correlation(rows)
print(_format_report(summary, n_sessions))
if rows:
cols = ["symbol", "session_date", "open_to_close_pct", "open_to_high_pct",
"has_catalyst", "catalyst_type", "is_dilutive", "catalyst_score", "headline"]
with open(args.out, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=cols)
w.writeheader()
for r in rows:
w.writerow({k: r.get(k) for k in cols})
print(f"\n wrote {len(rows)} rows -> {args.out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())