"""core/econ_calendar.py — Wirtschaftskalender (High-Impact-News) als Handels-Sperre. User-Vorgabe 2026-07-30 („berücksichtige auch die Quellen aus dem Trading-Handbuch", Kapitel 5.4 + 8.1 Regel 8: „Keine Trades vor High-Impact-News", „keine Trades 15 Min. vor EIA"). Quelle: **Forex Factory JSON** (`nfs.faireconomy.media`) — kostenlos, kein API-Key, strukturiert mit `impact`-Level. Enthält auch „Crude Oil Inventories" (= der EIA-Report) und OPEC-Termine. ⚠ ABGRENZUNG zum bestehenden EIA-Blackout: Der ist **gemessen** (`backtest_events.py`: Mi 15:30–16:30 in BEIDEN Hälften negativ) und bleibt als fester Fallback bestehen — er funktioniert auch, wenn dieses Modul keine Daten bekommt. Der Kalender-Blackout hier ist eine **plausible Erweiterung** (FOMC/NFP/CPI/OPEC statt nur Mittwoch), aber NICHT separat backtestbar: Event-Zeitreihen reichen nicht weit genug zurück, und die Ereignisse sind zu selten für eine 2-Stichproben-Aussage. Er ist damit ein **Risiko-Schutz auf Basis der Handbuch-Regel**, kein gemessener Edge — deshalb sperrt er nur die AUTONOMEN Entries und warnt in der Checkliste; er blockt keine manuelle Order. Fail-safe: Ohne Netz/Daten liefert das Modul „kein Blackout" und der Bot arbeitet unverändert weiter (der gemessene EIA-Blackout greift trotzdem). """ from __future__ import annotations import json import threading import time import urllib.request from datetime import datetime, timezone from core.logger import get_logger log = get_logger("calendar") # BEIDE Wochen holen: „thisweek" endet samstags — der Markt öffnet aber Sonntag # 23:00 (Berlin), und ein Montag-Event läge dann in einem toten Fenster. _URLS = ("https://nfs.faireconomy.media/ff_calendar_thisweek.json", "https://nfs.faireconomy.media/ff_calendar_nextweek.json") _REFRESH_S = 3600.0 # 1×/Stunde reicht — der Kalender ändert sich selten _TIMEOUT_S = 15.0 # Währungen, die WTI real bewegen (USD dominiert; OPEC/Oil-Titel unabhängig davon) _CCY = {"USD"} # Titel-Stichworte, die IMMER als relevant gelten (auch bei niedrigerem Impact — # „Crude Oil Inventories" läuft bei Forex Factory teils als Low/Medium) _OIL_KEYS = ("crude oil", "opec", "oil inventories", "petroleum") class EconCalendar: def __init__(self, before_min: int = 15, after_min: int = 15, enabled: bool = True): self.enabled = bool(enabled) self.before_s = max(0, int(before_min)) * 60 self.after_s = max(0, int(after_min)) * 60 self._lock = threading.Lock() self._events: list[dict] = [] # [{ts, title, ccy, impact}] self._ts = 0.0 self._busy = False self._fail_logged = False # ── Daten holen ────────────────────────────────────────────────────────── def _fetch(self) -> None: try: raw: list = [] errs = 0 for url in _URLS: try: req = urllib.request.Request( url, headers={"User-Agent": "Mozilla/5.0"}) with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as r: part = json.loads(r.read().decode()) if isinstance(part, list): raw.extend(part) except Exception: errs += 1 # eine Woche darf fehlen (nextweek ist oft leer) if errs == len(_URLS): raise RuntimeError("keine Woche abrufbar") out = [] seen = set() for e in raw: title = (e.get("title") or "").strip() ccy = (e.get("country") or "").strip() imp = (e.get("impact") or "").strip() tl = title.lower() is_oil = any(k in tl for k in _OIL_KEYS) if not (is_oil or (imp == "High" and ccy in _CCY)): continue try: dt = datetime.fromisoformat(e.get("date")) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) except Exception: continue key = (round(dt.timestamp()), title) if key in seen: # Überlappung thisweek/nextweek continue seen.add(key) out.append({"ts": dt.timestamp(), "title": title, "ccy": ccy, "impact": ("Oil" if is_oil else imp)}) out.sort(key=lambda x: x["ts"]) with self._lock: self._events = out self._ts = time.time() self._fail_logged = False log.info(f"Wirtschaftskalender geladen: {len(out)} relevante Events") except Exception as e: if not self._fail_logged: log.info(f"Kalender nicht abrufbar ({e}) — News-Sperre inaktiv " f"(der gemessene EIA-Blackout greift weiterhin)") self._fail_logged = True def maybe_refresh(self) -> None: """Im Trend-Loop aufrufen; self-throttled auf ~1 h. ⚠ Der Fetch läuft in einem **Daemon-Thread** — sonst würde ein hängender HTTP-Call den Trend-Loop bis zum Timeout (15 s) blockieren. """ if not self.enabled or self._busy: return if time.time() - self._ts < _REFRESH_S: return self._busy = True self._ts = time.time() # sofort setzen → kein Retry-Sturm bei Fehler def _job(): try: self._fetch() finally: self._busy = False threading.Thread(target=_job, daemon=True, name="econ-calendar").start() # ── Abfragen ───────────────────────────────────────────────────────────── def blackout(self) -> dict | None: """Läuft gerade ein News-Fenster? → {title, impact, minutes} sonst None.""" if not self.enabled: return None now = time.time() with self._lock: evs = list(self._events) for e in evs: if e["ts"] - self.before_s <= now <= e["ts"] + self.after_s: return {"title": e["title"], "impact": e["impact"], "ccy": e["ccy"], "minutes": round((e["ts"] - now) / 60.0, 1)} return None def next_event(self) -> dict | None: """Nächstes relevantes Event (für die Anzeige).""" now = time.time() with self._lock: evs = list(self._events) for e in evs: if e["ts"] >= now: return {"title": e["title"], "impact": e["impact"], "ccy": e["ccy"], "minutes": round((e["ts"] - now) / 60.0)} return None def snapshot(self) -> dict: bl = self.blackout() return {"enabled": self.enabled, "n": len(self._events), "blackout": bl, "next": self.next_event(), "age_min": round((time.time() - self._ts) / 60.0) if self._ts else None}