#!/usr/bin/env python3 """Signal-Verteilung mit dem VOLLEN Live-Gate-Stack (2026-08-05). WARUM ES DAS BRAUCHT — der 43-%-Maßstab ist ungültig: `backtest_dist.py` liefert „~57 % gerichtet / ~43 % WARTEN". Diese Zahl kommt aber aus **`_build` ALLEIN** — ohne **Entry-Raum-Gate** und ohne **Breakout-Bestätigung**. Genau die beiden erzeugen live **62 % aller Blocks** (seit 01.08., echte Marktzeit: entry_room 35,3 % · breakout_pending 26,5 %). Der Vergleich „live 93 % WARTEN gegen erwartete 43 %" stellt also zwei verschiedene Systeme gegenüber — und das dokumentierte Erfolgskriterium des TF-Churn-Fixes ist damit per Konstruktion unerreichbar. Dieses Skript stellt den fehlenden Maßstab her: dieselbe Verteilung, aber über den Stack, der live wirklich läuft (`_build` → `_confirm_breakout` → `_room_gate`). ⚠ ABGRENZUNG: gezählt wird **je BAR**, live je Snapshot (~1×/s). Für den ANTEIL ist das vergleichbar; für absolute Häufigkeiten nicht. ⚠ NICHT modelliert (live-only, ohne Bar-Entsprechung): EIA-Fenster, News-Blackout, Cooldowns, Startup-Schonfrist, `stale`. Deren Anteil ist live klein bzw. wird beim Vergleich herausgerechnet. Aufruf: python backtest_dist_gated.py [bars] """ import sys from bisect import bisect_left, bisect_right from collections import Counter import MetaTrader5 as mt5 from core.analysis import calc_trend_angle from core.wave_rec import (WaveRecommender, _EMA_FAST, _EMA_SLOW, _N_BARS, _ANGLE_LR, _ANGLE_DEAD, _HTF_DEADBAND) _ATRMIN = 0.06 _TIMEOUT_BARS = 12 _PIV_K, _PIV_LOOK = 3, 300 _ATR_FLOOR_PB = 0.12 _K = 0.3 # Live-Referenz (seit 01.08., echte Marktzeit, `stale` herausgerechnet) — aus der # `recommendations`-Tabelle; s. Roast-Analyse 2026-08-05. _LIVE = {"(gerichtet)": 7.2, "entry_room": 35.3, "breakout_pending": 26.5, "min_conf": 16.3, "htf_counter": 6.7, "stretch": 6.4, "deadband": 1.6} def _ema_series(v, p): k = 2 / (p + 1); out = [v[0]] for x in v[1:]: out.append(out[-1] + k * (x - out[-1])) return out def _atr_series(H, L, C, p=14): tr = [0.0] for i in range(1, len(C)): tr.append(max(H[i] - L[i], abs(H[i] - C[i - 1]), abs(L[i] - C[i - 1]))) out = [None] for i in range(1, len(C)): seg = tr[max(1, i - p + 1):i + 1] out.append(sum(seg) / len(seg)) return out def _htf_sign(C, i, n, atr): if i < n * _EMA_SLOW: return 0 seg = C[i - n * _EMA_SLOW:i + 1:n] if len(seg) < _EMA_SLOW + 2: return 0 ef = _ema_series(seg, _EMA_FAST)[-1]; es = _ema_series(seg, _EMA_SLOW)[-1] dead = _HTF_DEADBAND * atr * (n ** 0.5) return 1 if ef - es > dead else (-1 if es - ef > dead else 0) def verteilung(w, H, L, C, A, EF, ES, AD, HTF, H1, PH, PL, lo, hi, mit_gates): """→ Counter über die Block-Gründe. `mit_gates=False` = nur `_build` (die alte, ungültige Referenz), True = voller Live-Stack.""" z = Counter() pend = None for i in range(max(lo, _PIV_LOOK + _EMA_SLOW + 5), hi): atr = A[i] if not atr or atr < _ATRMIN: continue w._pb_feats = {"atr": max(atr, _ATR_FLOOR_PB)} # ⚠ bisect statt linearer Filterung (2026-08-07). Die alte Fassung # scannte je Bar die KOMPLETTE Pivot-Liste = O(Bars x Pivots) und war # der Engpass des Projekts (80k-Lauf: ~9 min -> 8,9 s). PH/PL sind # sortiert, die Binaersuche liefert DIESELBE Menge — bewiesen in # tests/test_pivotfenster.py, nicht behauptet. _a1 = bisect_left(PH, i - _PIV_LOOK); _b1 = bisect_right(PH, i - _PIV_K) _a2 = bisect_left(PL, i - _PIV_LOOK); _b2 = bisect_right(PL, i - _PIV_K) w._pb_levels = {"ph": [C[j] for j in PH[_a1:_b1]], "pl": [C[j] for j in PL[_a2:_b2]]} rec, _ = w._build(EF[i], ES[i], C[i], atr, "M5", 5, htf_trend=HTF[i], h1_trend=H1[i], angle=AD[i]) sig = rec.get("signal") if not mit_gates: z["(gerichtet)" if sig in ("LONG", "SHORT") else (rec.get("block") or "(unmarkiert)")] += 1 continue # ── Breakout-Bestätigung (Mechanik 1:1 aus `_confirm_breakout`) ────── if sig in ("LONG", "SHORT"): d = 1 if sig == "LONG" else -1 if pend is None or pend["dir"] != d: pend = {"dir": d, "level": C[i] + d * _K * atr, "invalid": C[i] - d * _K * atr, "confirmed": False, "bar": i} if not pend["confirmed"]: broke = (C[i] >= pend["level"]) if d > 0 else (C[i] <= pend["level"]) against = (C[i] <= pend["invalid"]) if d > 0 else (C[i] >= pend["invalid"]) if broke: pend["confirmed"] = True else: if against or (i - pend["bar"]) > _TIMEOUT_BARS: pend = {"dir": d, "level": C[i] + d * _K * atr, "invalid": C[i] - d * _K * atr, "confirmed": False, "bar": i} z["breakout_pending"] += 1 continue else: pend = None z[rec.get("block") or "(unmarkiert)"] += 1 continue rec2 = w._room_gate(rec, C[i]) if rec2.get("signal") not in ("LONG", "SHORT"): z["entry_room"] += 1 continue z["(gerichtet)"] += 1 return z def zeig(titel, z): ges = sum(z.values()) print(f"\n {titel} (n={ges})") print(f" {'Grund':<20}{'Anteil':>9}{'live':>9}{'Delta':>9}") for g, n in z.most_common(): p = 100.0 * n / max(1, ges) lv = _LIVE.get(g) print(f" {g:<20}{p:>8.1f} %" + (f"{lv:>8.1f} %{p-lv:>+8.1f}" if lv is not None else f"{'—':>9}{'':>9}")) return 100.0 * z["(gerichtet)"] / max(1, ges) def main(): n_bars = int(sys.argv[1]) if len(sys.argv) > 1 else 80000 mt5.initialize() sym = next((c for c in ("SpotCrude", "USOIL", "WTI", "XTIUSD") if mt5.symbol_info(c)), None) bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, n_bars) mt5.shutdown() H = [float(b["high"]) for b in bars]; L = [float(b["low"]) for b in bars] C = [float(b["close"]) for b in bars] EF = _ema_series(C, _EMA_FAST); ES = _ema_series(C, _EMA_SLOW) A = _atr_series(H, L, C) AD = [0.0] * len(C) for i in range(_N_BARS, len(C)): AD[i] = calc_trend_angle(C[i - _N_BARS:i + 1], _ANGLE_LR) - 90.0 if abs(AD[i]) < _ANGLE_DEAD: AD[i] = 0.0 HTF = [_htf_sign(C, i, 6, A[i] or 0.1) for i in range(len(C))] H1S = [_htf_sign(C, i, 12, A[i] or 0.1) for i in range(len(C))] PH = [j for j in range(_PIV_K, len(C) - _PIV_K) if H[j] == max(H[j - _PIV_K:j + _PIV_K + 1])] PL = [j for j in range(_PIV_K, len(C) - _PIV_K) if L[j] == min(L[j - _PIV_K:j + _PIV_K + 1])] w = WaveRecommender(lambda: None) w.set_reversal_enabled(False) # Live-Stand seit 04.08. # ⚠⚠ OHNE DIESE ZEILE IST DAS ENTRY-RAUM-GATE AUS: `_entry_room_atr` # ist im Konstruktor 0.0, und `_room_gate` gibt dann `rec` unveraendert # zurueck — der Aufruf sieht aus wie ein Gate, ist aber ein No-op. # Genau das ist mir am 05.08. in drei Skripten passiert (live blockt das # Gate 35 % aller Empfehlungen, im Backtest 0 %). w.set_entry_room(0.6) # Live-Wert w.set_dead_hours("") # live leer print("=" * 74) print(f" SIGNAL-VERTEILUNG — {sym} M5, {len(C)} Bars") print(" Der gueltige Massstab fuer den WARTEN-Anteil (s. Modulkopf)") print("=" * 74) g_ohne = zeig("A) NUR `_build` — die ALTE, ungueltige Referenz (~57 % erwartet)", verteilung(w, H, L, C, A, EF, ES, AD, HTF, H1S, PH, PL, 0, len(C), False)) g_mit = zeig("B) VOLLER Live-Stack (+ Breakout-Bestaetigung + Entry-Raum)", verteilung(w, H, L, C, A, EF, ES, AD, HTF, H1S, PH, PL, 0, len(C), True)) print("\n" + "=" * 74) print(" ERGEBNIS") print("=" * 74) print(f" gerichtet nur `_build` {g_ohne:>6.1f} % (daher die 43-%-Erwartung)") print(f" gerichtet voller Stack {g_mit:>6.1f} % <- der GUELTIGE Massstab") print(f" gerichtet LIVE (seit 01.08.) {_LIVE['(gerichtet)']:>5.1f} %") d = _LIVE["(gerichtet)"] - g_mit print() if abs(d) <= 3: print(f" => Live liegt {d:+.1f} Pp am Massstab. Der WARTEN-Anteil ist NORMAL,") print(" nicht defekt. Der 43-%-Alarm war ein Vergleichsfehler.") elif d < 0: print(f" => Live liegt {d:+.1f} Pp DARUNTER — es gibt also einen echten Rest,") print(" den die Bar-Simulation nicht erklaert (Cooldowns, EIA, News, TF-Wechsel).") else: print(f" => Live liegt {d:+.1f} Pp DARUEBER — mehr Signale als der Stack hergibt.") print() if __name__ == "__main__": main()