Files
momentum-system/research/ingestion/news_enrichment.py
T
Claude e3cb2b8e5f Add local-LLM (Ollama) news/catalyst scoring
Leverage a local Ollama model to interpret news catalysts for the small-cap
momentum strategy — distinguishing bullish catalysts (FDA approval, M&A) from
dilutive offerings the technicals alone can't see. Advisory-first, then automate.

Phase 1 (advisory; no change to trade behavior):
- research/ingestion/news_enrichment.py: stdlib urllib call to Ollama with
  structured JSON output, classifying each headline into a catalyst type,
  sentiment, conviction and dilution flag. Graceful degradation (None on any
  failure) so trading is never blocked.
- news_catalyst_cache table + populate the previously-dead news_events.sentiment.
- Runs on the existing interval Scheduler (off the hot path), gated by
  OllamaConfig; deduped by the raw_news_items payload hash.
- Surface the advisory on the dashboard approval queue (projection + api + UI).

Phase 2 (automation; ships gated OFF):
- Optional catalyst_score blended into calculate_setup_quality (None reproduces
  the exact legacy weights); pure pass-through via evaluate_setup.
- Dilution veto in TradingExecutionService (slow + fast paths) emitting a
  catalyst_dilution_veto risk event, behind NEWS_DILUTION_VETO_ENABLED.

Config (OllamaConfig.from_env), .env.example docs, and tests for parsing,
persistence/dedupe, the projection, the score blend, the veto, and an
end-to-end enrich->advisory flow (Ollama mocked). Default model moves to
qwen2.5:7b-instruct (best small-model JSON, fits a 12GB GPU).

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

319 lines
13 KiB
Python

"""Local-LLM (Ollama) news/catalyst enrichment — advisory layer.
Stdlib-only. Mirrors the proven ``strategy/evaluation/llm_integration.py`` urllib
call but asks Ollama for STRUCTURED JSON (``"format": "json"``) so each headline
yields a parseable catalyst classification.
Design rules (see plan):
* Runs OFF the hot trading path (called from the interval Scheduler).
* Every public function degrades gracefully — returns ``None`` / skips on any
failure (network down, bad JSON, missing keys). Trading is never blocked.
* Single Postgres datastore: SQL uses DuckDB-style ``?`` placeholders, which
``storage/db_pg`` translates to psycopg2 ``%s`` (exactly like rss.py).
"""
from __future__ import annotations
import json
import logging
import re
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
# Closed vocabulary the model must choose from. Keep in sync with build_prompt.
CATALYST_TYPES = {
"offering_dilution",
"earnings",
"fda_approval",
"clinical_trial",
"ma_acquisition",
"partnership_contract",
"guidance_update",
"regulatory",
"halt_resumption",
"stock_split_reverse",
"insider_buyback",
"analyst_rating",
"other",
"none",
}
# Cheap stdlib floor for the dilution veto so it still works when Ollama is down.
# The LLM CONFIRMS dilution; this regex is the safety net, not the primary signal.
_DILUTIVE_RE = re.compile(
r"\b(offering|registered\s+direct|at[-\s]the[-\s]market|atm\s+(?:facility|offering)|"
r"warrant|dilut\w*|shelf\s+(?:registration|offering)|priced\s+(?:public\s+)?offering|"
r"pricing\s+of\s+.{0,30}offering)\b|\bform\s+s-?3\b",
re.IGNORECASE,
)
def looks_dilutive(text: str) -> bool:
"""Keyword floor for the dilution signal (used when the LLM is unavailable)."""
return bool(_DILUTIVE_RE.search(text or ""))
@dataclass
class CatalystAnalysis:
"""A model's read of one headline. All fields validated/clamped on parse."""
catalyst_type: str = "other"
sentiment: float = 0.0 # [-1, 1] bearish..bullish for the stock
conviction: float = 0.0 # [0, 1] model confidence it's a real, tradeable catalyst
is_dilutive: bool = False
rationale: str = ""
def as_news_event_fields(self) -> dict:
"""Map to the (previously dead) news_events columns."""
return {
"sentiment": self.sentiment,
"category": self.catalyst_type,
"is_offering": self.is_dilutive,
"is_earnings": self.catalyst_type == "earnings",
"is_halt_related": self.catalyst_type == "halt_resumption",
}
def build_prompt(headline: str, snippet: str = "", tickers: str = "") -> str:
"""Instruction prompt pinning the enum and demanding a strict JSON object."""
types = ", ".join(sorted(CATALYST_TYPES))
ctx = f"Headline: {headline}"
if snippet:
ctx += f"\nDetail: {snippet}"
if tickers:
ctx += f"\nTickers: {tickers}"
return (
"You are a small-cap trading analyst. Classify the market catalyst in this "
"news for a momentum day-trader. Respond with a STRICT JSON object only, no "
"prose, with exactly these keys:\n"
' "catalyst_type": one of [' + types + "]\n"
' "sentiment": number from -1.0 (very bearish) to 1.0 (very bullish) for the stock\n'
' "conviction": number from 0.0 to 1.0 — how confident this is a real, tradeable catalyst\n'
' "is_dilutive": true ONLY for confirmed share dilution (offering, ATM, '
"registered direct, warrant, shelf takedown), else false\n"
' "rationale": one short sentence (max ~200 chars)\n\n'
+ ctx
)
def _clamp(value, lo: float, hi: float, default: float) -> float:
try:
return max(lo, min(hi, float(value)))
except (TypeError, ValueError):
return default
def _parse(raw: str, fallback_text: str = "") -> CatalystAnalysis | None:
"""Tolerant parse of the model's JSON string. None on unrecoverable failure."""
try:
data = json.loads(raw)
except (TypeError, ValueError):
return None
if not isinstance(data, dict):
return None
ctype = str(data.get("catalyst_type", "other")).strip().lower()
if ctype not in CATALYST_TYPES:
ctype = "other"
is_dilutive = bool(data.get("is_dilutive", False)) or (
ctype == "offering_dilution"
)
# belt-and-suspenders: trust the keyword floor if the model missed obvious dilution
if not is_dilutive and looks_dilutive(fallback_text):
is_dilutive = True
rationale = str(data.get("rationale", "") or "")[:200]
return CatalystAnalysis(
catalyst_type=ctype,
sentiment=_clamp(data.get("sentiment", 0.0), -1.0, 1.0, 0.0),
conviction=_clamp(data.get("conviction", 0.0), 0.0, 1.0, 0.0),
is_dilutive=is_dilutive,
rationale=rationale,
)
def classify_headline(
headline: str,
snippet: str = "",
tickers: str = "",
*,
host: str = "http://localhost:11434",
model: str = "qwen2.5:7b-instruct",
timeout: int = 30,
temperature: float = 0.3,
max_tokens: int = 256,
) -> CatalystAnalysis | None:
"""Ask a local Ollama model to classify a headline. None on any failure."""
if not (headline or snippet):
return None
prompt = build_prompt(headline, snippet, tickers)
try:
req = urllib.request.Request(
f"{host}/api/generate",
data=json.dumps(
{
"model": model,
"prompt": prompt,
"stream": False,
"format": "json",
"options": {
"temperature": temperature,
"num_predict": max_tokens,
},
}
).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = json.loads(resp.read().decode())
response = body.get("response")
except Exception: # noqa: BLE001
logger.debug("catalyst enrichment unavailable", exc_info=True)
return None
if not response:
return None
return _parse(response, fallback_text=f"{headline} {snippet}")
# ---------------------------------------------------------------------------
# Persistence (single Postgres datastore; ``?`` placeholders, like rss.py)
# ---------------------------------------------------------------------------
def catalyst_score(advisory: dict | None) -> float | None:
"""Map a catalyst advisory dict -> a 0..1 'how bullish a catalyst' score.
None when there is no advisory. A bearish / no-catalyst read scores low; a
high-conviction bullish catalyst scores high. Used by Phase 2 to blend into
the setup quality score (computed OUTSIDE the pure evaluator)."""
if not advisory:
return None
conviction = _clamp(advisory.get("conviction", 0.0), 0.0, 1.0, 0.0)
sentiment = _clamp(advisory.get("sentiment", 0.0), -1.0, 1.0, 0.0)
return round(conviction * (0.5 + 0.5 * max(0.0, sentiment)), 4)
def _store_cache_row(con, headline_hash, symbol, headline, source, analysis, model):
exists = con.execute(
"SELECT 1 FROM news_catalyst_cache WHERE headline_hash = ? AND symbol = ? LIMIT 1",
[headline_hash, symbol],
).fetchone()
if exists:
return
con.execute(
"INSERT INTO news_catalyst_cache ("
"headline_hash, symbol, headline, source, catalyst_type, sentiment, "
"conviction, is_dilutive, rationale, model"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[
headline_hash, symbol, headline, source, analysis.catalyst_type,
analysis.sentiment, analysis.conviction, analysis.is_dilutive,
analysis.rationale, model,
],
)
def _upsert_news_event(con, headline_hash, symbol, headline, source, published_at, analysis):
"""Populate the (previously dead) news_events row for this headline+ticker."""
nid = f"{headline_hash}:{symbol}"
f = analysis.as_news_event_fields()
exists = con.execute(
"SELECT 1 FROM news_events WHERE id = ? LIMIT 1", [nid]
).fetchone()
if exists:
con.execute(
"UPDATE news_events SET sentiment = ?, category = ?, is_offering = ?, "
"is_earnings = ?, is_halt_related = ? WHERE id = ?",
[f["sentiment"], f["category"], f["is_offering"], f["is_earnings"],
f["is_halt_related"], nid],
)
return
con.execute(
"INSERT INTO news_events ("
"id, symbol, headline, source, published_at, sentiment, category, "
"is_earnings, is_offering, is_halt_related"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[nid, symbol, headline, source, published_at, f["sentiment"],
f["category"], f["is_earnings"], f["is_offering"], f["is_halt_related"]],
)
def enrich_recent_news(con, cfg, lookback_hours: int | None = None, limit: int | None = None) -> dict:
"""Classify recently-ingested, not-yet-enriched headlines via Ollama.
Left-anti-joins ``raw_news_items`` against ``news_catalyst_cache`` on the
headline hash, classifies each (one LLM call per headline, fanned out per
ticker), and writes the cache + populates ``news_events``. Bounded per pass
by ``limit`` so a backlog cannot run the GPU forever. NEVER raises."""
lookback_hours = lookback_hours if lookback_hours is not None else getattr(
cfg, "enrichment_lookback_hours", 12)
limit = limit if limit is not None else getattr(cfg, "enrichment_batch_limit", 50)
counts = {"enriched": 0, "skipped": 0, "errors": 0}
cutoff = datetime.utcnow() - timedelta(hours=lookback_hours)
try:
rows = con.execute(
"SELECT payload_hash, raw_title, raw_body_snippet, raw_tickers, source, "
"fetched_at FROM raw_news_items "
"WHERE payload_hash IS NOT NULL AND fetched_at >= ? "
"AND payload_hash NOT IN (SELECT DISTINCT headline_hash FROM news_catalyst_cache) "
"ORDER BY fetched_at DESC LIMIT ?",
[cutoff, limit],
).fetchall()
except Exception as exc: # noqa: BLE001
logger.warning("enrich_recent_news query failed (DB fault?): %s", exc)
return counts
for payload_hash, title, snippet, raw_tickers, source, fetched_at in rows:
analysis = classify_headline(
title or "", snippet or "", raw_tickers or "",
host=cfg.host, model=cfg.model, timeout=cfg.timeout_seconds,
temperature=cfg.temperature, max_tokens=cfg.max_tokens,
)
if analysis is None:
# Ollama down / unparseable — DON'T poison the cache; retry next pass.
counts["errors"] += 1
continue
tickers = [t.strip().upper() for t in (raw_tickers or "").split(",") if t.strip()]
if not tickers:
tickers = [""] # sentinel: marks the headline enriched so we don't re-call
for sym in tickers:
try:
_store_cache_row(con, payload_hash, sym, title, source, analysis, cfg.model)
if sym:
_upsert_news_event(con, payload_hash, sym, title, source, fetched_at, analysis)
except Exception as exc: # noqa: BLE001
counts["errors"] += 1
logger.debug("catalyst store failed: %s", exc, exc_info=True)
counts["enriched"] += 1
return counts
def catalyst_map(con, lookback_hours: int = 8) -> dict[str, dict]:
"""{ticker: advisory} for tickers enriched within the window (latest wins).
Mirrors ``research.ingestion.discovery.recent_news_map`` — read at arm /
approval time and (Phase 2) injected into the watcher. Never raises."""
cutoff = datetime.utcnow() - timedelta(hours=lookback_hours)
try:
rows = con.execute(
"SELECT symbol, catalyst_type, sentiment, conviction, is_dilutive, "
"rationale, headline FROM news_catalyst_cache "
"WHERE symbol <> '' AND enriched_at >= ? ORDER BY enriched_at DESC",
[cutoff],
).fetchall()
except Exception as exc: # noqa: BLE001
logger.warning("catalyst_map query failed (DB fault?): %s", exc)
return {}
out: dict[str, dict] = {}
for sym, ctype, sentiment, conviction, is_dilutive, rationale, headline in rows:
key = (sym or "").upper()
if key and key not in out: # newest first -> first seen is the latest
out[key] = {
"catalyst_type": ctype,
"sentiment": sentiment,
"conviction": conviction,
"is_dilutive": bool(is_dilutive),
"rationale": rationale,
"headline": headline,
}
return out