Files
Axel HocksandClaude Opus 4.8 75d28827e8 Initial commit: Oil Trading Bot (MT5, WTI)
Headless FastAPI-Backend (server.py + core/engine.py) mit Mobile-PWA (web/),
Strategie-/Backtest-Suite und Doku. Secrets, DB, Logs und Laufzeit-State sind
via .gitignore ausgeschlossen; Config-Vorlage: oil_widget_config.ini.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 08:29:23 +02:00

843 lines
35 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
core/news.py — News-Fetcher + Übersetzer
==========================================
Lädt RSS-Feeds zu WTI-Crude/Öl-News und übersetzt sie optional auf Deutsch.
• NewsTranslator — deep-translator (Google + MyMemory-Fallback)
• NewsFetcher — 4 RSS-Feeds, Sortierung nach Datum
Cache der Übersetzungen liegt in oil_widget_translations.json
(neben dem Hauptscript) und überlebt Neustarts.
"""
from __future__ import annotations
import json
import hashlib
import threading
import time
from pathlib import Path
from core.logger import get_logger
log_trans = get_logger("trans")
log_news = get_logger("news")
# Cache-Pfad neben dem Hauptscript
TRANSLATION_CACHE_FILE = Path(__file__).parent.parent / "oil_widget_translations.json"
TRANSLATION_CACHE_MAX = 1000 # FIFO-Trim ab 1200
TRANSLATION_TIMEOUT_S = 8
# ══════════════════════════════════════════════
# NEWS-ÜBERSETZUNG
# ══════════════════════════════════════════════
class NewsTranslator:
"""
Übersetzt englische Nachrichten-Titel via deep-translator (Google).
• Persistenter JSON-Cache (kein doppelter API-Call für gleiche Headlines)
• Background-Threading: UI bleibt responsiv
• Fallback-Provider (Google → MyMemory) falls primary fehlschlägt
• Komplett lokal abschaltbar via [translation] enabled = false
"""
def __init__(self, enabled: bool = True,
target: str = "de",
provider: str = "google",
cache_file: Path = TRANSLATION_CACHE_FILE):
self.enabled = enabled
self.target = target.strip().lower() or "de"
self.provider = provider.strip().lower() or "google"
self.cache_file = cache_file
self.cache = {}
self._cache_dirty = False
self._lock = threading.Lock()
self.error = None
self.api_ok = None
self._load_cache()
def _load_cache(self):
if not self.cache_file.exists():
return
try:
with open(self.cache_file, "r", encoding="utf-8") as f:
self.cache = json.load(f)
log_trans.info(f"Cache geladen: {len(self.cache)} Einträge")
except Exception as e:
log_trans.error(f"Cache-Lesefehler: {e}")
self.cache = {}
def _save_cache(self):
if not self._cache_dirty:
return
try:
if len(self.cache) > TRANSLATION_CACHE_MAX + 200:
items = list(self.cache.items())
self.cache = dict(items[-TRANSLATION_CACHE_MAX:])
with open(self.cache_file, "w", encoding="utf-8") as f:
json.dump(self.cache, f, ensure_ascii=False, indent=1)
self._cache_dirty = False
except Exception as e:
log_trans.error(f"Cache-Schreibfehler: {e}")
@staticmethod
def _hash(text: str) -> str:
return hashlib.sha1(text.encode("utf-8", errors="ignore")).hexdigest()[:16]
def _translate_one(self, text: str) -> str | None:
try:
from deep_translator import GoogleTranslator
except ImportError:
self.error = "pip install deep-translator"
return None
try:
if self.provider == "google":
return GoogleTranslator(source="auto", target=self.target).translate(text)
except Exception as e:
log_trans.warning(f"Google-Fehler: {e}")
try:
from deep_translator import MyMemoryTranslator
src_full = "en-GB"
tgt_full = f"{self.target}-{self.target.upper()}"
return MyMemoryTranslator(source=src_full, target=tgt_full).translate(text)
except Exception as e:
log_trans.warning(f"MyMemory-Fehler: {e}")
return None
def translate(self, text: str) -> str:
if not self.enabled or not text:
return text
h = self._hash(text)
with self._lock:
cached = self.cache.get(h)
if cached:
return cached
result = self._translate_one(text)
if result and result.strip() and result.lower() != text.lower():
with self._lock:
self.cache[h] = result
self._cache_dirty = True
self.api_ok = True
return result
if result is None:
self.api_ok = False
return text
def translate_batch(self, headlines: list) -> list:
if not self.enabled:
return headlines
out = []
for h in headlines:
new = dict(h)
orig = h.get("title", "")
translated = self.translate(orig)
if translated != orig:
new["title_original"] = orig
new["title"] = translated
out.append(new)
if self._cache_dirty:
self._save_cache()
return out
def shutdown(self):
self._save_cache()
# ══════════════════════════════════════════════
# NEWS-FETCHER
# ══════════════════════════════════════════════
class NewsFetcher:
"""
Holt aktuelle WTI-Crude-News sowie geopolitische / makroökonomische News
aus öffentlichen RSS-Feeds.
Energie-Feeds (WTI / Rohstoff-spezifisch):
OilPrice, EIA Press, Rigzone, ShaleMag, Offshore Energy,
Oil&Gas 360, Guardian Oil
Makro- / Geopolitik-Feeds (marktrelevant für WTI):
Al Jazeera, BBC Business, BBC World, MarketWatch, The Hill Energy,
DW World, FT Commodities, Middle East Eye, Hellenic Shipping News
"""
# ── Energie / Rohstoffe — WTI-fokussiert ─────────────────────────────
FEEDS_ENERGY = [
("OilPrice", "https://oilprice.com/rss/main"),
("EIA Press", "https://www.eia.gov/rss/press_rss.xml"),
("Rigzone", "https://www.rigzone.com/news/rss/rigzone_latest.aspx"),
("ShaleMag", "https://shalemag.com/feed/"),
("Offshore Energy", "https://www.offshore-energy.biz/feed/"),
("Oil&Gas 360", "https://www.oilandgas360.com/feed/"),
("Guardian Oil", "https://www.theguardian.com/business/oil/rss"),
]
# ── Makro / Geopolitik (marktrelevant für WTI) ───────────────────────
# Reuters-Feed wurde 2020 abgeschaltet — nicht mehr verwenden.
FEEDS_GEO = [
("Al Jazeera", "https://www.aljazeera.com/xml/rss/all.xml"),
("BBC Business", "https://feeds.bbci.co.uk/news/business/rss.xml"),
("BBC World", "https://feeds.bbci.co.uk/news/world/rss.xml"),
("MarketWatch", "https://feeds.marketwatch.com/marketwatch/marketpulse/"),
("The Hill", "https://thehill.com/policy/energy-environment/feed/"),
("DW World", "https://rss.dw.com/rdf/rss-en-world"),
("FT Commodities","https://www.ft.com/commodities?format=rss"),
("Middle East Eye","https://www.middleeasteye.net/rss"),
("Hellenic Ship.", "https://www.hellenicshippingnews.com/feed/"),
]
# Kombination beider Feed-Gruppen
FEEDS = FEEDS_ENERGY + FEEDS_GEO
# Schlüsselwörter zur Relevanz-Filterung (Geo-Feeds)
GEO_KEYWORDS = {
# Öl/Energie allgemein
"oil", "crude", "wti", "brent", "petroleum", "energy",
"opec", "iea", "eia", "barrel", "supply", "demand",
"refin", "pipeline", "tanker", "stockpile", "inventory", "stocks",
"spr", "gas", "fuel", "commodity", "commodities", "lng",
# Schifffahrt / Tankerrouten — direkt preisrelevant
"shipping", "vessel", "suez", "canal", "chokepoint",
"bab-el-mandeb", "vlcc", "freight", "cargo",
# WTI-spezifisch: US-Produktion & Infrastruktur
"cushing", "permian", "bakken", "eagle ford", "marcellus",
"keystone", "gulf of mexico", "nymex", "shale", "fracking",
"us oil", "us energy", "us crude", "us production",
"refinery capacity", "hurricane",
# Sanktionen / Geopolitik
"iran", "sanction", "embargo", "strait", "hormuz", "gulf",
# Akteure
"saudi", "russia", "moscow", "putin", "iraq",
"venezuela", "libya", "nigeria", "kuwait", "uae", "qatar",
"ukraine", "hamas", "houthi", "yemen", "israel", "hezbollah",
"lebanon", "syria", "china", "beijing", "iran",
# Ereignisse
"war", "attack", "ceasefire", "truce", "drone", "missile",
"embargo", "blockade", "strike", "shutdown", "outage",
"disruption", "force majeure", "summit", "deal", "agreement",
"tariff", "trade war", "recession", "inflation", "gdp",
}
# ── Vollständige Feed-Pools inkl. Alternativen (Reihenfolge = Priorität) ──
# validate_feeds() testet alle Feeds parallel und ersetzt ausgefallene
# primäre Feeds automatisch mit dem nächsten funktionierenden Pool-Eintrag.
_ENERGY_POOL = [
# Primär
("OilPrice", "https://oilprice.com/rss/main"),
("EIA Press", "https://www.eia.gov/rss/press_rss.xml"),
("Rigzone", "https://www.rigzone.com/news/rss/rigzone_latest.aspx"),
("ShaleMag", "https://shalemag.com/feed/"),
("Offshore Energy", "https://www.offshore-energy.biz/feed/"),
("Oil&Gas 360", "https://www.oilandgas360.com/feed/"),
("Guardian Oil", "https://www.theguardian.com/business/oil/rss"),
# Alternativen (automatischer Fallback)
("Energy Monitor", "https://www.energymonitor.ai/feed/"),
("Natural Gas Int.", "https://www.naturalgasintel.com/feed/"),
]
_GEO_POOL = [
# Primär
("Al Jazeera", "https://www.aljazeera.com/xml/rss/all.xml"),
("BBC Business", "https://feeds.bbci.co.uk/news/business/rss.xml"),
("BBC World", "https://feeds.bbci.co.uk/news/world/rss.xml"),
("MarketWatch", "https://feeds.marketwatch.com/marketwatch/marketpulse/"),
("The Hill", "https://thehill.com/policy/energy-environment/feed/"),
("DW World", "https://rss.dw.com/rdf/rss-en-world"),
("FT Commodities", "https://www.ft.com/commodities?format=rss"),
("Middle East Eye", "https://www.middleeasteye.net/rss"),
("Hellenic Ship.", "https://www.hellenicshippingnews.com/feed/"),
# Alternativen (automatischer Fallback)
("New Arab", "https://www.newarab.com/rss.xml"),
]
# WTI-Direktseite (HTML-Scraper, keine RSS)
OILPRICE_WTI_URL = "https://oilprice.com/futures/wti/"
# Globale Öl-Preistabelle (Breadth + WTI/Brent Spot)
OILPRICE_CHARTS_URL = "https://oilprice.com/oil-price-charts/"
# HTTP-Timeout pro Feed in Sekunden — verhindert dass langsame/tote
# Feeds den ganzen Fetch-Thread blockieren.
FETCH_TIMEOUT_S = 8
def __init__(self, translator: 'NewsTranslator | None' = None):
self.headlines = []
self.last_fetch = None
self.error = None
self.translator = translator
self.sentiment = {"score": 0.0, "n_bull": 0, "n_bear": 0, "samples": []}
self.price_data: dict | None = None # OilPrice Charts Breadth + Spot
# Pro-Feed Status: {source: {"ok": bool, "n": int, "msg": str}}
self.feed_status: dict = {}
# Validierte, aktive Feed-Listen (None = noch nicht validiert → Klassenvariable)
self._active_energy: list | None = None
self._active_geo: list | None = None
self._lock = threading.Lock()
def _is_geo_relevant(self, title: str) -> bool:
"""Gibt True zurück, wenn ein Geo-Feed-Titel marktrelevante Keywords enthält."""
low = title.lower()
return any(kw in low for kw in self.GEO_KEYWORDS)
# ══════════════════════════════════════════════
# FEED-VALIDIERUNG (einmalig beim Start)
# ══════════════════════════════════════════════
def _test_url(self, source: str, url: str) -> tuple[bool, int, str]:
"""Schneller Feed-Test ohne feed_status zu verändern. Gibt (ok, n_entries, msg) zurück."""
try:
import feedparser, requests
except ImportError:
return False, 0, "missing: feedparser/requests"
try:
resp = requests.get(
url,
timeout=(2, 6), # kurze Timeouts für schnelle Validierung
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 OilWidget/1.0",
"Accept": "application/rss+xml, application/xml, text/xml, */*",
},
)
if resp.status_code != 200:
return False, 0, f"HTTP {resp.status_code}"
feed = feedparser.parse(resp.content)
n = len(feed.entries)
if n == 0:
return False, 0, "0 Einträge"
return True, n, "ok"
except Exception as e:
return False, 0, str(e)[:60]
def validate_feeds(self) -> dict:
"""
Testet alle Feeds aus _ENERGY_POOL / _GEO_POOL parallel.
Ausgefallene Primär-Feeds werden automatisch durch den nächsten
funktionierenden Pool-Eintrag ersetzt.
Ergebnis wird in self._active_energy / self._active_geo gespeichert.
Aufruf: einmalig beim Start in einem Daemon-Thread.
"""
import concurrent.futures
all_feeds = list({u: (s, u) for s, u in
self._ENERGY_POOL + self._GEO_POOL}.values())
log_news.info(f"Feed-Validierung: teste {len(all_feeds)} Feeds …")
# Parallel testen
test_results: dict[str, tuple[bool, int, str]] = {} # url → (ok, n, msg)
with concurrent.futures.ThreadPoolExecutor(max_workers=12) as ex:
fut_map = {ex.submit(self._test_url, s, u): (s, u) for s, u in all_feeds}
try:
for fut in concurrent.futures.as_completed(fut_map, timeout=25):
src, url = fut_map[fut]
try:
ok, n, msg = fut.result()
except Exception as e:
ok, n, msg = False, 0, str(e)[:50]
test_results[url] = (ok, n, msg)
status = "OK" if ok else "FAIL"
log_news.debug(f" [{status}] {src}: {msg} ({n} Einträge)")
except concurrent.futures.TimeoutError:
log_news.warning("Feed-Validierung: Timeout nach 25 s")
def build_active(primary: list, pool: list) -> tuple[list, list]:
"""
Gibt (aktive_feeds, ersetzungen) zurück.
Primäre Feeds haben Vorrang; ausgefallene werden mit dem
nächsten noch nicht verwendeten Pool-Feed ersetzt.
"""
primary_urls = {u for _, u in primary}
alternatives = [(s, u) for s, u in pool if u not in primary_urls]
active = []
used_urls: set[str] = set()
replacements = []
for src, url in primary:
ok, n, msg = test_results.get(url, (False, 0, "nicht getestet"))
if ok and n > 0:
active.append((src, url))
used_urls.add(url)
else:
log_news.warning(f"Feed ausgefallen: {src} [{msg}] → suche Ersatz")
replaced = False
for alt_src, alt_url in alternatives:
if alt_url in used_urls:
continue
alt_ok, alt_n, alt_msg = test_results.get(alt_url, (False, 0, "nicht getestet"))
if alt_ok and alt_n > 0:
active.append((alt_src, alt_url))
used_urls.add(alt_url)
replacements.append((src, alt_src))
log_news.info(f" → Ersetzt: {src}{alt_src}")
replaced = True
break
if not replaced:
log_news.warning(f" → Kein Ersatz verfügbar für {src}")
return active, replacements
active_e, rep_e = build_active(self.FEEDS_ENERGY, self._ENERGY_POOL)
active_g, rep_g = build_active(self.FEEDS_GEO, self._GEO_POOL)
with self._lock:
self._active_energy = active_e
self._active_geo = active_g
total_ok = sum(1 for ok, _, _ in test_results.values() if ok)
total_fail = len(test_results) - total_ok
log_news.info(
f"Feed-Validierung abgeschlossen: {total_ok} OK / {total_fail} ausgefallen "
f"| Energie: {len(active_e)} aktiv ({len(rep_e)} ersetzt) "
f"| Geo: {len(active_g)} aktiv ({len(rep_g)} ersetzt)"
)
return {
"energy_active": active_e,
"geo_active": active_g,
"replacements": rep_e + rep_g,
"n_ok": total_ok,
"n_fail": total_fail,
}
def _fetch_oilprice_wti_scrape(self) -> list:
"""
Scrapet https://oilprice.com/futures/wti/ nach WTI-spezifischen Headlines.
Nutzt BeautifulSoup wenn verfügbar, sonst Regex-Fallback.
Gibt bis zu 8 Einträge im selben Format wie RSS-Feeds zurück.
"""
try:
import requests
except ImportError:
return []
try:
resp = requests.get(
self.OILPRICE_WTI_URL,
timeout=(3, self.FETCH_TIMEOUT_S),
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 Chrome/120.0 OilWidget/1.0",
"Accept": "text/html,application/xhtml+xml,*/*",
"Accept-Language": "en-US,en;q=0.9",
},
)
except Exception as e:
log_news.warning(f"OilPrice WTI scrape: {e}")
self.feed_status["OilPrice WTI"] = {"ok": False, "n": 0, "msg": str(e)[:60]}
return []
if resp.status_code != 200:
log_news.warning(f"OilPrice WTI: HTTP {resp.status_code}")
self.feed_status["OilPrice WTI"] = {
"ok": False, "n": 0, "msg": f"HTTP {resp.status_code}"}
return []
items = []
now = time.time()
seen = set()
# Artikel-Pfade auf OilPrice.com: /Energy/... und /Latest-Energy-News/...
_ARTICLE_PATHS = ("/Energy/", "/Latest-Energy-News/")
try:
from bs4 import BeautifulSoup
soup = BeautifulSoup(resp.content, "html.parser")
for tag in soup.find_all("a", href=True):
href = tag.get("href", "")
title = tag.get_text(strip=True)
# Nur Artikel-Pfade, keine Navigation/Werbung
if not any(p in href for p in _ARTICLE_PATHS):
continue
if len(title) < 25 or title in seen:
continue
if not href.startswith("http"):
href = ("https://oilprice.com" + href
if href.startswith("/") else "")
if not href:
continue
seen.add(title)
items.append({
"title": title,
"link": href,
"source": "OilPrice WTI",
"ts": now,
"category": "energy",
})
if len(items) >= 8:
break
except ImportError:
# Regex-Fallback: kein beautifulsoup4 installiert
import re
pat = re.compile(
r'<a[^>]+href="(https?://oilprice\.com/'
r'(?:Energy|Latest-Energy-News)/[^"#?]{10,})"[^>]*>'
r'\s*([^<\n]{25,200})\s*</a>',
re.IGNORECASE | re.DOTALL,
)
for href, raw in pat.findall(resp.text):
title = re.sub(r'\s+', ' ', raw).strip()
if not title or title in seen:
continue
seen.add(title)
items.append({
"title": title,
"link": href,
"source": "OilPrice WTI",
"ts": now,
"category": "energy",
})
if len(items) >= 8:
break
if not items:
log_news.info("OilPrice WTI: BeautifulSoup nicht installiert "
"(pip install beautifulsoup4 lxml) — Regex-Fallback aktiv")
self.feed_status["OilPrice WTI"] = {
"ok": len(items) > 0,
"n": len(items),
"msg": "scrape ok" if items else "0 Headlines",
}
log_news.info(f"OilPrice WTI scrape: {len(items)} Headlines")
return items
def _fetch_oilprice_charts(self) -> dict | None:
"""
Scrapet https://oilprice.com/oil-price-charts/ und extrahiert:
• WTI Crude und Brent Crude: Preis, absolute Änderung, %Änderung
• Market Breadth aus Tabelle 'Futures & Indexes':
Anteil der Öl-Benchmarks mit positivem Tageschange
• breadth_signal: -1.0 (alle runter) … +1.0 (alle rauf)
Breadth > +0.5 → bullisher Marktkontext
Breadth < -0.5 → bärischer Marktkontext
"""
try:
import requests
except ImportError:
return None
try:
resp = requests.get(
self.OILPRICE_CHARTS_URL,
timeout=(3, self.FETCH_TIMEOUT_S),
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 Chrome/120.0 OilWidget/1.0",
"Accept": "text/html,application/xhtml+xml,*/*",
"Accept-Language": "en-US,en;q=0.9",
},
)
except Exception as e:
log_news.warning(f"OilPrice Charts fetch: {e}")
return None
if resp.status_code != 200:
log_news.warning(f"OilPrice Charts: HTTP {resp.status_code}")
return None
try:
from bs4 import BeautifulSoup
except ImportError:
log_news.warning("OilPrice Charts: BeautifulSoup fehlt (pip install beautifulsoup4)")
return None
try:
soup = BeautifulSoup(resp.content, "html.parser")
tables = soup.find_all("table", class_="oilprices__table")
if not tables:
log_news.warning("OilPrice Charts: Tabelle nicht gefunden")
return None
t0 = tables[0] # "Futures & Indexes"
n_up = n_down = 0
wti = brent = None
for row in t0.find_all("tr"):
cells = row.find_all(["th", "td"])
if len(cells) < 4:
continue
name_cell = cells[1]
price_cell = cells[2]
change_cell = cells[3]
pct_cell = cells[4] if len(cells) > 4 else None
name = name_cell.get_text(strip=True)
try:
price = float(price_cell.get_text(strip=True).replace(",", ""))
except ValueError:
continue
change_text = change_cell.get_text(strip=True)
change_classes = change_cell.get("class", [])
try:
change = float(change_text.replace(",", ""))
except ValueError:
change = 0.0
is_up = any("up" in c for c in change_classes)
is_down = any("down" in c for c in change_classes)
if is_up:
n_up += 1
elif is_down:
n_down += 1
if name in ("WTI Crude", "Brent Crude"):
pct = 0.0
if pct_cell:
raw_pct = pct_cell.get_text(strip=True).split("(")[0]
try:
pct = float(raw_pct.replace("%", "").replace(",", ""))
except ValueError:
pass
entry = {"price": price, "change": change, "pct": pct}
if name == "WTI Crude":
wti = entry
else:
brent = entry
n_total = n_up + n_down
breadth_pct = round(n_up / n_total * 100, 1) if n_total else 50.0
breadth_signal = round((n_up - n_down) / n_total, 3) if n_total else 0.0
result = {
"wti": wti,
"brent": brent,
"n_up": n_up,
"n_down": n_down,
"n_total": n_total,
"breadth_pct": breadth_pct, # % der Futures die gestiegen sind
"breadth_signal": breadth_signal, # -1..+1
}
wti_str = f"WTI={wti['price']:.2f} ({wti['pct']:+.2f}%)" if wti else "WTI=?"
brent_str = f"Brent={brent['price']:.2f} ({brent['pct']:+.2f}%)" if brent else "Brent=?"
log_news.info(
f"OilPrice Charts: {wti_str} {brent_str} "
f"Breadth {breadth_pct:.0f}% ({n_up}up/{n_down}dn) signal={breadth_signal:+.2f}"
)
return result
except Exception as e:
log_news.warning(f"OilPrice Charts parse: {e}", exc_info=True)
return None
def price_data_snapshot(self) -> dict | None:
"""Thread-safe Kopie der letzten Preisdaten (OilPrice Charts)."""
with self._lock:
return dict(self.price_data) if self.price_data else None
def _load_feed(self, source: str, url: str):
"""
Lädt eine einzelne RSS-URL mit hardem HTTP-Timeout.
Liefert die feedparser-Feed oder None bei Fehler.
Setzt feed_status[source] für spätere Diagnose.
"""
try:
import feedparser
import requests
except ImportError as e:
self.feed_status[source] = {"ok": False, "n": 0, "msg": f"missing: {e}"}
return None
try:
resp = requests.get(
url,
timeout=(3, self.FETCH_TIMEOUT_S),
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 OilWidget/1.0",
"Accept": "application/rss+xml, application/xml, text/xml, */*",
},
)
if resp.status_code != 200:
self.feed_status[source] = {
"ok": False, "n": 0,
"msg": f"HTTP {resp.status_code}",
}
log_news.warning(f"{source}: HTTP {resp.status_code}")
return None
feed = feedparser.parse(resp.content)
if feed.bozo and not feed.entries:
# bozo=1 mit Entries ist meist nur "namespace warning" → OK
msg = str(feed.bozo_exception)[:80] if hasattr(feed, "bozo_exception") else "parse error"
self.feed_status[source] = {"ok": False, "n": 0, "msg": msg}
log_news.warning(f"{source}: {msg}")
return None
n = len(feed.entries)
self.feed_status[source] = {"ok": True, "n": n, "msg": "ok"}
return feed
except Exception as e:
self.feed_status[source] = {"ok": False, "n": 0, "msg": str(e)[:80]}
log_news.warning(f"{source}: {e}")
return None
def fetch(self):
try:
import feedparser # nur für Verfügbarkeits-Check
except ImportError:
with self._lock:
self.error = "pip install feedparser requests"
return
# Per-Feed Status für diesen Run leeren
self.feed_status = {}
energy_items = []
geo_items = []
# ── OilPrice WTI-Direktseite (Scraper, höchste Priorität) ──
wti_items = self._fetch_oilprice_wti_scrape()
energy_items.extend(wti_items)
# ── OilPrice Charts — Preis-Breadth ──
charts_data = self._fetch_oilprice_charts()
# Validierte Listen verwenden (nach validate_feeds()), sonst Klassen-Defaults
with self._lock:
energy_feeds = self._active_energy if self._active_energy is not None else self.FEEDS_ENERGY
geo_feeds = self._active_geo if self._active_geo is not None else self.FEEDS_GEO
# ── Energie-Feeds: bis zu 5 Einträge je Feed, keine Filterung ──
for source, url in energy_feeds:
feed = self._load_feed(source, url)
if feed is None:
continue
for entry in feed.entries[:5]:
pub = entry.get("published_parsed") or time.gmtime()
energy_items.append({
"title": entry.get("title", "(no title)").strip(),
"link": entry.get("link", ""),
"source": source,
"ts": time.mktime(pub),
"category": "energy",
})
# ── Geo-Feeds: bis zu 3 Einträge je Feed, nur relevante Titel ──
for source, url in geo_feeds:
feed = self._load_feed(source, url)
if feed is None:
continue
count = 0
for entry in feed.entries:
if count >= 3:
break
title = entry.get("title", "").strip()
if not self._is_geo_relevant(title):
continue
pub = entry.get("published_parsed") or time.gmtime()
geo_items.append({
"title": title,
"link": entry.get("link", ""),
"source": source,
"ts": time.mktime(pub),
"category": "geo",
})
count += 1
# Diagnose-Zeilen ins Log
ok_count = sum(1 for s in self.feed_status.values() if s["ok"])
fail_count = len(self.feed_status) - ok_count
log_news.info(
f"Feed-Status: {ok_count} OK, {fail_count} fehlgeschlagen"
)
for source, st in self.feed_status.items():
if st["ok"]:
log_news.debug(f" ✓ {source}: {st['n']} Einträge")
else:
log_news.info(f" ✗ {source}: {st['msg']}")
# ── Zusammenführen: WTI-Scraper + RSS Energie + Geo ──
# WTI-Scraper-Items haben ts=now → sortieren immer an erste Stelle wenn aktuell
energy_items.sort(key=lambda x: x["ts"], reverse=True)
geo_items.sort(key=lambda x: x["ts"], reverse=True)
# WTI-Scraper bekommt bis zu 4 Slots im Energy-Bucket (von 7 gesamt)
wti_out = [i for i in energy_items if i.get("source") == "OilPrice WTI"][:4]
rss_energy = [i for i in energy_items if i.get("source") != "OilPrice WTI"][:3]
items = wti_out + rss_energy + geo_items[:4]
items.sort(key=lambda x: x["ts"], reverse=True)
items = items[:10]
log_news.info(
f"→ WTI-Scraper:{len(wti_out)} + Energie-RSS:{len(rss_energy)} + "
f"Geo:{len(geo_items[:4])} = {len(items)} Headlines"
)
if self.translator and self.translator.enabled:
try:
items = self.translator.translate_batch(items)
except Exception as e:
log_news.warning(f"Übersetzung fehlgeschlagen: {e}")
# Sentiment auf den (ggf. übersetzten) Headlines berechnen.
# calc_news_sentiment() greift bevorzugt auf title_original (englisch) zurück.
try:
from core.analysis import calc_news_sentiment
sentiment = calc_news_sentiment(items)
except Exception as e:
log_news.warning(f"Sentiment-Berechnung fehlgeschlagen: {e}")
sentiment = {"score": 0.0, "n_bull": 0, "n_bear": 0, "samples": []}
# ── Breadth-Signal in Sentiment einblenden (Gewicht 25%) ──────────────
# Breadth misst ob die Mehrheit aller Öl-Benchmarks steigt/fällt.
# Nur bei starkem Signal (|breadth| > 0.4) und ohne starke News-Gegenmeinung.
if charts_data:
bs = charts_data.get("breadth_signal", 0.0)
if abs(bs) > 0.4:
raw_score = sentiment["score"]
blended = round(raw_score * 0.75 + bs * 0.25, 3)
blended = max(-1.0, min(1.0, blended))
sentiment = dict(sentiment)
sentiment["score"] = blended
sentiment["breadth_signal"] = bs
sentiment["breadth_pct"] = charts_data.get("breadth_pct", 50.0)
log_news.info(
f"Breadth {bs:+.2f} → Sentiment {raw_score:+.2f}{blended:+.2f}"
)
with self._lock:
self.headlines = items
self.last_fetch = time.time()
self.error = None if items else "Keine News-Feeds erreichbar"
self.sentiment = sentiment
self.price_data = charts_data
log_news.info(f"News-Sentiment: {sentiment['score']:+.2f} "
f"(bull={sentiment['n_bull']}, bear={sentiment['n_bear']})")
def snapshot(self):
with self._lock:
return list(self.headlines), self.last_fetch, self.error
def sentiment_snapshot(self) -> dict:
"""Thread-safe Kopie des letzten Sentiment-Snapshots."""
with self._lock:
return dict(self.sentiment)
def storm_state(self) -> dict:
"""News-Sturm-Indikator (REINE ANZEIGE, kein Signal): Headline-Rate der
letzten Stunde vs. Basisrate des vorhandenen Feed-Fensters. Viel frische
Öl-/Geo-Schlagzeilen auf einmal = Ereignis läuft → Vorsicht (Vola).
normal · elevated (≥3 frisch & ≥2× Basis) · storm (≥5 frisch & ≥3× Basis)."""
with self._lock:
items = [h for h in self.headlines if h.get("ts")]
now = time.time()
if not items:
return {"level": "normal", "fresh": 0, "base_per_h": 0.0}
fresh = sum(1 for h in items if now - h["ts"] <= 3600)
# Basis-Fenster min. 6 h verankern — sonst bläht ein frischer Burst die
# Basisrate selbst auf und der Sturm erkennt sich nicht (Selbst-Normierung).
span_h = min(48.0, max(6.0, (now - min(h["ts"] for h in items)) / 3600))
base = len(items) / span_h # Ø Headlines/Stunde im Fenster
level = "normal"
if fresh >= 5 and fresh >= 3 * base:
level = "storm"
elif fresh >= 3 and fresh >= 2 * base:
level = "elevated"
return {"level": level, "fresh": fresh, "base_per_h": round(base, 1)}