diff --git a/CLAUDE.md b/CLAUDE.md index dc85903..b80ebe1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1945,6 +1945,52 @@ Aktivierung genullt (kein Altwert-Fehlalarm); Erkennung erst ab dem 2. Modify. T v=108. `GET /api/snapshot` ist ohnehin öffentlich/token-frei nutzbar — externe Tools (Shortcuts, Home Assistant, …) können ihn direkt pollen. +## ⚠ Deployment-Drift — die strukturelle Schwachstelle (2026-07-31) + +**Track B schützt gegen OVERFITTING** (ein Edge existiert gar nicht). Es schützt +**nicht** gegen **DEPLOYMENT-DRIFT**: ein real existierender Edge wird unter anderen +Bedingungen BETRIEBEN als VALIDIERT. An EINEM Tag wurden drei Fälle gefunden: + +| # | Komponente | Gemessen unter | Betrieben unter | Schaden | +|---|---|---|---|---| +| 1 | P(break) | Anlauf zu **fixiertem** Level | **dynamisch** gewähltes Level | AUC 0,715 → **0,368** | +| 2 | `breakout_k=0,3` | **feste** Zeitebene | TF wechselt 33×/Tag, löscht den Anker | 43 % → **93 % WARTEN** | +| 3 | Trailing `mult` | **1,5** | **1,5–3,0** je nach TF | H1 ΣR −249 → **−1367**, Worst −1,50 → −2,50 | + +Keiner wäre durch MEHR Backtesting gefunden worden — die Backtests waren korrekt. +**Ursachen (strukturell):** (a) Backtests sind NACHBAUTEN, keine Nutzer des Live-Codes +(`sim_run`/`_ema_series`/`_atr_series` liegen in ≥6 Skripten als Kopie); (b) es gibt +**Live-only-Gates**, die kein Backtest modelliert (Entry-Raum, EIA, Dead-Hours, +News-Blackout, Cooldowns, Startup-Grace); (c) Live-Parameter sind **kontextabhängig** +(Trail-mult je TF, SL-ATR fix M15, TF selbst per Heuristik), Backtest-Parameter +konstant; (d) es gab **keine Telemetrie über die Entscheidung selbst**. + +**Behebung in 3 Stufen (User-Entscheidung 2026-07-31):** +- **Stufe 1 — ERLEDIGT:** `_MULT_BY_TF` auf durchgehend **1,5** und + `_MULT_BREAKOUT_ADD` auf **0,0** (`core/trailing.py`) — der einzige gemessene Wert. +- **Stufe 2 — ERLEDIGT:** **Entscheidungs-Telemetrie.** (a) Jede Empfehlung loggt + jetzt einen maschinenlesbaren **`block_reason`** (`recommendations.block_reason`, + DB migriert mit Backup): `deadband · dead_hour · eia · htf_counter · stretch · + min_conf · breakout_pending · entry_room · no_data · stale`; NULL = kein Block. + Ohne den war nicht feststellbar, WELCHES Gate die 93 % erzeugt. (b) **`analyze_ + divergence.py`** hält vier Dinge gegen die Backtest-Erwartung: **A** Signal-Verteilung + (Soll ~43 % WARTEN), **B** Gate-Anteile inkl. Kennzeichnung „im Backtest modelliert?", + **C** TF-Wechsel-Rate (Soll: Median ≥15 min), **D0** Modell-Kalibrierung + (Ø-Vorhersage vs. echte Rate) und **D** Merkmals-Drift gegen `_PB_MU/_SD`. + ⚠ **Lehre aus dem Bau selbst:** die Merkmals-Drift (D) hätte den realen Fehler + **NICHT** gefangen — mom3 lag nur **0,50σ** unter µ, bei Gewicht 1,43 wurden daraus + aber −0,71 im Logit (23 % statt 41 %). Deshalb ist **D0 der schärfere Test** und die + D-Schwelle auf 0,5σ gesetzt. Erster Lauf nach dem Fix: D0 zeigt **27,0 % vorhergesagt + vs. 28,0 % real** (vorher 23 vs 41) — die Reparatur ist damit live bestätigt. +- **Stufe 3 — OFFEN (später, einzeln):** geteilter Kern. Den Exit-Simulator EINMAL in + `core/exit_model.py` extrahieren und von `trailing.py` UND allen Backtests nutzen + lassen — Fall 3 wäre damit unmöglich gewesen. Gegenprüfung: bestehende Backtests + müssen danach dieselben Zahlen liefern. + +⚠ **Erwartung: die drei Fälle sind nicht vollständig.** Sie wurden bei gezielter Suche +in ~20 min gefunden; die Trefferquote spricht für weitere. `analyze_divergence.py` +regelmäßig laufen lassen (Kandidat für den Wochenreport). + ## Ausstehende Messungen — Erinnerung per Timer `measurement_reminder.py` + Windows-Task **`OilMeasurementReminder`** (täglich 18:00) diff --git a/analyze_divergence.py b/analyze_divergence.py new file mode 100644 index 0000000..3ec28a3 --- /dev/null +++ b/analyze_divergence.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Divergenz-Wächter: läuft der Bot unter den Bedingungen, unter denen gemessen wurde? + +Stufe 2 der Deployment-Drift-Behebung (2026-07-31). + +WARUM ES DAS GIBT: An EINEM Tag wurden drei Fälle gefunden, in denen eine Komponente +unter anderen Bedingungen BETRIEBEN als VALIDIERT wurde: + 1. P(break) — trainiert auf Anlauf zu FIXIERTEM Level, live dynamisch gewähltes + Level → AUC 0,715 fiel auf 0,368. + 2. `breakout_k=0,3` — validiert auf FESTER Zeitebene, live wechselt sie 33×/Tag + und löschte dabei den Bestätigungs-Anker → 93 % WARTEN statt ~43 %. + 3. Trailing-`mult` — validiert bei 1,5, live 1,5–3,0 je nach TF → H1 ΣR −249 + statt −1367, Worst −1,50 statt −2,50. +Keiner davon wäre durch MEHR Backtesting gefunden worden — die Backtests waren +korrekt. Gefehlt hat die Kontrolle, ob der Betrieb ihren Annahmen entspricht. + +Track B schützt gegen OVERFITTING (Edge existiert gar nicht). Dieses Skript schützt +gegen DEPLOYMENT-DRIFT (Edge existiert, läuft aber unter anderen Bedingungen). + +GEPRÜFT WIRD: + A) Signal-Verteilung live vs. Backtest-Erwartung (`backtest_dist.py`: ~43 % WARTEN) + B) WELCHES Gate blockt — Anteile je `block_reason` (seit 2026-07-31 geloggt) + C) TF-Wechsel-Rate (die Bedingung, unter der `breakout_k` validiert wurde, ist + „feste TF" — je mehr Wechsel, desto weiter weg) + D) Merkmals-Drift der eingebetteten Modelle: Live-Mittel gegen `_PB_MU`/`_PB_SD` + +Aufruf: python analyze_divergence.py [tage] (Default 7) +""" +import re +import sqlite3 +import sys +from datetime import datetime, timedelta + +DB = "oil_widget_history.db" +LOG = "oil_widget.log" + +# Erwartungswerte aus den Backtests — die Messlatte +_EXP_WAIT = 43.0 # backtest_dist.py: ~57 % LONG/SHORT, ~43 % WARTEN +_EXP_WAIT_TOL = 15.0 # ab dieser Abweichung wird geflaggt (Pp) + + +def _flag(ok: bool) -> str: + return "OK " if ok else "⚠ " + + +def sec_a(c, t0): + print("\n" + "=" * 84) + print(" A) SIGNAL-VERTEILUNG — läuft der Bot so oft wie gemessen?") + print("=" * 84) + c.execute("SELECT signal, COUNT(*) FROM recommendations WHERE timestamp>=? " + "GROUP BY signal", (t0,)) + d = dict(c.fetchall()); tot = sum(d.values()) + if not tot: + print(" keine Daten"); return + w = 100.0 * d.get("WARTEN", 0) / tot + dev = w - _EXP_WAIT + print(f" n={tot} WARTEN {w:.1f} % LONG {100*d.get('LONG',0)/tot:.1f} % " + f"SHORT {100*d.get('SHORT',0)/tot:.1f} %") + print(f" Backtest-Erwartung: {_EXP_WAIT:.0f} % WARTEN → Abweichung {dev:+.1f} Pp " + f"{_flag(abs(dev) <= _EXP_WAIT_TOL)}") + if dev > _EXP_WAIT_TOL: + print(" ⚠ Der Bot wartet deutlich öfter als gemessen — ein Live-Gate blockt") + print(" mehr, als der Backtest kennt. Siehe Abschnitt B.") + + +def sec_b(c, t0): + print("\n" + "=" * 84) + print(" B) WELCHES GATE BLOCKT? (block_reason, seit 2026-07-31)") + print("=" * 84) + try: + c.execute("SELECT block_reason, COUNT(*) FROM recommendations " + "WHERE timestamp>=? AND signal='WARTEN' GROUP BY block_reason " + "ORDER BY COUNT(*) DESC", (t0,)) + rows = c.fetchall() + except sqlite3.OperationalError: + print(" Spalte block_reason fehlt (ältere DB)"); return + tot = sum(n for _, n in rows) + if not tot: + print(" noch keine Daten — das Logging läuft erst seit dem Neustart am 31.07.") + return + # Welche Gates kennt der Backtest? (nur diese dürfen den Vergleich prägen) + im_backtest = {"deadband": True, "htf_counter": True, "stretch": True, + "min_conf": True, "breakout_pending": True, + "entry_room": False, "dead_hour": False, "eia": False, + "no_data": False, "stale": False, None: None} + print(f" {'Gate':<20}{'n':>8}{'Anteil':>9} im Backtest modelliert?") + for br, n in rows: + mb = im_backtest.get(br) + mark = "ja" if mb else ("NEIN — live-only" if mb is False else "—") + print(f" {str(br):<20}{n:>8}{100*n/tot:>8.1f} % {mark}") + live_only = sum(n for br, n in rows if im_backtest.get(br) is False) + print(f"\n Nur-Live-Gates blocken {100*live_only/tot:.1f} % aller WARTEN-Fälle.") + print(" ⚠ Dieser Anteil ist per Definition NICHT durch einen Backtest gedeckt.") + + +def sec_c(t0): + print("\n" + "=" * 84) + print(" C) TF-WECHSEL — `breakout_k` wurde auf FESTER Zeitebene validiert") + print("=" * 84) + try: + sw = [] + for ln in open(LOG, encoding="utf-8", errors="ignore"): + m = re.search(r"^(\S+ \S+).*Wellen-Timeframe → (\w+)", ln) + if m: + try: + t = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S") + except ValueError: + continue + if t.timestamp() >= t0: + sw.append((t, m.group(2))) + except FileNotFoundError: + print(" kein Log gefunden"); return + if not sw: + print(" keine TF-Wechsel im Zeitraum OK"); return + days = max(1.0, (datetime.now().timestamp() - t0) / 86400) + gaps = sorted((sw[i][0] - sw[i - 1][0]).total_seconds() for i in range(1, len(sw))) + med = gaps[len(gaps) // 2] / 60 if gaps else 0 + print(f" {len(sw)} Wechsel in {days:.1f} Tagen = {len(sw)/days:.1f}/Tag") + if gaps: + print(f" Median-Abstand {med:.0f} min {_flag(med >= 15)}") + if med < 15: + print(" ⚠ Wechselt schneller, als ein Signal seine Breakout-Bestätigung") + print(" abschließen kann → prüfe `tf_min_dwell_s`.") + + +def sec_d0(c, t0): + """Der SCHÄRFSTE Test: sagt das Modell im Mittel das voraus, was eintritt? + + ⚠ Diese Prüfung ersetzt NICHT die Merkmals-Drift unten, sie ist ihr überlegen — + und das ist eine Lehre aus dem Bau dieses Skripts: mit den ALTEN Modellwerten + lag `mom3` nur **0,50σ** unter µ und wäre an einer 1σ-Schwelle NICHT + aufgefallen. Entscheidend ist nicht die Abweichung des Merkmals, sondern ihre + WIRKUNG nach Gewichtung (mom3 hatte Gewicht 1,43 → 0,50σ wurden zu −0,71 im + Logit). Der Vergleich „Ø-Vorhersage vs. echte Rate" fängt das direkt ab: er + hätte 23 % vs 41 % sofort gezeigt. + """ + print("\n" + "=" * 84) + print(" D0) MODELL-KALIBRIERUNG — sagt es voraus, was eintritt?") + print("=" * 84) + c.execute("""SELECT AVG(p_break), AVG(CASE WHEN outcome='break' THEN 100.0 ELSE 0 END), + COUNT(*) FROM pbreak_predictions + WHERE ts>=? AND outcome IS NOT NULL""", (t0,)) + pm, rm, n = c.fetchone() + if not n or n < 30: + print(f" nur {n or 0} ausgewertete Vorhersagen — zu wenig"); return + gap = (rm or 0) - (pm or 0) + print(f" n={n} Ø-Vorhersage {pm:.1f} % echte Rate {rm:.1f} % " + f"Δ {gap:+.1f} Pp {_flag(abs(gap) <= 8)}") + if abs(gap) > 8: + print(" ⚠ Das Modell ist systematisch verschoben — es bewertet live eine") + print(" andere Situation als beim Training (Deployment-Drift).") + + +def sec_d(c, t0): + print("\n" + "=" * 84) + print(" D) MERKMALS-DRIFT der eingebetteten Modelle") + print("=" * 84) + try: + from core.engine import _PB_MU, _PB_SD + except Exception as e: + print(f" Modell nicht ladbar: {e}"); return + # Live-Merkmale liegen (noch) nicht in der DB → aus candles_m1 rekonstruieren + c.execute("SELECT MAX(time) FROM candles_m1") + last = c.fetchone()[0] + if not last: + print(" keine M1-Kerzen — Rekonstruktion nicht möglich"); return + import time as _t + off = round((last - _t.time()) / 3600) * 3600 + c.execute("""SELECT ts,direction,level,price_at_pred,atr FROM pbreak_predictions + WHERE ts>=? AND atr IS NOT NULL""", (t0,)) + rows = c.fetchall() + if len(rows) < 30: + print(f" nur {len(rows)} Vorhersagen im Zeitraum — zu wenig"); return + + def close_at(ts): + c.execute("SELECT c FROM candles_m1 WHERE time<=? ORDER BY time DESC LIMIT 1", (ts,)) + r = c.fetchone(); return r[0] if r else None + + m3, m6 = [], [] + for ts, d, lvl, px, atr in rows: + b = ts + off + c0, c3, c6 = close_at(b), close_at(b - 15 * 60), close_at(b - 30 * 60) + if None in (c0, c3, c6): + continue + dd = 1 if d == "LONG" else -1 + m3.append((c0 - c3) * dd / atr); m6.append((c0 - c6) * dd / atr) + if len(m3) < 30: + print(" zu wenige rekonstruierbare Merkmale"); return + print(f" n={len(m3)} (P(break)-Modell, Merkmale aus candles_m1 rekonstruiert)") + print(f" {'Merkmal':<8}{'Live-µ':>10}{'Modell-µ':>11}{'Δ in SD':>10}") + for nm, v, k in (("mom6", m6, 0), ("mom3", m3, 1)): + mean = sum(v) / len(v) + dsd = (mean - _PB_MU[k]) / _PB_SD[k] + print(f" {nm:<8}{mean:>10.3f}{_PB_MU[k]:>11.3f}{dsd:>9.2f}σ {_flag(abs(dsd) <= 0.5)}") + print("\n ⚠ Schwelle bewusst 0,5σ (nicht 1σ): der reale P(break)-Fehler lag bei") + print(" mom3 nur 0,50σ unter µ — bei Gewicht 1,43 wurden daraus aber −0,71 im") + print(" Logit, was 23 % statt 41 % vorhersagte. Merkmals-Drift allein") + print(" unterschätzt den Schaden; D0 (Kalibrierung) ist der schärfere Test.") + + +def main(): + days = int(sys.argv[1]) if len(sys.argv) > 1 else 7 + t0 = (datetime.now() - timedelta(days=days)).timestamp() + db = sqlite3.connect(DB); c = db.cursor() + print("=" * 84) + print(f" DIVERGENZ-WÄCHTER — läuft der Bot unter den validierten Bedingungen?") + print(f" Zeitraum: letzte {days} Tage") + print("=" * 84) + sec_a(c, t0) + sec_b(c, t0) + sec_c(t0) + sec_d0(c, t0) + sec_d(c, t0) + db.close() + print("\n" + "=" * 84) + print(" Track B schützt gegen Overfitting. DIESER Report schützt gegen") + print(" Deployment-Drift: gemessen unter X, betrieben unter Y.") + print("=" * 84) + + +if __name__ == "__main__": + main() diff --git a/core/engine.py b/core/engine.py index a3bfd7e..a5d6ba2 100644 --- a/core/engine.py +++ b/core/engine.py @@ -1366,7 +1366,11 @@ class TradingEngine: reversal=("rev" if "REV" in (rec.get("setup") or "") else None), ai_sentiment=adv.get("bias"), ai_confidence=adv.get("confidence"), setup=rec.get("setup"), regime=None, rsi=s.get("rsi_m15"), - news_score=_news) + news_score=_news, + # Stufe-2-Telemetrie (2026-07-31): WELCHES Gate blockt gerade? + # Ohne das war live nicht feststellbar, warum der Bot zu 93 % + # WARTEN sagt (Backtest-Erwartung ~43 %). + block=rec.get("block")) except Exception as e: log.warning(f"log_recommendation: {e}") diff --git a/core/history.py b/core/history.py index ea24e0d..c353553 100644 --- a/core/history.py +++ b/core/history.py @@ -85,7 +85,13 @@ SCHEMA = [ setup TEXT, regime TEXT, rsi REAL, - news_score REAL + news_score REAL, + -- Maschinenlesbarer Grund für WARTEN (Stufe-2-Telemetrie 2026-07-31): + -- deadband · dead_hour · eia · htf_counter · stretch · min_conf · + -- breakout_pending · entry_room · no_data · stale. NULL = kein Block. + -- Ohne den war live nicht feststellbar, WELCHES Gate die 93 % WARTEN + -- erzeugt (Backtest-Erwartung ~43 %). + block_reason TEXT ) """, """ @@ -340,7 +346,8 @@ class HistoryLogger: setup: str | None = None, regime: str | None = None, rsi: float | None = None, - news_score: float | None = None): + news_score: float | None = None, + block: str | None = None): """ Empfehlungs-Logging mit Throttling: nur jede N Sekunden, um die DB nicht mit identischen Snapshots zu fluten. @@ -355,13 +362,13 @@ class HistoryLogger: (timestamp, signal, score, conf_pct, angle_m5, angle_m15, angle_m30, angle_h1, reversal, ai_sentiment, ai_confidence, - setup, regime, rsi, news_score) - VALUES (?,?,?,?, ?,?,?,?, ?,?,?, ?,?,?,?) + setup, regime, rsi, news_score, block_reason) + VALUES (?,?,?,?, ?,?,?,?, ?,?,?, ?,?,?,?,?) """, (now, signal, score, conf_pct, angles.get("M5"), angles.get("M15"), angles.get("M30"), angles.get("H1"), reversal, ai_sentiment, ai_confidence, - setup, regime, rsi, news_score)) + setup, regime, rsi, news_score, block)) conn.commit() def log_signal(self, *, signal_type: str, direction: str, diff --git a/core/trailing.py b/core/trailing.py index 8999c41..c2c0ef8 100644 --- a/core/trailing.py +++ b/core/trailing.py @@ -107,16 +107,31 @@ _TF_LABELS = { mt5.TIMEFRAME_H1: "H1", } -# Trail-Multiplikator je Timeframe: niedrige TF (Scalp) → enger Stop, -# hohe TF (Trend) → mehr Puffer. Ersetzt die alte 2.5–4.5-Logik. +# Trail-Multiplikator je Timeframe. +# ⚠⚠ ANGEGLICHEN AUF DEN GEMESSENEN WERT 1,5 (2026-07-31, Stufe 1 der +# Deployment-Drift-Behebung). VORHER: M15 2,0 · M30 2,5 · H1 3,0 (+0,5 bei +# Breakouts) — diese Staffelung war **nie gemessen**. `backtest_trailing.py` +# validiert ausschließlich 1,5, und die breiteren Werte sind dort MESSBAR +# SCHLECHTER (80k Bars, 2 Halbjahre, Signal Trend+Reversal): +# mult 1,5 (start 0,3): H1 ΣR −249 · H2 +1252 · Worst −1,50 +# mult 2,0 (start 0,3): H1 ΣR −412 · H2 +1187 · Worst −2,00 +# mult 2,5 (start 0,3): H1 ΣR −1367 · H2 +1450 · Worst −2,50 +# Auf M30 fuhr der Bot also 5,5× schlechteres H1 und einen 67 % größeren +# Einzelverlust; mit `_MULT_BREAKOUT_ADD` waren es bei Breakouts sogar 3,0 — +# ein Wert, den nie jemand getestet hat. Real relevant, weil die TF-Heuristik +# häufig auf M15/M30 landet (31.07.: 19 von 33 Phasen). +# ⚠ Falls die Staffelung je zurück soll: erst `backtest_trailing.py` auf +# M15-/M30-BASIS-Signalen laufen lassen — bisher misst er nur M5. _MULT_BY_TF = { mt5.TIMEFRAME_M1: 1.5, mt5.TIMEFRAME_M5: 1.5, - mt5.TIMEFRAME_M15: 2.0, - mt5.TIMEFRAME_M30: 2.5, - mt5.TIMEFRAME_H1: 3.0, + mt5.TIMEFRAME_M15: 1.5, + mt5.TIMEFRAME_M30: 1.5, + mt5.TIMEFRAME_H1: 1.5, } -_MULT_BREAKOUT_ADD = 0.5 # Breakouts brauchen etwas mehr Luft (gedeckelt) +# 0,0 statt 0,5: der Aufschlag hob Breakout-Trades auf mind. 2,0 — gemessen +# schlechter als 1,5 (s. o.) und für Breakouts nie separat validiert. +_MULT_BREAKOUT_ADD = 0.0 _PHASE_RANK = {"Init": 0, "Trail": 1, "Lock": 2} diff --git a/core/wave_rec.py b/core/wave_rec.py index e82c44f..07845a7 100644 --- a/core/wave_rec.py +++ b/core/wave_rec.py @@ -564,7 +564,7 @@ class WaveRecommender: snap["breakout"] = {"pending": True, "dir": sig, "level": round(p["level"], 3), "need": round(need, 3)} return {"signal": "WARTEN", "conf_pct": 0, "score": 0.0, "setup": "WAVE", - "regime": None, "rsi": None, + "regime": None, "rsi": None, "block": "breakout_pending", "reasons": [f"warte auf Breakout (+{k:.1f}×ATR {sig}, noch {need:.2f})"]} def _room_gate(self, rec, cur): @@ -594,7 +594,7 @@ class WaveRecommender: if dist >= x: return rec return {"signal": "WARTEN", "conf_pct": 0, "score": 0.0, "setup": "WAVE", - "regime": None, "rsi": None, + "regime": None, "rsi": None, "block": "entry_room", "reasons": [f"kein Raum: {'Widerstand' if d > 0 else 'Support'} " f"{lvl:.2f} nur {dist:.2f}×ATR entfernt (Gate {x:.1f}) — " f"Ertrag gedeckelt, Kosten fressen den Edge"]} @@ -673,11 +673,16 @@ class WaveRecommender: "dir": "LONG" if stretch < 0 else "SHORT"} else: snap["bounce"] = {"state": None, "dir": None} - wait = {"signal": "WARTEN", "conf_pct": 0, "score": 0.0, + # `block` = maschinenlesbarer Grund, WARUM WARTEN (Stufe-2-Telemetrie + # 2026-07-31). Die `reasons` sind für Menschen, `block` fuer die Statistik: + # ohne ihn war live nicht feststellbar, welches der Gates die 93 % WARTEN + # erzeugt (Backtest-Erwartung ~43 %). + wait = {"signal": "WARTEN", "conf_pct": 0, "score": 0.0, "block": None, "setup": "WAVE", "regime": None, "rsi": None, "reasons": []} # Totband: kein klarer Trend → kein Trade (Chop) if abs(sep) < _TREND_DEADBAND: + wait["block"] = "deadband" wait["reasons"] = [f"kein klarer Trend ({tf_lbl}, EMA-Abstand {sep:+.2f}×ATR)"] return wait, snap @@ -685,6 +690,7 @@ class WaveRecommender: # Kostenfalle + 12/16 Uhr. Leer = AUS, User-Vorgabe 2026-07-22 trotz Messung). if hour is not None and hour in self._dead_hours: why = "Nacht-Spread frisst den Edge" if hour <= 7 else "gemessen negativer Edge" + wait["block"] = "dead_hour" wait["reasons"] = [f"Zeit-Gate {hour}:00 Uhr — {why}, kein Trade"] return wait, snap @@ -695,6 +701,7 @@ class WaveRecommender: if hour is not None: _now_b = datetime.now(_BERLIN) if _now_b.weekday() == 2 and (15, 30) <= (_now_b.hour, _now_b.minute) < (16, 30): + wait["block"] = "eia" wait["reasons"] = ["EIA-Blackout Mi 15:30–16:30 — Lagerdaten-Vorlauf, " "gemessen negativ, kein Trade"] return wait, snap @@ -721,15 +728,18 @@ class WaveRecommender: # Higher-TF-Gegen-Trend-Filter: kein Short im M30-Aufwärtstrend (und # umgekehrt). Per Backtest belegt (Ø-Edge ×2). Nur reguläre Trendsignale. if htf_trend != 0 and htf_trend != d_sig: + wait["block"] = "htf_counter" wait["reasons"] = [ f"gegen {_HTF_LABEL}-Trend " f"({'auf' if htf_trend > 0 else 'ab'}) — kein Gegen-Trade"] return wait, snap # Anti-Überdehnung: nicht weit weg von der EMA hinterherkaufen/-shorten if sig == "LONG" and stretch > _STRETCH_MAX: + wait["block"] = "stretch" wait["reasons"] = [f"überdehnt: {stretch:+.1f}×ATR über EMA — kein Spät-Long"] return wait, snap if sig == "SHORT" and stretch < -_STRETCH_MAX: + wait["block"] = "stretch" wait["reasons"] = [f"überdehnt: {stretch:+.1f}×ATR unter EMA — kein Spät-Short"] return wait, snap @@ -830,6 +840,7 @@ class WaveRecommender: # Mindest-Konfidenz-Gate: schwache Setups (zu viele Strafen gestapelt) # tragen negativen Edge (gemessen Band 40–54 %) → kein Trade. if conf < _MIN_CONF: + wait["block"] = "min_conf" wait["reasons"] = [f"Konfidenz {conf}% < {_MIN_CONF}% — Setup zu schwach"] + reasons[:2] return wait, snap @@ -848,11 +859,11 @@ class WaveRecommender: rec, ts, err = self._rec, self._ts, self._error if rec is None: return {"signal": "WARTEN", "conf_pct": 0, "score": 0.0, - "setup": "WAVE", "regime": None, "rsi": None, + "setup": "WAVE", "regime": None, "rsi": None, "block": "no_data", "reasons": [err or "keine Wellen-Daten"]} if not ts or time.time() - ts > _STALE_S: return {"signal": "WARTEN", "conf_pct": 0, "score": 0.0, - "setup": "WAVE", "regime": None, "rsi": None, + "setup": "WAVE", "regime": None, "rsi": None, "block": "stale", "reasons": ["Wellen-Daten veraltet"]} return dict(rec)