diff --git a/CLAUDE.md b/CLAUDE.md index c78c1c1..4c9ef40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1352,6 +1352,66 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: Die Originaldatei bleibt unverändert (ihre Zahlen sind dokumentiert); die live-treue Variante steht als `backtest_auto_signal_norev.py` daneben und sollte künftig die Referenz sein. +- **⚠⚠ AUTO-SIGNAL NEU GEMESSEN 2026-08-05 (`backtest_auto_signal_v3.py`) — ZUM + ERSTEN MAL BESTEHT ER, ABER NUR MIT EINEM ANDEREN EINSTIEGSPREIS.** + Anlass: User-Wunsch nach einer ausführlichen Neumessung „mit den neuen Änderungen". + Geändert seit dem 04.08. waren Nacht-Fenster 0–7→3–6 und — der eigentliche Punkt — + der **Ausführungs-Befund vom selben Tag**: `_confirm_breakout` hat ein LEVEL + (`_pend["level"]` = Anker ± k×ATR), an dem eine ruhende Stop-Order liegen könnte. + ALLE bisherigen SIG-Messungen sind am Bar-Close eingestiegen. + Voller Live-Gate-Stack (echte `_build`/`_confirm_breakout`/`_room_gate`), + sequentiell EIN Slot, Live-Dedup (1× je Episode), kanonischer Exit, Echtkosten, + 80k M5, 2 Halbjahre: + | Konf | **A) Market (Bar-Close)** H1 / H2 | **B) am Bestätigungs-Level** H1 / H2 | + |---|---|---| + | ≥55 | −0,098 / −0,093 | **+0,349 / +0,331** | + | ≥65 | −0,092 / −0,112 | **+0,357 / +0,317** | + | **≥75 (live)** | −0,050 / −0,189 | **+0,380 / +0,272** | + | ≥85 | −0,118 / −0,234 | **+0,351 / +0,279** | + **A ist in ALLEN acht Feldern negativ** (bestätigt die drei Vormessungen). + **B besteht in ALLEN acht** — Trefferquote 39,6 → 53,2 %, PF 0,83 → 2,20, und + **alle vier Nachbar-Schwellen halten mit** (Parameter-Robustheit, an der ORB und + die SMC-Sweeps gescheitert sind). + ⚠ **HÄRTETEST Fill-Slippage** (Konf≥65) — eine Stop-Order füllt zum Markt, der + Idealpreis ist die Obergrenze: +0,05 → **+0,315/+0,249** · +0,10 → +0,273/+0,197 · + **+0,20 → +0,148/+0,090** (noch beidseitig positiv) · **+0,31 → +0,026/−0,031**. + ✅ **Die letzte Zeile ist die interne Kontrolle:** bei Slippage = dem gemessenen + Median-Abstand (0,311×ATR) konvergiert B gegen A — das Modell prüft sich selbst. + Der Kipp-Punkt liegt bei ~0,25–0,30×ATR Fill-Slippage. + **Abstand Bar-Close ↔ Bestätigungs-Level: Median +0,311×ATR** (Ø +0,422, Max + +5,29) — praktisch derselbe Wert wie beim Squeeze live (+0,275). Gleicher + Mechanismus, gleiche Größenordnung. + ⚠ **Nacht-Fenster ist hier nahezu irrelevant:** 3–6 (+0,380/+0,272) · 0–7 + (+0,371/+0,226) · keine Sperre (+0,359/**+0,302**) — alle drei bestehen, ohne + Sperre ist H2 sogar am besten. Deckt sich mit `backtest_cost_gate.py`. + ⚠⚠ **GRENZEN — bewusst NICHTS umgeschaltet:** + (1) **Wo LIVE zwischen A und B liegt, ist für SIG NICHT verifizierbar** — beim + Squeeze gab es 34 echte Entries zum Nachrechnen, hier sind es 4. Die Empfehlung + macht das zwar gegenstandslos (eine ruhende Order IST B per Konstruktion), aber + behauptet ist es damit nicht. + (2) **Variante A ist STRENGER als live:** für die Vergleichbarkeit steigen beide + Varianten nur im Bar der frischen Bestätigung ein (`frisch`); live darf + `_check_auto_signal` auch später feuern. A ist ohnehin negativ, die Folgerung + bleibt — aber A ist nicht exakt das Live-Verhalten. + (3) **Operativ deutlich schwieriger als beim Squeeze:** die Squeeze-Box steht über + viele Bars still, `_pend` dagegen **verankert sich neu**, sobald der Kurs k×ATR + dagegen läuft, nach 12 Bars Timeout ODER bei Richtungswechsel. Eine ruhende Order + müsste ständig nachgezogen werden (Broker-Last, mehr Ablehnungen am + Mindestabstand) — und **wenn sie nicht platzierbar ist, landet man wieder bei A**, + also im gemessen negativen Fall. Wie oft das passiert, ist ungemessen. + (4) `_pend` wird aktuell **nicht im Snapshot veröffentlicht** — das wäre die erste + Bauarbeit. + ⚠⚠ **KONSEQUENZ FÜR DEN LAUFENDEN BETRIEB:** SIG steht seit dem 05.08. 08:13 auf + AN (User-Toggle) und läuft dabei über die **Market-Order — also Variante A, die in + beiden Halbjahren negativ ist.** Solange der Pending-Pfad nicht gebaut ist, ist der + Schalter die gemessen schlechte Variante. + ⚠ **Methodik-Fehler beim Bau, gefunden und behoben:** der erste Entwurf liess + `pend["level"]` nach der Bestätigung über weitere Bars stehen — bei verzögertem + Einstieg (Slot belegt, Konfidenz zu niedrig) wäre er zu einem Preis eingestiegen, + den der Markt längst verlassen hatte. Kein Look-ahead, aber **unerreichbar**, und + sichtbar an einem unplausiblen Bruch zwischen Konf 65 und 75 (+0,33 → −0,12). + Behoben über das `frisch`-Flag. **Lehre: ein Preis, der einmal galt, ist keine + Einstiegsmöglichkeit — nur der Moment der Auslösung zählt.** - **⚠⚠ 15-MINUTEN-REGEL GANZ ABGESCHALTET (`adverse_15min_atr = 0`, 2026-07-31, User-Entscheidung nach der Messung).** Auf die User-Frage „vielleicht die Auto-Close auf 30 min setzen?" wurde die **ZEIT-Achse** mitgemessen diff --git a/backtest_auto_signal_v3.py b/backtest_auto_signal_v3.py new file mode 100644 index 0000000..d53d128 --- /dev/null +++ b/backtest_auto_signal_v3.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +"""AUTO-SIGNAL (🎯 SIG) — vollständige Neumessung mit dem Stand vom 2026-08-05. + +WARUM SCHON WIEDER: seit `backtest_auto_signal_norev.py` (04.08.) haben sich vier +Dinge geändert, von denen drei die Messgrundlage berühren: + 1. **Nacht-Fenster 0–7 → 3–6** (05.08., `auto_squeeze_night_hours` gilt auch für + den Signal-Pfad). Die alte Messung sperrte 8 Stunden, live sind es noch 4. + 2. **Reversal aus** (04.08.) — war dort schon berücksichtigt. + 3. **Trail 1,0** im kanonischen Exit (31.07.) — dort schon drin. + 4. ⚠ **NEU und der eigentliche Anlass: die AUSFÜHRUNG.** Am 05.08. wurde für den + Squeeze gemessen, dass sein gesamter Edge im Einstiegspreis liegt (am Level + ØR +0,456, hinterhergelaufen −0,158). **Dieselbe Frage stellt sich für SIG** — + denn `_confirm_breakout` hat ein LEVEL (`_pend["level"]` = Anker ± k×ATR), an + dem eine ruhende Stop-Order liegen könnte, statt am Markt nachzukaufen. Alle + bisherigen SIG-Messungen sind am Bar-Close eingestiegen. + +GEMESSEN WIRD ENTLANG DREI ACHSEN + * Einstieg: **am Bestätigungs-Level** vs. **am Bar-Close** (Ist-Zustand) + * Nacht-Fenster: **3–6 (live)** vs. 0–7 (alt) vs. keins + * Konfidenz-Schwelle: 55 / 65 / **75 (live)** / 85 + +METHODIK: voller Live-Gate-Stack über die ECHTEN Methoden (`_build` inkl. Totband, +M30-Filter, Anti-Überdehnung, Winkel, min_conf; `_confirm_breakout`-Mechanik mit +k=0,3; `_room_gate`), sequentielle 1-Slot-Sim wie live, kanonischer Exit +(`exit_model.LIVE`), Echtkosten aus dem Bar-Spread, 2 Halbjahre. +⚠ Live-Dedup nachgebildet: **1× je Signal-Episode** — re-armt erst bei WARTEN oder +Richtungswechsel (`_signal_last_sig`), nicht nach jedem Exit. + +VORAB FIXIERTE ENTSCHEIDUNGSREGEL: + SIG bleibt nur an, wenn eine Variante in **BEIDEN** Halbjahren ØR > 0 UND PF > 1 + liefert und die Nachbar-Konfidenzschwellen mithalten. Sonst: gemessen nicht + tragfähig — die Entscheidung bleibt beim User, die Zahl nicht. + +Aufruf: python backtest_auto_signal_v3.py [bars] +""" +import sys +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo + +import MetaTrader5 as mt5 + +from core.analysis import calc_trend_angle +from core.exit_model import LIVE, simulate +from core.wave_rec import (WaveRecommender, _EMA_FAST, _EMA_SLOW, _N_BARS, + _ANGLE_LR, _ANGLE_DEAD, _HTF_DEADBAND) + +_BERLIN = ZoneInfo("Europe/Berlin") +_BROKER = timezone(timedelta(hours=3)) +_UTC = timezone.utc +_ATRMIN = 0.06 +_TIMEOUT_BARS = 12 # `_breakout_timeout_s` 3600 s = 12 M5-Bars +_PIV_K, _PIV_LOOK = 3, 300 +_ATR_FLOOR_PB = 0.12 +_K = 0.3 # breakout_k live +_NACHT_LIVE = (3, 4, 5, 6) +_NACHT_ALT = (0, 1, 2, 3, 4, 5, 6, 7) + + +def _berlin(ts): + return (datetime.fromtimestamp(int(ts), _UTC) + .replace(tzinfo=_BROKER).astimezone(_BERLIN)) + + +def d_slip(sig): + """Slippage wirkt IMMER gegen den Trade (LONG teurer, SHORT billiger).""" + return 1 if sig == "LONG" else -1 + + +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 lauf(w, H, L, C, T, A, SP, EF, ES, AD, HTF, H1, PH, PL, lo, hi, + min_conf, nacht, am_level, slip=0.0): + """Ein Durchlauf: sequentiell, EIN Positions-Slot, Live-Dedup.""" + def kosten(i, atr): + return (SP[i] if SP[i] > 0 else 0.0225) / atr + + Rs, abst = [], [] + pend = None + letztes_sig = None # spiegelt `_signal_last_sig` + frei_ab = lo + i = max(lo, _PIV_LOOK + _EMA_SLOW + 5) + while i < hi: + atr = A[i] + if not atr or atr < _ATRMIN: + i += 1; continue + w._pb_feats = {"atr": max(atr, _ATR_FLOOR_PB)} + w._pb_levels = {"ph": [C[j] for j in PH if i - _PIV_LOOK <= j <= i - _PIV_K], + "pl": [C[j] for j in PL if i - _PIV_LOOK <= j <= i - _PIV_K]} + 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") + + # ── Breakout-Bestätigung (Mechanik 1:1 aus `_confirm_breakout`) ────── + # ⚠⚠ `frisch` = die Bestätigung ist GERADE auf diesem Bar eingetreten. + # Nur dann ist ein Einstieg AM LEVEL überhaupt erreichbar: eine ruhende + # Stop-Order füllt in genau diesem Moment — oder gar nicht. Ein erster + # Entwurf liess `pend["level"]` über weitere Bars stehen und wäre bei + # verzögertem Einstieg (Slot belegt / Konfidenz zu niedrig) zu einem Preis + # eingestiegen, den der Markt längst verlassen hatte. Das ist kein + # Look-ahead, aber unerreichbar — und hat die Zahlen sichtbar verbogen + # (Bruch zwischen Konf 65 und 75). Deshalb gilt `frisch` für BEIDE + # Varianten, damit A und B dieselbe Einstiegs-Population haben. + lvl_best = None + frisch = False + if _K > 0 and 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 + frisch = 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} + i += 1; continue + lvl_best = pend["level"] + elif sig == "WARTEN": + pend = None + letztes_sig = None # Dedup re-armt bei WARTEN (wie live) + if sig not in ("LONG", "SHORT"): + i += 1; continue + rec = w._room_gate(rec, C[i]) + if rec.get("signal") not in ("LONG", "SHORT"): + i += 1; continue + + sig = rec["signal"] + if _K > 0 and not frisch: + i += 1; continue # nur im Moment der Bestätigung (s. oben) + if (rec.get("conf_pct") or 0) < min_conf: + i += 1; continue # Dedup NICHT setzen (wie live) + if nacht and _berlin(T[i]).hour in nacht: + i += 1; continue # Nacht-Sperre, kein Dedup + if sig == letztes_sig: + i += 1; continue # dieselbe Episode schon gehandelt + letztes_sig = sig + if i < frei_ab: + i += 1; continue # Slot belegt → Episode verfällt + + d = 1 if sig == "LONG" else -1 + a = max(atr, _ATRMIN) + # `slip` = unterstellte Fill-Slippage der ausgeloesten Stop-Order in ×ATR. + # Eine Stop-Order fuellt zum Markt, sobald sie triggert — der Idealpreis ist + # die Obergrenze, nicht die Erwartung. Ohne diesen Test waere die Empfehlung + # eine Behauptung (gleiche Pruefung wie beim Squeeze, Variante E). + entry = ((lvl_best + d_slip(sig) * slip * atr) + if (am_level and lvl_best is not None) else C[i]) + abst.append((C[i] - (lvl_best if lvl_best is not None else C[i])) * d / a) + r, xj = simulate(entry, d, a, H, L, C, i + 1, LIVE, + timestop=True, use_tp=True, ret_bar=True) + Rs.append(r - kosten(i, a)) + pend = None + frei_ab = xj + 1 + i += 1 + return Rs, abst + + +def kenn(rs): + if not rs: + return None + n = len(rs); s = sum(rs) + g = sum(x for x in rs if x > 0); v = -sum(x for x in rs if x < 0) + return {"n": n, "wr": 100 * sum(1 for x in rs if x > 0) / n, + "oer": s / n, "sr": s, "pf": (g / v) if v > 0 else float("inf")} + + +def zeile(lbl, k1, k2): + def f(k): + if not k: + return f"{'—':>34}" + return (f"{k['n']:>5} {k['wr']:>5.1f}% {k['oer']:>+7.3f} " + f"{k['pf']:>5.2f} {k['sr']:>+8.1f}") + beide = bool(k1 and k2 and k1["oer"] > 0 and k2["oer"] > 0 + and k1["pf"] > 1 and k2["pf"] > 1) + print(f" {lbl:<34}{f(k1)} |{f(k2)}" + (" <<< BESTEHT" if beide else "")) + return beide + + +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) + point = mt5.symbol_info(sym).point + 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]; T = [int(b["time"]) for b in bars] + SP = [float(b["spread"]) * point 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) + w.set_dead_hours("") + mid = len(C) // 2 + + print("=" * 106) + print(f" AUTO-SIGNAL (🎯 SIG) — Neumessung Stand 05.08.2026 · {sym} M5, {len(C)} Bars") + print(f" {_berlin(T[0]):%d.%m.%Y} bis {_berlin(T[-1]):%d.%m.%Y} · 2 Halbjahre · " + f"sequentiell (EIN Slot) · Live-Gate-Stack · kanonischer Exit · Echtkosten") + print(f" Exit: SL {LIVE.sl_atr} · Trail {LIVE.mult} · BE {LIVE.be} · " + f"Time-Stop {LIVE.timestop_bars} Bars · TP {LIVE.tp_init}") + print("=" * 106) + + def paar(min_conf, nacht, am_level, slip=0.0): + r1, a1 = lauf(w, H, L, C, T, A, SP, EF, ES, AD, HTF, H1S, PH, PL, + 0, mid, min_conf, nacht, am_level, slip) + r2, a2 = lauf(w, H, L, C, T, A, SP, EF, ES, AD, HTF, H1S, PH, PL, + mid, len(C), min_conf, nacht, am_level, slip) + return kenn(r1), kenn(r2), (a1 + a2) + + print(f" {'Variante':<34}{'H1 n WR OeR PF SigmaR':>34}" + f" |{'H2 n WR OeR PF SigmaR':>34}") + print(" " + "-" * 104) + print(" A) EINSTIEG AM BAR-CLOSE (Ist-Zustand: Market-Order)") + besteht = [] + for mc in (55, 65, 75, 85): + k1, k2, ab = paar(mc, _NACHT_LIVE, False) + if zeile(f" Konf>={mc} · Nacht 3-6 (live)", k1, k2): + besteht.append(("close", mc)) + print(" " + "-" * 104) + print(" B) EINSTIEG AM BESTAETIGUNGS-LEVEL (ruhende Stop-Order, wie beim Squeeze)") + for mc in (55, 65, 75, 85): + k1, k2, ab = paar(mc, _NACHT_LIVE, True) + if zeile(f" Konf>={mc} · Nacht 3-6 (live)", k1, k2): + besteht.append(("level", mc)) + print(" " + "-" * 104) + print(" C) NACHT-FENSTER im Vergleich (Konf>=75, Einstieg am Level)") + for lbl, nn in (("3-6 (live)", _NACHT_LIVE), ("0-7 (alt)", _NACHT_ALT), + ("keine Sperre", ())): + k1, k2, _ = paar(75, nn, True) + zeile(f" Nacht {lbl}", k1, k2) + + print(" " + "-" * 104) + print(" D) HAERTETEST — Fill-Slippage der Stop-Order (Konf>=65, Nacht 3-6)") + for sl in (0.00, 0.05, 0.10, 0.20, 0.31): + k1, k2, _ = paar(65, _NACHT_LIVE, True, sl) + zeile(f" Level +{sl:.2f}xATR" + + (" (= Ist-Zustand Market)" if sl >= 0.30 else ""), k1, k2) + + # Wie weit laeuft der Kurs ueber das Bestaetigungs-Level? + _, _, ab = paar(75, _NACHT_LIVE, True) + if ab: + s = sorted(ab) + print() + print(f" Abstand Bar-Close ↔ Bestaetigungs-Level (×ATR): Median " + f"{s[len(s)//2]:+.3f} · Ø {sum(s)/len(s):+.3f} · Max {s[-1]:+.3f} (n={len(s)})") + print(" = so viel guenstiger waere eine ruhende Stop-Order am Level.") + + print() + print("=" * 106) + print(" URTEIL (Regel VOR dem Lauf fixiert: OeR > 0 UND PF > 1 in BEIDEN") + print(" Halbjahren, Nachbar-Konfidenzschwellen muessen mithalten)") + print("=" * 106) + if besteht: + print(f" Einzeln bestanden: {besteht}") + print(" ⚠ Nachbar-Pruefung von Hand vornehmen (s. Tabelle) — eine einzelne") + print(" bestehende Schwelle zwischen zwei durchgefallenen ist Rauschen.") + else: + print(" => KEINE Variante besteht. Der Auto-Signal-Pfad ist auch mit den") + print(" Aenderungen vom 05.08. nicht tragfaehig.") + print() + + +if __name__ == "__main__": + main()