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
84 lines
2.8 KiB
Python
84 lines
2.8 KiB
Python
"""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
|