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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7e0d2a4563
commit
a6f370ac02
@@ -0,0 +1,162 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user