Anlass: Frage nach X-Anbindung fuer den Copiloten. Vorher geprueft, ob der vorhandene Textstrom ueberhaupt traegt. Er trug nichts, weil der Score kaputt war. calc_news_sentiment bildete ein REINES VERHAELTNIS (bull-bear)/total, ohne Bezug zur Beweismenge. Eine einzige bullische Schlagzeile bei null baerischen ergab +1,00. Diagnose: - 173 von 173 Log-Eintraegen auf +/-1,00 gesaettigt (100 %) - in 100 % der Faelle eine Seite exakt 0 - Median-Beweismenge nur 2,8 gewichtete Headlines - ueber die DB (54.548 Zeilen): 63,9 % auf exakt +1,00 Real am 03.08.: +1,00 waehrend WTI -5,9 % stand. Der Wert trug ein Bit und loeste im Entry-Check dauerhaft falsche "News-Konflikt"-Warnungen aus. Die Keyword-Listen sind NICHT die Ursache (56/48, keine Ueberschneidung). Fix: Shrinkage score = raw * total/(total+4,0). Die 4,0 ist begruendet, nicht optimiert: bei der realen Median-Beweismenge 2,8 bleibt eine einseitige 3-Headline-Lage unter der 0,5-Warnschwelle, ~10 Headlines ergeben 0,71. Zusaetzlich raw_score und evidence im Rueckgabewert, damit unterscheidbar wird ob "ausgewogene Lage" oder "kaum Daten". Leer-Fall liefert dieselben Felder. 6 Szenarien getestet, live verifiziert: bei identischer Beweislage (bull=1.8, bear=0.0) +1,00 -> +0,31. EHRLICH: das repariert die Kalibrierung, nicht die Aussagekraft. Parallel gemessen (Forward-Return 120 min, 38.213 Paare, 84 Tage, 2 Haelften, gegen die Regime-Drift gelesen): der stark-bullische Bucket liegt in BEIDEN Haelften bei null bis negativ, der baerische kippt das Vorzeichen. News-Sentiment ist nicht robust praediktiv. Bleibt Kontext-Chip ohne Verdict-Stimme. Methodik-Falle dokumentiert: der erste Messlauf nutzte candles_m1 (nur ~14 Tage) -> bisect lieferte fuer aeltere Zeitstempel denselben Randwert, alle Forward-Returns exakt 0,0000. Erst der Lauf mit MT5-M5-Bars plus Deckungspruefung war gueltig. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
114 lines
5.7 KiB
Python
114 lines
5.7 KiB
Python
"""
|
||
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],
|
||
}
|