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:
Axel Hocks
2026-07-30 22:58:58 +02:00
co-authored by Claude Opus 5
parent 7e0d2a4563
commit a6f370ac02
6 changed files with 284 additions and 8 deletions
+162
View File
@@ -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: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}
+51
View File
@@ -173,6 +173,15 @@ class TradingEngine:
url=_t2.get("hl_walls_url", "http://localhost:8001/api/walls"),
enabled=_t2.get("export_hl_walls", "true").lower() == "true")
self.candles = CandleLogger(HISTORY_DB_FILE) # M1-Candle-Log (Daten-Sammlung)
# Wirtschaftskalender (Handbuch-Regel „keine Trades vor High-Impact-News").
# ⚠ NICHT gemessen (Events zu selten für 2-Stichproben) → sperrt nur die
# AUTONOMEN Entries + warnt in der Checkliste, blockt keine manuelle Order.
# Der gemessene EIA-Blackout (Mi 15:3016:30) bleibt unabhängig davon aktiv.
from core.econ_calendar import EconCalendar
self.calendar = EconCalendar(
before_min=int(_t2.get("news_blackout_before_min", "15")),
after_min =int(_t2.get("news_blackout_after_min", "15")),
enabled =_t2.get("news_blackout", "true").lower() == "true")
# News: eigener Fetch-Loop (versorgt UI /api/news UND Agent-Kontext)
tcfg = self.cfg["translation"]
translator = NewsTranslator(
@@ -265,6 +274,7 @@ class TradingEngine:
self._sr_close_dir = None # → Cooldown gegen Gleichrichtungs-Roundtrip
self._open_pos_dir = None # Richtung der aktuell offenen Position (LONG/SHORT)
self._pos_close_ts = 0.0 # Zeit + Richtung des letzten Positions-Close (JEDE
self._news_block_logged = None # Dedup für die News-Blackout-Logzeile
self._pos_close_dir = None # Art) → Squeeze-Wiedereinstiegs-Sperre (gegen die
# Open→Instant-Close-Kaskade bei zu engem Notfall-Stop)
self._sr_close_min_gain_armed_ticket = None # Ticket, für das die %-Schwelle schon
@@ -533,6 +543,10 @@ class TradingEngine:
except Exception as ce: log.warning(f"candles.log: {ce}")
try: self._evaluate_pbreak_predictions() # ~alle 5 min, self-throttled
except Exception as pe: log.warning(f"pbreak_predictions: {pe}")
# Wirtschaftskalender (self-throttled ~1 h, außerhalb des mt5_lock
# unkritisch — reiner HTTP-Fetch mit Timeout)
try: self.calendar.maybe_refresh()
except Exception as ke: log.warning(f"calendar.refresh: {ke}")
self._run_analysis()
except Exception as e:
log.error(f"_trend_loop: {e}", exc_info=True)
@@ -944,6 +958,18 @@ class TradingEngine:
# aktiv. `auto_squeeze_skip_night=false` schaltet aus.
if self._squeeze_skip_night and datetime.now(_BERLIN).hour in _SQUEEZE_NIGHT:
return
# ── News-Blackout (Handbuch-Regel, NICHT gemessen): 15 min um ein
# High-Impact-Event (FOMC/NFP/CPI/EIA/OPEC) kein autonomer Entry. Setzt
# KEINEN Dedup-Marker → nach dem Fenster feuert der Ausbruch, falls noch
# aktiv. Gilt nur für den Bot; manuelle Orders bleiben frei.
_bl = self.calendar.blackout()
if _bl:
if self._news_block_logged != _bl["title"]:
self._news_block_logged = _bl["title"]
log.info(f"📅 News-Blackout — kein Auto-Entry: {_bl['title']} "
f"({_bl['impact']}, in {_bl['minutes']:.0f} min)")
return
self._news_block_logged = None
ps = self.trader.snapshot()
ticket = ps.get("ticket")
@@ -1061,6 +1087,9 @@ class TradingEngine:
# damit das Signal nach 08:00 noch feuern kann, falls es dann noch steht.
if self._auto_signal_skip_night and datetime.now(_BERLIN).hour in _SQUEEZE_NIGHT:
return
# News-Blackout (s. `_check_auto_squeeze`) — kein Dedup-Marker.
if self.calendar.blackout():
return
ps = self.trader.snapshot()
if ps.get("ticket"):
# Position offen: läuft sie in Signalrichtung, gilt die Episode als
@@ -2391,6 +2420,25 @@ class TradingEngine:
"gemessen: 07 Uhr = Kostenfalle (Spread ÷ dünner ATR)")
except Exception:
pass
# ── 8. Wirtschaftskalender (Handbuch: „keine Trades vor High-Impact-News")
# ⚠ NICHT gemessen (zu wenige Events für eine 2-Stichproben-Aussage) —
# deshalb hier nur „warn", nicht „fail": es soll den Verdict nicht allein
# auf `stop` kippen. Der Bot selbst pausiert im Fenster (autonome Entries).
try:
bl = self.calendar.blackout()
if bl:
m = bl["minutes"]
when = (f"in {m:.0f} min" if m > 0 else f"vor {abs(m):.0f} min")
add("event", "warn", f"News-Fenster: {bl['title']} ({when})",
"Handbuch-Regel: 15 min um High-Impact-Events nicht handeln "
"(im Projekt nicht gegengemessen)")
else:
nx = self.calendar.next_event()
if nx and nx["minutes"] <= 90:
add("event", "ok",
f"Nächstes Event: {nx['title']} in {nx['minutes']} min", "")
except Exception:
pass
fails = sum(1 for x in items if x["state"] == "fail")
warns = sum(1 for x in items if x["state"] == "warn")
return {"side": want, "items": items, "fails": fails, "warns": warns,
@@ -2689,6 +2737,9 @@ class TradingEngine:
"adverse15_atr": self._adverse15_atr,
"adverse15_count": self._adverse15_count,
"last_trades": last_trades,
# Wirtschaftskalender: {blackout, next, enabled, n} — Anzeige + Grund,
# warum der Bot gerade keine autonome Order eröffnet.
"calendar": self.calendar.snapshot(),
}
# ── Entry-Checkliste je Richtung (User-Vorgabe 2026-07-30: „blende einen
# Hinweis ein, wenn die Checkliste gegen meine Eröffnung ist"). Wird im