Files
momentum-system/research/ingestion/catalyst_eval.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

166 lines
7.7 KiB
Python

"""Label-quality evaluation for the Ollama catalyst classifier.
Runs a fixed, hand-labeled set of representative small-cap headlines through
``classify_headline`` and scores the model's labels against the ground truth.
This is the objective basis for (a) confirming the few-shot / schema prompt
helps and (b) A/B-ing models (``qwen3:14b`` vs ``qwen2.5:7b-instruct``) on the
SAME inputs.
The scoring (`score_predictions`) is PURE — no Ollama, no DB — so it is unit
tested directly. ``run_eval`` is the thin live wrapper that calls the model.
The single most decision-relevant metric is **is_dilutive precision/recall**:
that flag is what the Phase-2 dilution veto acts on, so a false negative lets a
dilutive trap through and a false positive vetoes a good name.
"""
from __future__ import annotations
from research.ingestion.news_enrichment import CatalystAnalysis, classify_headline
# A representative, hand-labeled fixture spanning the catalyst vocabulary.
# ``sentiment_sign`` is the EXPECTED sign for the stock: +1 bullish, -1 bearish,
# 0 neutral/ambiguous. It is scored with a deadband so near-zero reads pass.
FIXTURE: list[dict] = [
{"headline": "Acme Bio Announces $30 Million Registered Direct Offering Priced At-The-Market",
"catalyst_type": "offering_dilution", "is_dilutive": True, "sentiment_sign": -1},
{"headline": "NanoTech Prices Underwritten Public Offering of 8 Million Shares at $2.10",
"catalyst_type": "offering_dilution", "is_dilutive": True, "sentiment_sign": -1},
{"headline": "QuantumX Files Form S-3 Shelf Registration for Up to $200 Million",
"catalyst_type": "offering_dilution", "is_dilutive": True, "sentiment_sign": -1},
{"headline": "CureGen Receives FDA Approval for Its Lead Oncology Therapy",
"catalyst_type": "fda_approval", "is_dilutive": False, "sentiment_sign": 1},
{"headline": "TrialCo Reports Positive Topline Phase 3 Results Meeting Primary Endpoint",
"catalyst_type": "clinical_trial", "is_dilutive": False, "sentiment_sign": 1},
{"headline": "BioX Drug Fails to Meet Primary Endpoint in Phase 3 Study",
"catalyst_type": "clinical_trial", "is_dilutive": False, "sentiment_sign": -1},
{"headline": "RetailCo Reports Q4 Revenue Up 35%, Beating Analyst Estimates",
"catalyst_type": "earnings", "is_dilutive": False, "sentiment_sign": 1},
{"headline": "WidgetCorp Misses Q2 Earnings as Margins Compress",
"catalyst_type": "earnings", "is_dilutive": False, "sentiment_sign": -1},
{"headline": "TargetCo to Be Acquired by MegaCorp for $12.00 Per Share in Cash",
"catalyst_type": "ma_acquisition", "is_dilutive": False, "sentiment_sign": 1},
{"headline": "TechCo Signs Multi-Year Supply Agreement With a Major Automaker",
"catalyst_type": "partnership_contract", "is_dilutive": False, "sentiment_sign": 1},
{"headline": "SoftCo Raises Full-Year Revenue Guidance Above Consensus",
"catalyst_type": "guidance_update", "is_dilutive": False, "sentiment_sign": 1},
{"headline": "PharmaCo Receives FDA Complete Response Letter for Its Drug Application",
"catalyst_type": "regulatory", "is_dilutive": False, "sentiment_sign": -1},
{"headline": "MicroCap Announces 1-for-20 Reverse Stock Split to Regain Listing Compliance",
"catalyst_type": "stock_split_reverse", "is_dilutive": False, "sentiment_sign": -1},
{"headline": "GadgetCo Upgraded to Buy at a Major Bank, Price Target Raised",
"catalyst_type": "analyst_rating", "is_dilutive": False, "sentiment_sign": 1},
{"headline": "BoardCo Authorizes $100 Million Share Repurchase Program",
"catalyst_type": "insider_buyback", "is_dilutive": False, "sentiment_sign": 1},
{"headline": "CEO of HoldingCo to Present at an Upcoming Investor Conference",
"catalyst_type": "none", "is_dilutive": False, "sentiment_sign": 0},
]
def _sign(value, deadband: float = 0.15) -> int:
"""-1 / 0 / +1 with a neutral deadband so near-zero reads count as neutral."""
if value is None:
return 0
if value > deadband:
return 1
if value < -deadband:
return -1
return 0
def _field(pred, name):
"""Read a field off a CatalystAnalysis or a plain dict (keeps scoring pure)."""
if pred is None:
return None
if isinstance(pred, dict):
return pred.get(name)
return getattr(pred, name, None)
def score_predictions(cases: list[tuple]) -> dict:
"""Score (expected_dict, predicted) pairs. PURE — no Ollama/DB.
``predicted`` may be a ``CatalystAnalysis``, a dict, or ``None`` (model
failed → counted as an error, not scored). Returns accuracy for the
catalyst type and sentiment sign, plus precision/recall/F1 for the
is_dilutive flag (the veto's basis) and the list of type mistakes."""
n = len(cases)
type_hits = sent_hits = errors = scored = 0
tp = fp = fn = tn = 0
confusion: list[dict] = []
for exp, pred in cases:
if pred is None:
errors += 1
continue
scored += 1
p_type = _field(pred, "catalyst_type")
if p_type == exp["catalyst_type"]:
type_hits += 1
else:
confusion.append({
"headline": exp.get("headline", ""),
"expected": exp["catalyst_type"], "predicted": p_type,
})
e_dil, p_dil = bool(exp["is_dilutive"]), bool(_field(pred, "is_dilutive"))
if p_dil and e_dil:
tp += 1
elif p_dil and not e_dil:
fp += 1
elif not p_dil and e_dil:
fn += 1
else:
tn += 1
if "sentiment_sign" in exp and _sign(_field(pred, "sentiment")) == exp["sentiment_sign"]:
sent_hits += 1
def _ratio(a, b):
return round(a / b, 4) if b else None
precision = _ratio(tp, tp + fp)
recall = _ratio(tp, tp + fn)
f1 = (round(2 * precision * recall / (precision + recall), 4)
if precision and recall else None)
return {
"n": n, "scored": scored, "errors": errors,
"type_accuracy": _ratio(type_hits, scored),
"sentiment_sign_accuracy": _ratio(sent_hits, scored),
"dilutive": {"tp": tp, "fp": fp, "fn": fn, "tn": tn,
"precision": precision, "recall": recall, "f1": f1},
"confusion": confusion,
}
def run_eval(cases: list[dict] | None = None, **kwargs) -> tuple[dict, list[tuple]]:
"""Classify each fixture headline live, then score. Returns (metrics, pairs).
``kwargs`` pass straight through to ``classify_headline`` (host, model,
timeout, use_schema, ...). A model failure on a case yields ``None`` for
that prediction (counted as an error)."""
cases = cases if cases is not None else FIXTURE
pairs = [
(c, classify_headline(c["headline"], c.get("snippet", ""),
c.get("tickers", ""), **kwargs))
for c in cases
]
return score_predictions(pairs), pairs
def format_report(metrics: dict, model: str = "") -> str:
"""Human-readable metrics block for the CLI."""
d = metrics["dilutive"]
def pct(x):
return f"{x * 100:5.1f}%" if isinstance(x, (int, float)) else " n/a"
lines = [
f"Catalyst label eval — model={model or '?'}",
f" cases: {metrics['n']} scored: {metrics['scored']} errors: {metrics['errors']}",
f" catalyst_type accuracy : {pct(metrics['type_accuracy'])}",
f" sentiment-sign accuracy: {pct(metrics['sentiment_sign_accuracy'])}",
f" is_dilutive P={pct(d['precision'])} R={pct(d['recall'])} F1={pct(d['f1'])}"
f" (tp={d['tp']} fp={d['fp']} fn={d['fn']} tn={d['tn']})",
]
if metrics["confusion"]:
lines.append(" type misses:")
for m in metrics["confusion"]:
lines.append(f" [{m['expected']} -> {m['predicted']}] {m['headline'][:70]}")
return "\n".join(lines)