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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
This commit is contained in:
1 parent
082d01e8bd
commit
3e523e132a
2 files changed
+63
-4
No files matched your search
@@ -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"<news>\n{news}\n</news>"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user