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
189 lines
7.3 KiB
Python
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())
|