Files
AH-Oil-Trader/core/econ_calendar.py
T
Axel HocksandClaude Opus 5 a6f370ac02 Wirtschaftskalender-Blackout + Konsolidierungs-Box beschriftet
core/econ_calendar.py (NEU): High-Impact-News-Fenster als Handels-Sperre.
Quelle Forex Factory JSON (kostenlos, kein Key, mit impact-Feld), beide
Wochen (thisweek endet Sa, Markt oeffnet So 23:00 -> sonst blinder Fleck),
1x/h im Daemon-Thread, dedupliziert. Filter: High+USD ODER Oil/OPEC-Titel.

WICHTIG - nicht gemessen: Events sind zu selten fuer eine 2-Stichproben-
Aussage. Das ist die Handbuch-Regel, kein belegter Edge. Deshalb bewusst
asymmetrisch: sperrt NUR die autonomen Entries (Auto-Squeeze + Auto-Signal,
ohne Dedup-Marker -> feuert nach dem Fenster nach), in der Entry-Checkliste
nur "warn" statt "fail". Manuelle Orders bleiben frei. Der gemessene
EIA-Blackout (Mi 15:30-16:30) bleibt unabhaengig aktiv und greift auch
ohne Netz.

Verifiziert: Live-Fetch (EIA korrekt Mi 16:30 Berlin, FOMC/GDP/PCE erkannt)
+ 4 synthetische Szenarien (5 min vor/nach Event, 50 min davor, disabled).

MQL5 v1.30: graue Konsolidierungs-Box beschriftet (User-Frage "was bedeutet
die graue Box") - sichtbares Label an der Oberkante, rechtsbuendig wie die
uebrigen: "Konsolidierung (10-Bar-Spanne)" bzw. bei Kompression
"Konsolidierung - komprimiert, Ausbruch steht bevor". 0 Fehler kompiliert.

Frontend v=122: #cal-note in der Meldungen-Karte (laufendes Fenster bzw.
Vorwarnung <=60 min).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 22:58:58 +02:00

163 lines
7.2 KiB
Python
Raw 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/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:3016: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}