diff --git a/CLAUDE.md b/CLAUDE.md index 9e563af..e17eff7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -320,6 +320,30 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: dem alten „16 Uhr"-Befund. eia_post/api_pre/api_post NICHT robust → nicht geblockt. Nur im Live-Pfad (`hour≠None`), Backtests bleiben gate-frei. Best-Practice „Event-Blackout" statt Pauschal-Gate. +- **Wirtschaftskalender-Blackout (`core/econ_calendar.py`, 2026-07-30, `[trading] + news_blackout=true`, ±15 min):** Erweiterung der EIA-Idee auf ALLE High-Impact- + Termine (FOMC/Zinsentscheid/NFP/CPI/GDP/PCE + alle Oil-/OPEC-Titel). Quelle + **Forex Factory JSON** (`nfs.faireconomy.media`, kostenlos, kein Key, mit + `impact`-Feld); geholt werden **beide Wochen** (`thisweek`+`nextweek` — „thisweek" + endet samstags, der Markt öffnet aber Sonntag 23:00 → sonst blinder Fleck), 1×/h + **im Daemon-Thread** (ein hängender HTTP-Call darf den Trend-Loop nicht blockieren), + dedupliziert über (ts, title). Filter: `impact=High` + `country=USD`, ODER Titel + enthält crude oil/opec/oil inventories/petroleum (der EIA-Report läuft bei FF teils + als Low/Medium). ⚠ **NICHT gemessen** — Events sind zu selten für eine + 2-Stichproben-Aussage, das ist die Handbuch-Regel („keine Trades 15 Min. vor + High-Impact-News"), kein belegter Edge. Deshalb bewusst asymmetrisch verdrahtet: + **sperrt nur die AUTONOMEN Entries** (`_check_auto_squeeze` + `_check_auto_signal`, + jeweils OHNE Dedup-Marker → nach dem Fenster feuert das Setup, falls noch aktiv) und + erscheint in der Entry-Checkliste nur als **„warn"**, nicht „fail" (soll den Verdict + nicht allein auf `stop` kippen); **manuelle Orders bleiben frei.** Der gemessene + EIA-Blackout bleibt unabhängig davon aktiv — er greift auch, wenn der Kalender kein + Netz hat (Fail-safe: ohne Daten → kein Blackout, Log 1×, dann still). Snapshot + `calendar {enabled, n, blackout{title,impact,ccy,minutes}, next, age_min}`, + Frontend `#cal-note` (Meldungen-Karte, bernstein: laufendes Fenster bzw. Vorwarnung + ≤60 min). Abschalten `news_blackout=false`; Fenster via `news_blackout_before_min`/ + `_after_min`. Mit 4 synthetischen Szenarien getestet (5 min vor/nach Event, 50 min + davor = kein Blackout, disabled) + Live-Fetch verifiziert (EIA lag korrekt auf + Mi 16:30 Berlin). - **Winkel-vs-EMA-Warnung (nur Konfidenz):** Regressions-Winkel der Basis-TF gegen die EMA-Richtung → −10 „⚠ Winkel gegen EMA (mögliche Wende)" (nachlaufende EMA!); dafür → +5. `backtest_angle.py`: dafür/neutral +0,055 vs dagegen +0,025; als Gate diff --git a/core/econ_calendar.py b/core/econ_calendar.py new file mode 100644 index 0000000..98d9a89 --- /dev/null +++ b/core/econ_calendar.py @@ -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} diff --git a/core/engine.py b/core/engine.py index c673d5c..e73cf70 100644 --- a/core/engine.py +++ b/core/engine.py @@ -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:30–16: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: 0–7 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 diff --git a/mql5/SR_Levels.mq5 b/mql5/SR_Levels.mq5 index fb92aa8..7e0a0ec 100644 --- a/mql5/SR_Levels.mq5 +++ b/mql5/SR_Levels.mq5 @@ -12,7 +12,11 @@ //| 4) Der Bot muss laufen (schreibt die CSV alle ~5 s). | //+------------------------------------------------------------------+ #property copyright "Oil Trading Bot" -#property version "1.29" +#property version "1.30" +// v1.30 (2026-07-30): 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". // v1.29 (2026-07-30): KONSOLIDIERUNGS-BOX (CO;lo;hi;tight) — die Spanne der letzten // 10 M5-Bars, aus der der validierte Squeeze-Ausbruch kommt. Komprimiert // (<=2,5xATR) wird sie deutlicher gezeichnet. ⚠ REINE ANZEIGE: alle @@ -621,11 +625,29 @@ void Redraw() ObjectSetInteger(0, cn, OBJPROP_COLOR, BlendBg(InpConsolColor, tight ? InpConsolAlpha * 2 : InpConsolAlpha)); - ObjectSetString (0, cn, OBJPROP_TOOLTIP, - (tight ? "Konsolidierung (komprimiert - Ausbruch steht bevor)" - : "aktuelle 10-Bar-Spanne") - + " " + DoubleToString(clo, (int)_Digits) + " - " - + DoubleToString(chi, (int)_Digits)); + string ctip = (tight ? "Konsolidierung (komprimiert - Ausbruch steht bevor)" + : "aktuelle 10-Bar-Spanne") + + " " + DoubleToString(clo, (int)_Digits) + " - " + + DoubleToString(chi, (int)_Digits); + ObjectSetString (0, cn, OBJPROP_TOOLTIP, ctip); + // SICHTBARE BESCHRIFTUNG (User-Frage 2026-07-30 „was bedeutet die graue Box"): + // eigenes OBJ_TEXT an der OBERKANTE, rechtsbündig wie alle anderen Labels + // (Name mit PFX+"T" → RepositionLabels() führt es beim Scrollen nach). + string ctn = PFX + "TCONSOL"; + if(ObjectCreate(0, ctn, OBJ_TEXT, 0, RightTime(), chi)) + { + ObjectSetInteger(0, ctn, OBJPROP_COLOR, InpLabelColor); + ObjectSetInteger(0, ctn, OBJPROP_FONTSIZE, InpLabelSize); + ObjectSetInteger(0, ctn, OBJPROP_ANCHOR, ANCHOR_RIGHT_LOWER); + ObjectSetInteger(0, ctn, OBJPROP_BACK, false); + ObjectSetInteger(0, ctn, OBJPROP_SELECTABLE, false); + } + else + ObjectMove(0, ctn, 0, RightTime(), chi); + ObjectSetString(0, ctn, OBJPROP_TEXT, + tight ? "Konsolidierung - komprimiert, Ausbruch steht bevor" + : "Konsolidierung (10-Bar-Spanne)"); + ObjectSetString(0, ctn, OBJPROP_TOOLTIP, ctip); continue; } // AKTUELLER KURS oben rechts, fett (User-Wunsch): PX;bid diff --git a/web/app.js b/web/app.js index 87e04be..989a5cc 100644 --- a/web/app.js +++ b/web/app.js @@ -666,6 +666,22 @@ function render(d) { if (txt) { ncEl.className = "news-conflict warn"; ncEl.textContent = txt; } else { ncEl.className = "news-conflict hidden"; ncEl.textContent = ""; } } + // Wirtschaftskalender (2026-07-30): laufendes News-Fenster bzw. Vorwarnung ≤60 min. + // ⚠ NICHT gemessen — Handbuch-Regel; der Bot pausiert im Fenster nur seine + // AUTONOMEN Entries, manuelle Orders bleiben frei. + const calEl = $("cal-note"); + if (calEl) { + const c = d.calendar || {}; + let t = ""; + if (c.blackout) { + const m = c.blackout.minutes; + t = `📅 News-Fenster: ${c.blackout.title} ${m > 0 ? "in " + Math.round(m) + " min" : "vor " + Math.round(-m) + " min"} — Bot eröffnet gerade keine Auto-Trades`; + } else if (c.next && c.next.minutes <= 60) { + t = `📅 ${c.next.title} in ${c.next.minutes} min`; + } + if (t) { calEl.className = "news-conflict warn"; calEl.textContent = t; } + else { calEl.className = "news-conflict hidden"; calEl.textContent = ""; } + } // Schalter-Zustand: Auto-Close am S/R an/aus + Mindestgewinn const srBtn = $("srclose-btn"); if (srBtn) { diff --git a/web/index.html b/web/index.html index 2129c55..b1a6f5a 100644 --- a/web/index.html +++ b/web/index.html @@ -6,7 +6,7 @@