Files
momentum-system/tests/unit/strategy/test_quality_catalyst.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

63 lines
2.0 KiB
Python

"""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