""" core/analysis/news.py — Keyword-basiertes News-Sentiment """ from __future__ import annotations # Bullische Phrasen für WTI (Angebot ↓ / Nachfrage ↑ / Risiko ↑) NEWS_BULLISH_KW = { "supply cut", "production cut", "opec cut", "opec+ cut", "output cut", "sanction", "embargo", "ban on", "blockade", "shutdown", "outage", "disruption", "halt", "force majeure", "pipeline attack", "attack", "strike on", "missile", "drone strike", "tension escalat", "iran tension", "houthi", "red sea", "strait of hormuz", "war", "conflict escalat", "retaliat", "threat", "demand growth", "demand surge", "demand rise", "demand strong", "stockpile draw", "inventory draw", "stocks drop", "stocks fall", "stockpiles fall", "crude draw", "cushing draw", "eia draw", "pipeline shutdown", "refinery fire", "gulf of mexico storm", "hurricane", "winter storm", "cold snap", "oil surge", "oil rally", "oil jump", "oil soar", "oil spike", "crude rise", "crude rally", "wti rise", "wti surge", "wti rally", } # Bärische Phrasen für WTI (Angebot ↑ / Nachfrage ↓ / Entspannung) NEWS_BEARISH_KW = { "supply glut", "oversupply", "production increase", "output rise", "production boost", "opec boost", "opec+ unwind", "spr release", "strategic reserve release", "saudi increase", "us production record", "shale boom", "permian growth", "demand drop", "demand fall", "demand weak", "demand slump", "recession", "slowdown", "weak economy", "china slowdown", "stockpile build", "inventory build", "stocks rise", "stocks build", "crude build", "stockpiles rise", "cushing build", "eia build", "ceasefire", "truce", "deal reached", "agreement", "diplomatic", "talks resume", "easing tension", "sanction lift", "sanction relief", "oil drop", "oil plunge", "oil slide", "oil fall", "oil decline", "crude drop", "crude plunge", "wti fall", "wti slide", "oil crash", } # Wie viele gewichtete Headlines es braucht, damit der Score seinen Rohwert # annähernd erreicht (Shrinkage-Konstante, s. Begründung in `calc_news_sentiment`). # 4,0 gewählt, weil die reale Median-Beweismenge bei 2,8 liegt: damit bleibt eine # typische, einseitige 3-Headline-Lage UNTER der 0,5-Warnschwelle des Entry-Checks. _EVIDENCE_K = 4.0 def calc_news_sentiment(headlines: list, half_life_hours: float = 8.0) -> dict: """ Bewertet Headlines per Keyword-Matching mit altersgewichtetem Decay. score: -1.0 (klar bärisch) … +1.0 (klar bullisch) """ import time as _time if not headlines: # Felder identisch zum Normalfall halten — sonst wirft ein Konsument, der # `raw_score`/`evidence` liest, ausgerechnet im „keine News"-Fall. return {"score": 0.0, "n_bull": 0.0, "n_bear": 0.0, "raw_score": 0.0, "evidence": 0.0, "samples": []} now = _time.time() bull_w = bear_w = 0.0 samples = [] for h in headlines: text = (h.get("title_original") or h.get("title") or "").lower() if not text: continue age_h = max(0.0, (now - h.get("ts", now)) / 3600.0) weight = 0.5 ** (age_h / max(1.0, half_life_hours)) b = sum(1 for kw in NEWS_BULLISH_KW if kw in text) s = sum(1 for kw in NEWS_BEARISH_KW if kw in text) if b > s: bull_w += weight * (1 + 0.3 * (b - 1)) samples.append(("bull", h.get("title", "")[:70])) elif s > b: bear_w += weight * (1 + 0.3 * (s - 1)) samples.append(("bear", h.get("title", "")[:70])) total = bull_w + bear_w raw = 0.0 if total < 0.1 else (bull_w - bear_w) / total # ── BEWEISMENGEN-GEWICHTUNG (Fix 2026-08-03) ───────────────────────────── # ⚠ Vorher war der Score ein REINES VERHÄLTNIS: `(bull-bear)/total`. Eine # einzige bullische Schlagzeile bei null bearischen ergab damit +1,00 — # maximale Überzeugung aus EINEM Treffer. Gemessen an 173 Log-Einträgen: # **100 % standen auf ±1,00**, und in **100 %** der Fälle war eine Seite # exakt 0; die Median-Beweismenge betrug **2,8** gewichtete Headlines. # Über die gesamte DB (54.548 Zeilen) klebten **63,9 % auf exakt +1,00**. # Real am 03.08.: Sentiment +1,00 („bull=1.8, bear=0.0"), während WTI # −5,9 % stand. Der Wert trug damit faktisch EIN Bit (das Vorzeichen) und # löste im Entry-Check dauerhaft falsche „News-Konflikt"-Warnungen aus. # Korrektur: zur Null schrumpfen, solange die Beweislage dünn ist # (klassische Shrinkage). Bei der Median-Beweismenge 2,8 wird aus 1,00 # noch 0,41 — unter der 0,5-Warnschwelle des Entry-Checks; erst ~10 # gewichtete Headlines ergeben 0,71. # ⚠ EHRLICH: Das repariert die KALIBRIERUNG, nicht die Aussagekraft. Der # Score bleibt das Vorzeichen weniger Keyword-Treffer, und dessen # Prädiktivität ist gemessen NICHT robust (Forward-Return über 38k Paare, # 2 Hälften: der bearische Bucket kippt das Vorzeichen, der stark-bullische # liegt in BEIDEN Hälften bei null bis negativ). `k` ist eine begründete # Setzung, kein optimierter Wert — es gibt hier nichts zu optimieren, # solange das Signal selbst nichts vorhersagt. conf = total / (total + _EVIDENCE_K) if total > 0 else 0.0 score = raw * conf return { "score": max(-1.0, min(1.0, score)), "n_bull": round(bull_w, 1), "n_bear": round(bear_w, 1), # Rohwert + Beweismenge mitliefern: so ist im UI/Copilot erkennbar, ob # ein schwacher Score „ausgewogene Nachrichtenlage" oder „kaum Daten" heißt. "raw_score": round(max(-1.0, min(1.0, raw)), 3), "evidence": round(total, 2), "samples": samples[:5], }