From 3e523e132adaf7ab877ad060c2cacaaaca07686b Mon Sep 17 00:00:00 2001 From: momentum dev Date: Thu, 1 Oct 2026 21:53:42 -0500 Subject: [PATCH] fix: reactive coverage (halt notices, "why is X up") no longer scores as a catalyst qwen3:14b rated "halted on circuit breaker to the upside, stock now up 172%" at conviction 0.8 / sentiment +1.0, a bigger rank boost than most real news. So names that had already run were ranked up. - Prompt: halt notices, movers roundups and articles that only report a price move are not a new catalyst (conviction <= 0.1). If one names a new announcement, classify it with conviction <= 0.3. - A deterministic cap that does not depend on the model: halt type or halt wording caps conviction at 0.1; after-the-fact wording ("why is X stock up", "X shares surge", "stocks moving") caps it at 0.3. Dilution flags are never touched. On the 4,046-headline news history the pattern catches 100% of halt notices and 82% of roundups, 0% of analyst items and 0.8% of earnings items. Against qwen3:14b on a fixed 20-headline set: reactive mean boost 0.318 -> 0.055 (max 0.80 -> 0.21); real catalysts 0.353 -> 0.362; offerings 3/3 still flagged dilutive. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86 --- research/ingestion/news_enrichment.py | 33 +++++++++++++++++--- tests/unit/research/test_news_enrichment.py | 34 +++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/research/ingestion/news_enrichment.py b/research/ingestion/news_enrichment.py index 17c4be2..9168dc5 100644 --- a/research/ingestion/news_enrichment.py +++ b/research/ingestion/news_enrichment.py @@ -41,6 +41,20 @@ CATALYST_TYPES = { "none", } +# Coverage that only REPORTS a move or a halt is not a new catalyst, but the model rated "halted to the upside, +# stock up 172%" at conviction 0.8 / sentiment +1.0 (a bigger boost than most real news), which ranks up names +# that already ran. The prompt says so; this deterministic cap does not depend on the model listening. +HALT_RE = re.compile(r"\b(halted|trading halt|halt status|circuit breaker|luld|resum(?:e|es|ed|ption) (?:of )?trad)", + re.I) +REACTIVE_RE = re.compile( + r"\b(why|what'?s going on|what'?s driving|here'?s why)\b.{0,80}\b(stock|shares)\b" + r"|\b(stock|shares)\b.{0,40}\b(surg|soar|jump|spik|rall|climb|gain|ris|trend|tumbl|plung|slid|sink|drop|fall)\w*" + r"|\b(stock|shares)\b.{0,40}\btrading (?:higher|lower)" + r"|stocks? moving|\bmovers\b|(?:top|biggest) (?:gainers|losers)|unusual (?:options|volume)|52-week (?:high|low)", + re.I) +HALT_MAX_CONVICTION = 0.1 +REACTIVE_MAX_CONVICTION = 0.3 + # Cheap stdlib floor for the dilution veto so it still works when Ollama is down. # The LLM CONFIRMS dilution; this regex is the safety net, not the primary signal. # "offering" is matched ONLY as a securities offering — a qualifier in front of it @@ -129,7 +143,12 @@ def build_prompt(headline: str, snippet: str = "", tickers: str = "") -> str: 'everything else, including an ETF/fund "offering exposure", a product or ' "service offering, an IPO of a different company, a buyback, or a reverse " "split\n" - ' "rationale": one short sentence (max ~200 chars)\n\n' + ' "rationale": one short sentence (max ~200 chars)\n' + "A trading halt/resumption notice, a 'stocks moving'/movers roundup, or an article that only reports or " + "explains a price move ('Why is X stock up', 'X shares surge') is NOT a new catalyst: use catalyst_type " + '"none" (or "halt_resumption" for a halt) with conviction at most 0.1. If such an article names a NEW ' + "company announcement as the cause, classify that announcement but with conviction at most 0.3, because " + "the move has already happened.\n\n" f"\n{news}\n" ) @@ -141,7 +160,7 @@ def _clamp(value, lo: float, hi: float, default: float) -> float: return default -def _parse(raw: str, fallback_text: str = "") -> CatalystAnalysis | None: +def _parse(raw: str, fallback_text: str = "", headline: str = "") -> CatalystAnalysis | None: """Tolerant parse of the model's JSON string. None on unrecoverable failure.""" try: data = json.loads(raw) @@ -162,10 +181,16 @@ def _parse(raw: str, fallback_text: str = "") -> CatalystAnalysis | None: if not is_dilutive and looks_dilutive(fallback_text): is_dilutive = True rationale = str(data.get("rationale", "") or "")[:200] + conviction = _clamp(data.get("conviction", 0.0), 0.0, 1.0, 0.0) + # cap reactive coverage (never touches is_dilutive: an offering is still an offering) + if ctype == "halt_resumption" or HALT_RE.search(headline or ""): + conviction = min(conviction, HALT_MAX_CONVICTION) + elif REACTIVE_RE.search(headline or ""): + conviction = min(conviction, REACTIVE_MAX_CONVICTION) return CatalystAnalysis( catalyst_type=ctype, sentiment=_clamp(data.get("sentiment", 0.0), -1.0, 1.0, 0.0), - conviction=_clamp(data.get("conviction", 0.0), 0.0, 1.0, 0.0), + conviction=conviction, is_dilutive=is_dilutive, rationale=rationale, ) @@ -213,7 +238,7 @@ def classify_headline( return None if not response: return None - return _parse(response, fallback_text=f"{headline} {snippet}") + return _parse(response, fallback_text=f"{headline} {snippet}", headline=headline) # --------------------------------------------------------------------------- diff --git a/tests/unit/research/test_news_enrichment.py b/tests/unit/research/test_news_enrichment.py index 5d2fc6e..48c320b 100644 --- a/tests/unit/research/test_news_enrichment.py +++ b/tests/unit/research/test_news_enrichment.py @@ -254,3 +254,37 @@ def test_ollama_think_env(): from config import OllamaConfig assert OllamaConfig.from_env({"OLLAMA_THINK": "false"}).think is False assert OllamaConfig.from_env({"OLLAMA_THINK": ""}).think is None + + +@pytest.mark.parametrize("headline,cap", [ + ("Veea Shares Halted On Circuit Breaker To The Upside, Stock Now Up 172.05%", 0.1), + ("Trading Halt: Halt status updated at 9:45:42 AM ET: Halt News Pending", 0.1), + ("Why Is Veea Stock Gaining Thursday?", 0.3), + ("Xos Stock Surges on US Air Force Contract Win", 0.3), + ("Why FingerMotion Shares Are Trading Higher By Around 18%; Here Are 20 Stocks Moving Premarket", 0.3), +]) +def test_reactive_coverage_conviction_is_capped(headline, cap): + raw = '{"catalyst_type": "partnership_contract", "sentiment": 0.9, "conviction": 0.9, "is_dilutive": false}' + a = ne._parse(raw, fallback_text=headline, headline=headline) + assert a.conviction <= cap + + +@pytest.mark.parametrize("headline", [ + "BioCardia Receives FDA Confirmation For Its Biologics Evaluation And Research Pathway", + "Zedge Q3 Adj. EPS $0.07 Beats $0.05 Estimate, Sales $7.992M Beat $7.008M Estimate", + "Expion Energy Commences $9M Offering Of 2.117M Common Shares At $4.25 Per Share", +]) +def test_real_news_is_not_capped(headline): + raw = '{"catalyst_type": "fda_approval", "sentiment": 0.6, "conviction": 0.85, "is_dilutive": false}' + assert ne._parse(raw, fallback_text=headline, headline=headline).conviction == 0.85 + + +def test_halt_type_capped_and_dilution_untouched(): + raw = '{"catalyst_type": "halt_resumption", "sentiment": 1.0, "conviction": 0.8, "is_dilutive": true}' + a = ne._parse(raw, headline="ABC resumes trading") + assert a.conviction <= 0.1 and a.is_dilutive is True + + +def test_prompt_tells_the_model_reactive_coverage_is_not_a_catalyst(): + p = ne.build_prompt("Why Is Veea Stock Gaining Thursday?") + assert "NOT a new catalyst" in p and "" in p