Files
momentum-system/tests/unit/research/test_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

206 lines
7.5 KiB
Python

"""Local-LLM (Ollama) news/catalyst enrichment tests.
The LLM call is mocked at ``urllib.request.urlopen`` (no network), matching the
codebase's graceful-degradation contract: any failure -> ``None``, never raises.
The persistence tests run against an in-memory Postgres schema.
"""
import io
import json
import pytest
from research.ingestion import news_enrichment as ne
from storage.db import get_connection
# --------------------------------------------------------------------------
# classify_headline / parsing
# --------------------------------------------------------------------------
class _FakeResp:
def __init__(self, body: bytes):
self._body = body
def read(self):
return self._body
def __enter__(self):
return self
def __exit__(self, *a):
return False
def _ollama_reply(payload: dict) -> _FakeResp:
"""Wrap a model JSON object the way Ollama's /api/generate returns it."""
return _FakeResp(json.dumps({"response": json.dumps(payload)}).encode())
def test_classify_headline_happy_path(monkeypatch):
monkeypatch.setattr(
ne.urllib.request, "urlopen",
lambda *a, **k: _ollama_reply({
"catalyst_type": "fda_approval", "sentiment": 0.9,
"conviction": 0.85, "is_dilutive": False, "rationale": "FDA nod",
}),
)
a = ne.classify_headline("BioCo gets FDA approval", tickers="BIO")
assert a is not None
assert a.catalyst_type == "fda_approval"
assert a.sentiment == 0.9 and a.conviction == 0.85
assert a.is_dilutive is False and a.rationale == "FDA nod"
def test_classify_headline_network_down_returns_none(monkeypatch):
def boom(*a, **k):
raise OSError("connection refused")
monkeypatch.setattr(ne.urllib.request, "urlopen", boom)
assert ne.classify_headline("anything") is None
def test_classify_headline_malformed_json_returns_none(monkeypatch):
monkeypatch.setattr(
ne.urllib.request, "urlopen",
lambda *a, **k: _FakeResp(json.dumps({"response": "not json at all"}).encode()),
)
assert ne.classify_headline("headline") is None
def test_classify_headline_empty_input_returns_none():
assert ne.classify_headline("", "") is None
def test_parse_clamps_and_validates():
a = ne._parse('{"catalyst_type":"bogus","sentiment":5,"conviction":-1}')
assert a.catalyst_type == "other" # unknown enum -> other
assert a.sentiment == 1.0 # clamped to [-1, 1]
assert a.conviction == 0.0 # clamped to [0, 1]
def test_parse_offering_type_implies_dilutive():
a = ne._parse('{"catalyst_type":"offering_dilution","sentiment":-0.5,"conviction":0.7,"is_dilutive":false}')
assert a.is_dilutive is True # type forces the flag
def test_parse_keyword_floor_catches_missed_dilution():
# model said not dilutive, but the headline text obviously is
a = ne._parse(
'{"catalyst_type":"other","sentiment":0.0,"conviction":0.3,"is_dilutive":false}',
fallback_text="Company announces $40M registered direct offering",
)
assert a.is_dilutive is True
@pytest.mark.parametrize("text,expected", [
("Priced public offering of 5M shares", True),
("Announces at-the-market facility", True),
("Files Form S-3 shelf registration", True),
("Reports record Q3 earnings beat", False),
("Signs partnership with Pfizer", False),
])
def test_looks_dilutive(text, expected):
assert ne.looks_dilutive(text) is expected
def test_catalyst_score():
assert ne.catalyst_score(None) is None
assert ne.catalyst_score({"conviction": 0.0, "sentiment": 1.0}) == 0.0
# bearish catalyst -> low score (sentiment floored at 0 in the multiplier)
assert ne.catalyst_score({"conviction": 0.9, "sentiment": -0.8}) == pytest.approx(0.45)
assert ne.catalyst_score({"conviction": 0.9, "sentiment": 0.8}) == pytest.approx(0.81)
# --------------------------------------------------------------------------
# Persistence: enrich_recent_news + catalyst_map (in-memory Postgres)
# --------------------------------------------------------------------------
@pytest.fixture
def con():
c = get_connection(":memory:")
yield c
c.close()
class _Cfg:
"""Minimal stand-in for OllamaConfig (avoids pydantic in the persist tests)."""
host = "http://localhost:11434"
model = "test-model"
timeout_seconds = 5
temperature = 0.3
max_tokens = 128
enrichment_lookback_hours = 12
enrichment_batch_limit = 50
def _seed_news(con, payload_hash, title, tickers, source="prnewswire"):
con.execute(
"INSERT INTO raw_news_items (id, fetch_attempt_id, source, raw_url, raw_title, "
"raw_published_at, raw_body_snippet, raw_tickers, payload_hash, parser_version, "
"fetched_at, ingest_run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, "
"current_timestamp, ?)",
[payload_hash, "fa1", source, "http://x/1", title, "", "snippet",
tickers, payload_hash, "rss-v2", "run1"],
)
def test_enrich_recent_news_persists_and_dedupes(con, monkeypatch):
_seed_news(con, "hash_abc", "BioCo gets FDA approval", "BIO")
fixed = ne.CatalystAnalysis(
catalyst_type="fda_approval", sentiment=0.8, conviction=0.9,
is_dilutive=False, rationale="approval",
)
monkeypatch.setattr(ne, "classify_headline", lambda *a, **k: fixed)
res = ne.enrich_recent_news(con, _Cfg())
assert res["enriched"] == 1 and res["errors"] == 0
# cache row written
cache = con.execute(
"SELECT symbol, catalyst_type, sentiment, conviction FROM news_catalyst_cache"
).fetchall()
assert cache == [("BIO", "fda_approval", 0.8, 0.9)]
# the previously-dead news_events.sentiment is now populated
ev = con.execute(
"SELECT symbol, sentiment, category, is_offering FROM news_events"
).fetchall()
assert ev == [("BIO", 0.8, "fda_approval", False)]
# second pass enriches nothing (deduped by headline hash)
res2 = ne.enrich_recent_news(con, _Cfg())
assert res2["enriched"] == 0
def test_enrich_skips_when_llm_unavailable(con, monkeypatch):
_seed_news(con, "hash_down", "Some headline", "XYZ")
monkeypatch.setattr(ne, "classify_headline", lambda *a, **k: None)
res = ne.enrich_recent_news(con, _Cfg())
assert res["enriched"] == 0 and res["errors"] == 1
# cache NOT poisoned — headline stays available for a retry next pass
assert con.execute("SELECT count(*) FROM news_catalyst_cache").fetchone()[0] == 0
def test_enrich_fans_out_multiple_tickers(con, monkeypatch):
_seed_news(con, "hash_multi", "MergerCo to acquire TargetCo", "ACQ,TGT")
fixed = ne.CatalystAnalysis(catalyst_type="ma_acquisition", sentiment=0.6,
conviction=0.8, is_dilutive=False, rationale="M&A")
monkeypatch.setattr(ne, "classify_headline", lambda *a, **k: fixed)
ne.enrich_recent_news(con, _Cfg())
syms = sorted(r[0] for r in con.execute(
"SELECT symbol FROM news_catalyst_cache").fetchall())
assert syms == ["ACQ", "TGT"]
def test_catalyst_map_latest_per_symbol(con, monkeypatch):
_seed_news(con, "hash_dil", "BioCo announces registered direct offering", "BIO")
fixed = ne.CatalystAnalysis(catalyst_type="offering_dilution", sentiment=-0.7,
conviction=0.85, is_dilutive=True, rationale="dilution")
monkeypatch.setattr(ne, "classify_headline", lambda *a, **k: fixed)
ne.enrich_recent_news(con, _Cfg())
m = ne.catalyst_map(con)
assert "BIO" in m
assert m["BIO"]["is_dilutive"] is True
assert m["BIO"]["catalyst_type"] == "offering_dilution"