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
This commit is contained in:
Claude committed 2026-06-19 17:29:24 +00:00
1 parent 506ff253ec
commit e3cb2b8e5f
17 files changed
+1085 -21

No files matched your search

@@ -0,0 +1,83 @@
"""End-to-end (Ollama mocked): raw headline -> enrich -> dashboard advisory.
Exercises the full Phase 1 path on a single Postgres connection: seed
raw_news_items, run the enrichment pass with the LLM call patched, then read it
back through the dashboard projection exactly as api/main.py snapshots() does.
"""
from types import SimpleNamespace
import pytest
from research.ingestion import news_enrichment as ne
from storage.db import get_connection
from storage.projections import query_catalyst_advisory
class _Cfg:
host = "http://localhost:11434"
model = "test-model"
timeout_seconds = 5
temperature = 0.3
max_tokens = 128
enrichment_lookback_hours = 12
enrichment_batch_limit = 50
@pytest.fixture
def con():
c = get_connection(":memory:")
yield c
c.close()
def test_headline_flows_to_dashboard_advisory(con, monkeypatch):
# 1. a fresh dilutive-offering headline lands in the raw table (as RSS would)
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, ?)",
["h1", "fa1", "prnewswire", "http://x/1",
"SHEL Inc. announces $30M registered direct offering",
"", "pricing of offering", "SHEL", "h1", "rss-v2", "run1"],
)
# 2. Ollama replies with a structured classification (patched — no network).
# urlopen is used as a context manager in classify_headline.
import json
class _CtxResp:
def __init__(self, body):
self._b = body
def read(self):
return self._b
def __enter__(self):
return self
def __exit__(self, *a):
return False
monkeypatch.setattr(
ne.urllib.request, "urlopen",
lambda *a, **k: _CtxResp(json.dumps({"response": json.dumps({
"catalyst_type": "offering_dilution", "sentiment": -0.8,
"conviction": 0.9, "is_dilutive": True,
"rationale": "registered direct offering dilutes holders",
})}).encode()),
)
# 3. the scheduled enrichment pass runs
res = ne.enrich_recent_news(con, _Cfg())
assert res["enriched"] == 1
# 4. the dashboard reads it via the projection (store.con == this connection)
store = SimpleNamespace(con=con)
advisory = query_catalyst_advisory(store)
assert "SHEL" in advisory
assert advisory["SHEL"]["is_dilutive"] is True
assert advisory["SHEL"]["catalyst_type"] == "offering_dilution"
# 5. the previously-dead news_events.sentiment column is now populated
sent = con.execute(
"SELECT sentiment FROM news_events WHERE symbol = ?", ["SHEL"]
).fetchone()
assert sent is not None and sent[0] == -0.8
+205
View File
@@ -0,0 +1,205 @@
"""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"
@@ -0,0 +1,44 @@
"""Tests for the catalyst-advisory dashboard projection."""
import pytest
from storage.event_store import EventStore
from storage.projections import query_catalyst_advisory
@pytest.fixture
def store():
s = EventStore(":memory:")
yield s
s.close()
def _seed(store, headline_hash, symbol, ctype, sentiment, conviction, dilutive):
store.con.execute(
"INSERT INTO news_catalyst_cache (headline_hash, symbol, headline, source, "
"catalyst_type, sentiment, conviction, is_dilutive, rationale, model) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[headline_hash, symbol, f"{symbol} news", "rss", ctype, sentiment,
conviction, dilutive, "why", "test-model"],
)
def test_advisory_empty_when_no_rows(store):
assert query_catalyst_advisory(store) == {}
def test_advisory_returns_per_symbol(store):
_seed(store, "h1", "BIO", "fda_approval", 0.9, 0.85, False)
_seed(store, "h2", "SHEL", "offering_dilution", -0.7, 0.8, True)
adv = query_catalyst_advisory(store)
assert adv["BIO"]["catalyst_type"] == "fda_approval"
assert adv["BIO"]["is_dilutive"] is False
assert adv["BIO"]["sentiment"] == 0.9
assert adv["SHEL"]["is_dilutive"] is True
def test_advisory_skips_empty_symbol_marker(store):
# enrichment writes a sentinel row with symbol='' for ticker-less headlines
_seed(store, "h3", "", "other", 0.0, 0.2, False)
assert query_catalyst_advisory(store) == {}
@@ -0,0 +1,62 @@
"""Phase 2: optional catalyst_score blend stays pure & backward-compatible."""
import pytest
from strategy.evaluation.quality import calculate_setup_quality
_KW = dict(
gap_pct=0.08,
relative_volume=3.0,
structure_quality=0.7,
above_vwap=True,
opening_strength="strong",
data_quality=1.0,
)
def test_none_catalyst_reproduces_legacy_score():
# The exact pre-catalyst weighting (gap .20 / rvol .25 / structure .30 /
# vwap .10 / opening .05 / data .10) must be unchanged when score is None.
q = calculate_setup_quality(**_KW, catalyst_score=None)
expected = (
0.20 * 0.8 + 0.25 * 0.6 + 0.30 * 0.7 + 0.10 * 1.0
+ 0.05 * 1.0 + 0.10 * 1.0
)
assert q.score == pytest.approx(round(expected, 4))
assert "catalyst" not in q.components
def test_default_arg_matches_explicit_none():
assert calculate_setup_quality(**_KW).score == calculate_setup_quality(
**_KW, catalyst_score=None
).score
def test_strong_catalyst_raises_score_and_adds_component():
base = calculate_setup_quality(**_KW, catalyst_score=None).score
high = calculate_setup_quality(**_KW, catalyst_score=1.0)
assert high.score > base
assert high.components["catalyst"] == 1.0
def test_zero_catalyst_lowers_score_vs_legacy():
# re-balancing toward a 0 catalyst pulls a strong setup down a touch
base = calculate_setup_quality(**_KW, catalyst_score=None).score
weak = calculate_setup_quality(**_KW, catalyst_score=0.0).score
assert weak < base
def test_catalyst_weights_sum_to_one():
# an all-1.0 setup with a 1.0 catalyst must score exactly 1.0 (weights sum=1)
q = calculate_setup_quality(
gap_pct=1.0, relative_volume=10.0, structure_quality=1.0,
above_vwap=True, opening_strength="strong", data_quality=1.0,
catalyst_score=1.0,
)
assert q.score == pytest.approx(1.0)
def test_catalyst_score_clamped():
q = calculate_setup_quality(**_KW, catalyst_score=5.0)
assert q.components["catalyst"] == 1.0
+12
View File
@@ -63,3 +63,15 @@ def test_criteria_results_cover_all_weights():
}
evaluated = set(result.criteria_names_passed) | set(result.criteria_names_failed)
assert expected <= evaluated
def test_catalyst_score_is_optional_passthrough():
"""The Phase 2 catalyst_score is a pure pass-through: None == legacy, and a
strong catalyst lifts the setup's quality score without changing the gate."""
kw = dict(previous_close=10.0, avg_daily_volume=500_000, evaluation_time=EVAL_TIME)
legacy = evaluate_setup(bull_flag_bars(), **kw)
explicit_none = evaluate_setup(bull_flag_bars(), catalyst_score=None, **kw)
boosted = evaluate_setup(bull_flag_bars(), catalyst_score=1.0, **kw)
assert legacy.setups[0]["quality_score"] == explicit_none.setups[0]["quality_score"]
assert boosted.setups[0]["quality_score"] > legacy.setups[0]["quality_score"]
+67
View File
@@ -472,3 +472,70 @@ def test_repeated_backouts_bench_symbol_for_session(store):
service._register_backout("THRASH")
clock.advance(10_000) # hours later, still benched
assert service._in_cooldown("THRASH")
# --- catalyst dilution veto (Phase 2; gated OFF by default) -----------------
def _service_with_catalyst(store, advisory, *, veto_enabled, executor=None):
settings = ExecutionSettings(
enabled=True, auto_approve=False, max_orders_per_tick=2,
max_concurrent_positions=3, risk_per_trade_pct=0.01,
default_equity=100_000.0,
catalyst_veto_enabled=veto_enabled,
catalyst_veto_min_conviction=0.6,
)
return TradingExecutionService(
store,
executor=executor or FakeExecutor(),
settings=settings,
trading_mode=TradingModeSettings(execution_mode="alpaca_paper"),
session_id="t",
catalyst_provider=lambda sym: advisory,
)
def test_dilution_veto_blocks_entry_and_emits_event(store):
emit_signal(store, symbol="DILUT")
advisory = {"is_dilutive": True, "conviction": 0.9,
"catalyst_type": "offering_dilution", "sentiment": -0.8,
"rationale": "registered direct offering"}
service = _service_with_catalyst(store, advisory, veto_enabled=True)
requested = service.request_approvals_for_ready_signals()
assert requested == [] # no approval created
assert query_approval_queue(store) == []
risk_events = store.query_events(event_type="risk_rule_triggered")
assert any(
__import__("json").loads(e["payload_json"]).get("rule_type")
== "catalyst_dilution_veto"
for e in risk_events
)
def test_dilution_veto_disabled_allows_entry(store):
emit_signal(store, symbol="DILUT")
advisory = {"is_dilutive": True, "conviction": 0.9}
service = _service_with_catalyst(store, advisory, veto_enabled=False)
assert len(service.request_approvals_for_ready_signals()) == 1
def test_dilution_veto_below_conviction_allows_entry(store):
emit_signal(store, symbol="MAYBE")
advisory = {"is_dilutive": True, "conviction": 0.4} # under the 0.6 floor
service = _service_with_catalyst(store, advisory, veto_enabled=True)
assert len(service.request_approvals_for_ready_signals()) == 1
def test_non_dilutive_catalyst_allows_entry(store):
emit_signal(store, symbol="FDA")
advisory = {"is_dilutive": False, "conviction": 0.95,
"catalyst_type": "fda_approval"}
service = _service_with_catalyst(store, advisory, veto_enabled=True)
assert len(service.request_approvals_for_ready_signals()) == 1
def test_breakout_now_respects_dilution_veto(store):
advisory = {"is_dilutive": True, "conviction": 0.9}
service = _service_with_catalyst(store, advisory, veto_enabled=True)
res = service.submit_breakout_now("DILUT", trigger=14.0, stop=13.45, last_price=14.0)
assert res["ok"] is False and res["skipped"] == "dilution_veto"