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
106 lines
3.1 KiB
Python
106 lines
3.1 KiB
Python
"""Setup quality scoring."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
@dataclass
|
|
class QualityThresholds:
|
|
"""Thresholds used to grade setup quality."""
|
|
|
|
a_grade: float = 0.80
|
|
b_grade: float = 0.65
|
|
c_grade: float = 0.50
|
|
min_tradeable: float = 0.50
|
|
|
|
|
|
@dataclass
|
|
class QualityScore:
|
|
"""Composite quality score for a detected setup."""
|
|
|
|
score: float = 0.0
|
|
grade: str = "F"
|
|
components: dict = field(default_factory=dict)
|
|
tradeable: bool = False
|
|
|
|
|
|
def calculate_setup_quality(
|
|
gap_pct: float,
|
|
relative_volume: float,
|
|
structure_quality: float,
|
|
above_vwap: bool,
|
|
opening_strength: str = "neutral",
|
|
data_quality: float = 1.0,
|
|
thresholds: QualityThresholds | None = None,
|
|
catalyst_score: float | None = None,
|
|
) -> QualityScore:
|
|
"""Blend setup ingredients into one 0..1 quality score.
|
|
|
|
Default weights (no catalyst): gap 20%, RVOL 25%, structure 30%, VWAP 10%,
|
|
opening strength 5%, data quality 10%.
|
|
|
|
When ``catalyst_score`` (a 0..1 'how bullish a catalyst' signal, computed
|
|
OUTSIDE from the LLM advisory) is provided, weights re-balance to make room
|
|
for a 15% catalyst component: gap 15%, RVOL 20%, structure 25%, VWAP 10%,
|
|
opening 5%, data 10%, catalyst 15%. Passing ``None`` reproduces the exact
|
|
pre-catalyst score (so existing behavior/tests are unchanged).
|
|
"""
|
|
thresholds = thresholds or QualityThresholds()
|
|
|
|
gap_component = min(1.0, max(0.0, gap_pct / 0.10))
|
|
rvol_component = min(1.0, max(0.0, relative_volume / 5.0))
|
|
structure_component = min(1.0, max(0.0, structure_quality))
|
|
vwap_component = 1.0 if above_vwap else 0.0
|
|
opening_component = {"strong": 1.0, "neutral": 0.5, "weak": 0.0}.get(
|
|
opening_strength, 0.5
|
|
)
|
|
dq_component = min(1.0, max(0.0, data_quality))
|
|
|
|
components = {
|
|
"gap": gap_component,
|
|
"relative_volume": rvol_component,
|
|
"structure": structure_component,
|
|
"vwap": vwap_component,
|
|
"opening_strength": opening_component,
|
|
"data_quality": dq_component,
|
|
}
|
|
|
|
if catalyst_score is None:
|
|
score = (
|
|
0.20 * gap_component
|
|
+ 0.25 * rvol_component
|
|
+ 0.30 * structure_component
|
|
+ 0.10 * vwap_component
|
|
+ 0.05 * opening_component
|
|
+ 0.10 * dq_component
|
|
)
|
|
else:
|
|
catalyst_component = min(1.0, max(0.0, catalyst_score))
|
|
components["catalyst"] = catalyst_component
|
|
score = (
|
|
0.15 * gap_component
|
|
+ 0.20 * rvol_component
|
|
+ 0.25 * structure_component
|
|
+ 0.10 * vwap_component
|
|
+ 0.05 * opening_component
|
|
+ 0.10 * dq_component
|
|
+ 0.15 * catalyst_component
|
|
)
|
|
|
|
if score >= thresholds.a_grade:
|
|
grade = "A"
|
|
elif score >= thresholds.b_grade:
|
|
grade = "B"
|
|
elif score >= thresholds.c_grade:
|
|
grade = "C"
|
|
else:
|
|
grade = "F"
|
|
|
|
return QualityScore(
|
|
score=round(score, 4),
|
|
grade=grade,
|
|
components=components,
|
|
tradeable=score >= thresholds.min_tradeable,
|
|
)
|