diff --git a/CLAUDE.md b/CLAUDE.md index 1a65ae4..5add8c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4942,6 +4942,58 @@ Modell schliesst keine Trades. „die Rangfolge ist kaputt" ist **nicht** belegt; belegt ist nur die Basisraten-/Kalibrierungslücke. +## ⚠⚠ VWAP-BÄNDER AUF M15 = VERWORFEN (2026-08-08) — die letzte ungetestete Zutat + +Anlass: User-Wunsch nach einem **M15-Daytrading-Modul** auf Profi-Methodik +(Order-Flow, Volume Profile, VWAP, Session-Struktur, Liquidity Sweeps, MSS, +Mean-Reversion). Abgleich mit dem Gemessenen ergab: **fast alles davon ist in +diesem Projekt bereits durchgefallen** — Volume Profile (POC-Reversion +beidhälftig negativ), Liquidity Sweeps (alle 9 Kombis), Market Structure (24/24 +Zellen), ORB (mit realer Fill-Annahme alle 4), RSI/MACD, Inter-Market. +Footprint/CVD/DOM existieren hier nicht (Market-Maker-Broker ohne Orderbuch; das +HL-Orderbuch ist gemessen 0,2–1,0 bp gegen ~3 bp Kostenhürde und läuft dem CFD +**hinterher**). Kpler/Bloomberg scheitern am Preis, CoT und Cushing an der +wöchentlichen Frequenz. +**VWAP war die EINZIGE nie gemessene Zutat** (`calc_vwap` existiert in +`core/analysis/indicators.py`, taucht in CLAUDE.md nirgends als Signal auf). +Jetzt gemessen: `backtest_vwap_m15.py`, 26.000 M15-Bars (02.07.25–07.08.26), +session-verankert auf den Berliner Kalendertag, 2 Hälften, kanonischer Exit, +Echtkosten, **Fill bei Berührung**. + +⚠⚠ **ZUERST DER EIGENE FEHLER — er ist die halbe Lehre.** Der erste Lauf zeigte +Reversion bei k=2,0–3,0 mit **ØR +0,13 bis +0,27 und PF bis 1,71 in BEIDEN +Hälften** — verdächtig sauber. Ursache: das Band an Bar `i` wird **mit** Bar `i` +berechnet (der VWAP kumuliert sie), und getestet wurde, ob genau diese Bar es +berührt. **Ein Ausschlag zieht das Band zu sich hin und erzeugt seine eigene +Berührung.** Mit Band aus `i−1` (live zwangsläufig der Fall) bleibt: +| k | vorher H1/H2 | **nach dem Fix** | +|---|---|---| +| 2,0 | +0,130 / +0,130 | **+0,041 / −0,009** | +| 2,5 | +0,189 / +0,235 | **+0,046 / +0,087** | +| 3,0 | +0,162 / +0,272 | **+0,024 / +0,010** | +**Der Look-ahead war 0,10–0,25 R wert** — dieselbe Größenordnung wie beim +Squeeze (0,32), Momentum (0,45) und SIG (0,77–0,85). + +**ALLE DREI vorab fixierten Kriterien verfehlt:** +· **(b) Haltequote gegen Zufallskontrolle** — eine **willkürliche Linie gleicher +Distanz** hält in **9 von 10 Zellen genauso gut oder besser** (VWAP 70,3–75,1 %, +Zufall 73,9–76,6 %). Exakt der Fibonacci-Befund reproduziert: die Frage ist nie +„hält das Level?", sondern „hält es BESSER als eine beliebige Linie?". +· **(c) Ertrag** — k=2,0 kippt in H2 (−0,009), also kippt der **Nachbar** der +besten Zelle. Und **alle sechs 95-%-Bootstrap-Intervalle enthalten die Null** +(k=2,5 H2, die beste Zelle: +0,087 [−0,012 … +0,185]). +· **(a) Beweglichkeit** — ⚠ **die Regel war von mir unterspezifiziert** +(„≤ Squeeze-Box-Niveau" = 0 ist für ein gleitendes Band unerfüllbar). Gemessen +rückt das Band im Median nur 0,03–0,05×ATR je Bar, im **90-Perzentil aber bis +0,34×ATR** — mehr als die Einstiegstoleranz von 0,15. Eine ruhende Order müsste +also laufend nachgezogen werden: dasselbe operative Problem wie bei `_pend`. + +✅ **Kohärenz-Kontrolle:** der **Ausbruch** an den Bändern ist spiegelbildlich +negativ (H1 −0,107…−0,230 über alle k). Reversion leicht positiv, Ausbruch klar +negativ — ein stimmiges Bild, kein Zufallsrauschen. +**24. verworfener Signal-Eingriff.** Damit ist aus dem eingereichten +Profi-Werkzeugkasten **keine ungetestete Zutat mehr offen.** + ## ⚠⚠ VERLUSTBEGRENZUNG BEI „FALSCHEN" EINSTIEGEN — BEIDE IDEEN GEMESSEN, BEIDE DURCHGEFALLEN (2026-08-07) User: „um meine Verluste bei falschen Einstiegen zu begrenzen würde ich gerne den diff --git a/backtest_vwap_m15.py b/backtest_vwap_m15.py new file mode 100644 index 0000000..d31a1a3 --- /dev/null +++ b/backtest_vwap_m15.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +"""VWAP-Bänder auf M15 — taugen sie als LEVEL für ein Daytrading-Modul? + +ANLASS (2026-08-08, User-Wunsch nach einem M15-Modul auf Profi-Methodik). Vom +eingereichten Werkzeugkasten ist fast alles in diesem Projekt schon gemessen und +durchgefallen — Volume Profile (POC-Reversion beidhälftig negativ), Liquidity +Sweeps (alle 9 Kombis), Market Structure (24/24 Zellen), ORB (alle 4 Varianten +mit realer Fill-Annahme), RSI/MACD, Inter-Market. Footprint/CVD/DOM gibt es hier +gar nicht (Market-Maker-Broker ohne Orderbuch). +**VWAP ist die einzige Zutat aus der Liste, die nie gemessen wurde** — +`core/analysis/indicators.calc_vwap` existiert, taucht in CLAUDE.md aber nirgends +als Signal auf. + +⚠⚠ DIE VORFRAGE ENTSCHEIDET ALLES — und sie steht deshalb ganz vorne: +**STEHT das Band still?** Der einzige im Projekt belegte Hebel ist die +Ausführung: eine ruhende Order an einem STEHENDEN Level (Squeeze +0,32 R +gegenüber Market; SIG +0,35/+0,32 am Level gegen −0,09/−0,11 am Markt). Und der +Gegenbeleg ist genauso klar: das Momentum-Level WANDERT jede Bar, deshalb fällt +es durch (+0,45 R Aufschlag war reines Look-ahead). Ein VWAP-Band bewegt sich +per Konstruktion mit jedem Tick. Wandert es so stark wie das Momentum-Level, ist +das Modul erledigt, bevor irgendein Ertrag gerechnet wird. + +GEMESSEN WIRD, in dieser Reihenfolge: + 0) BEWEGLICHKEIT — wie weit rückt das Band je Bar (in ×ATR)? Gegen zwei + Referenzen: die Squeeze-Box (steht, trägt) und `C[i−N]` (wandert, trägt nicht). + 1) HALTEQUOTE — prallt der Kurs am Band ab? ⚠ MIT KONTROLLGRUPPE: eine + Zufallslinie gleicher Distanz. Die Fibonacci-Messung hat gezeigt, dass ein + BELIEBIGES Level hier zu ~55 % hält — ohne Kontrolle misst man die Basisrate. + 2) ERTRAG — Reversion (Limit am Band) und Ausbruch (Stop hinter dem + Band), beide mit FILL BEI BERÜHRUNG (die ehrliche Annahme, s. 06.08.), + kanonischer Exit, Echtkosten, 2 Halbjahre. + +⚠⚠ ENTSCHEIDUNGSREGEL, VORAB FESTGELEGT: + (a) Beweglichkeit ≤ Squeeze-Box-Niveau, sonst Abbruch — ein wanderndes Level + ist als ruhende Order nicht handelbar. + (b) Haltequote muss die Zufallskontrolle in BEIDEN Hälften schlagen. + (c) ØR > 0 und PF > 1 in BEIDEN Hälften, und die Nachbar-k dürfen nicht kippen. +Sonst: kein Einbau. Bei 23 gescheiterten Signal-Eingriffen gilt hohe Beweislast. + +Aufruf: python backtest_vwap_m15.py [n_M15_bars] (Default 26000 ≈ 9 Monate) +""" +from __future__ import annotations + +import sys +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo + +import MetaTrader5 as mt5 + +from core.exit_model import LIVE, simulate + +_BERLIN = ZoneInfo("Europe/Berlin") +_BROKER = timezone(timedelta(hours=3)) # Brokerzeit = UTC+3 +_ATRMIN = 0.12 +_BAENDER = (1.0, 1.5, 2.0, 2.5, 3.0) +_ZONE = 0.15 # „am Band" in ×ATR (wie `at_level` live) + + +def _berlin(ts: int) -> datetime: + """⚠ MT5 liefert die BROKER-Wallclock, keinen echten UTC-Epoch. Genau hier + ist `analyze_tageszeit.py` am 05.08. um drei Stunden verrutscht.""" + return datetime.fromtimestamp(ts, timezone.utc).replace( + tzinfo=_BROKER).astimezone(_BERLIN) + + +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 vwap_baender(T, H, L, C, V): + """Session-verankerter VWAP + kumulierte Standardabweichung je M15-Bar. + + Anker = Berliner Kalendertag (Reset 00:00). ⚠ KAUSAL: an Bar i fliessen nur + Bars bis EINSCHLIESSLICH i ein — das Band an i ist also aus i bekannt, und + eine Order kann fruehestens ab i+1 liegen (so wird unten auch gehandelt). + """ + vw = [None] * len(C) + sd = [None] * len(C) + cum_pv = cum_v = cum_p2v = 0.0 + tag = None + for i in range(len(C)): + d = _berlin(T[i]).date() + if d != tag: + tag = d + cum_pv = cum_v = cum_p2v = 0.0 + tp = (H[i] + L[i] + C[i]) / 3.0 + v = float(V[i] or 1) + cum_pv += tp * v + cum_v += v + cum_p2v += tp * tp * v + if cum_v <= 0: + continue + m = cum_pv / cum_v + var = max(0.0, cum_p2v / cum_v - m * m) + vw[i] = m + sd[i] = var ** 0.5 + return vw, sd + + +def beweglichkeit(vw, sd, AT, k): + """Wie weit rueckt das obere Band je Bar, in ×ATR? Median + 90-Perzentil.""" + d = [] + for i in range(1, len(vw)): + if not (vw[i] and vw[i - 1] and sd[i] is not None and sd[i - 1] is not None): + continue + a = AT[i] + if not a or a <= 0: + continue + o1 = vw[i - 1] + k * sd[i - 1] + o2 = vw[i] + k * sd[i] + d.append(abs(o2 - o1) / max(a, _ATRMIN)) + if not d: + return None + d.sort() + return d[len(d) // 2], d[int(0.9 * len(d))] + + +def halte_quote(H, L, C, AT, vw, sd, k, lo, hi, fenster=8, zufall=False): + """Prallt der Kurs am Band ab? Beruehrung = Bar-Spanne trifft die Zone. + Ausgang: erreicht er zuerst Band∓0,5×ATR (haelt) oder Band±0,5×ATR (bricht)? + `zufall=True` = Kontrolllinie in derselben Distanz, aber ohne VWAP-Bezug.""" + import random + rnd = random.Random(7) + haelt = bricht = 0 + war = False + for i in range(max(lo, 1), min(hi, len(C) - fenster - 1)): + # ⚠⚠ BAND AUS i-1, NICHT AUS i. Der VWAP an i enthaelt Bar i selbst — + # gegen die Spanne DERSELBEN Bar zu testen ist Look-ahead: ein Ausschlag + # zieht das Band zu sich hin und erzeugt so seine eigene „Beruehrung". + # Live liegt die Order zwangslaeufig auf dem Stand des VORBARS. + if not vw[i - 1] or sd[i - 1] is None: + war = False + continue + a = AT[i] + if not a or a <= 0: + war = False + continue + a = max(a, _ATRMIN) + lvl = vw[i - 1] + k * sd[i - 1] + if zufall: + # gleiche Distanz zum Kurs, aber willkuerliche Lage + dist = abs(lvl - C[i]) + lvl = C[i] + dist * rnd.choice((1, -1)) + oben = lvl > C[i] + beruehrt = (L[i] <= lvl + _ZONE * a) and (H[i] >= lvl - _ZONE * a) + if beruehrt and not war: + durch = lvl + (0.5 * a if oben else -0.5 * a) + zurueck = lvl - (0.5 * a if oben else -0.5 * a) + aus = None + for j in range(i + 1, i + 1 + fenster): + if (H[j] >= durch) if oben else (L[j] <= durch): + aus = "break"; break + if (L[j] <= zurueck) if oben else (H[j] >= zurueck): + aus = "halt"; break + if aus == "break": + bricht += 1 + else: + haelt += 1 # Timeout zaehlt als Halt (wie im P(break)-Training) + war = beruehrt + n = haelt + bricht + return (n, haelt / n if n else None) + + +def ertrag(H, L, C, AT, SP, vw, sd, k, lo, hi, art): + """`art`: 'reversion' = Limit AM Band, Richtung zurueck zum VWAP · + 'ausbruch' = Stop HINTER dem Band, Richtung weiter. + ⚠ FILL BEI BERUEHRUNG — die einzige live erreichbare Annahme (s. 06.08.: + der Close-Bruch-Fill ist Look-ahead und schenkt 0,3–0,85 R).""" + Rs = [] + i = max(lo, 1) + war = False + while i < min(hi, len(C) - LIVE.max_hold - 2): + # ⚠⚠ Band aus i-1 (s. `halte_quote`): das an i berechnete Band enthaelt + # Bar i selbst und waere Look-ahead. + if not vw[i - 1] or sd[i - 1] is None: + war = False; i += 1; continue + a = AT[i] + if not a or a <= 0: + war = False; i += 1; continue + a = max(a, _ATRMIN) + for seite in (1, -1): # oberes und unteres Band + lvl = vw[i - 1] + seite * k * sd[i - 1] + beruehrt = (L[i] <= lvl + _ZONE * a) and (H[i] >= lvl - _ZONE * a) + if not beruehrt or war: + continue + d = -seite if art == "reversion" else seite + kosten = (SP[i] if SP[i] > 0 else 0.0225) / a + R, xb = simulate(lvl, d, a, H, L, C, i + 1, LIVE, ret_bar=True) + Rs.append(R - kosten) + i = xb + break + if vw[i - 1] and sd[i - 1] is not None: + _o = vw[i - 1] + k * sd[i - 1] + war = (L[i] <= _o + _ZONE * a) and (H[i] >= _o - _ZONE * a) + else: + war = False + i += 1 + return Rs + + +def kz(Rs): + if not Rs: + return "n=0" + n = len(Rs); w = sum(1 for x in Rs if x > 0); s = sum(Rs) + g = sum(x for x in Rs if x > 0); v = -sum(x for x in Rs if x < 0) + return (f"n={n:>4} WR={100*w/n:>3.0f}% ØR={s/n:+.3f} " + f"PF={(g/v if v > 0 else 9.99):>4.2f} ΣR={s:>+6.0f}") + + +def main(): + n = int(sys.argv[1]) if len(sys.argv) > 1 else 26000 + 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_M15, 0, n) + point = mt5.symbol_info(sym).point + mt5.shutdown() + if bars is None or len(bars) < 5000: + print(f"zu wenige M15-Bars ({0 if bars is None else len(bars)})"); return + T = [int(b["time"]) for b in bars] + H = [float(b["high"]) for b in bars]; L = [float(b["low"]) for b in bars] + C = [float(b["close"]) for b in bars] + V = [float(b["tick_volume"]) for b in bars] + SP = [float(b["spread"]) * point for b in bars] + AT = atr_series(H, L, C) + vw, sd = vwap_baender(T, H, L, C, V) + mid = len(C) // 2 + + print("=" * 92) + print(f" VWAP-BÄNDER auf M15 — {sym}, {len(C)} Bars " + f"({_berlin(T[0]):%d.%m.%Y} bis {_berlin(T[-1]):%d.%m.%Y})") + print("=" * 92) + + # ── 0) DIE VORFRAGE ─────────────────────────────────────────────────── + print("\n 0) BEWEGLICHKEIT — steht das Band still? (Verschiebung je Bar in ×ATR)") + print(f" {'k':>5}{'Median':>10}{'90-Perzentil':>15}") + for k in _BAENDER: + b = beweglichkeit(vw, sd, AT, k) + if b: + print(f" {k:>5.1f}{b[0]:>10.3f}{b[1]:>15.3f}") + print(" Referenz: Squeeze-Box steht (0,000 zwischen den Ausbrüchen) und TRÄGT;") + print(" das Momentum-Level `C[i−N]` wandert und TRÄGT NICHT.") + + # ── 1) HALTEQUOTE mit Kontrolle ─────────────────────────────────────── + print("\n 1) HALTEQUOTE am oberen Band — gegen eine Zufallslinie gleicher Distanz") + print(f" {'k':>5}{'H1 VWAP':>12}{'H1 Zufall':>12}{'H2 VWAP':>12}{'H2 Zufall':>12}") + for k in _BAENDER: + z = [] + for lo, hi in ((0, mid), (mid, len(C))): + for zuf in (False, True): + nn, q = halte_quote(H, L, C, AT, vw, sd, k, lo, hi, zufall=zuf) + z.append(f"{100*q:.1f}% ({nn})" if q is not None else "—") + print(f" {k:>5.1f}" + "".join(f"{x:>12}" for x in z)) + + # ── 2) ERTRAG ───────────────────────────────────────────────────────── + for art in ("reversion", "ausbruch"): + print(f"\n 2) ERTRAG — {art.upper()} (Fill bei Berührung, kanonischer Exit, Echtkosten)") + for k in _BAENDER: + r1 = ertrag(H, L, C, AT, SP, vw, sd, k, 0, mid, art) + r2 = ertrag(H, L, C, AT, SP, vw, sd, k, mid, len(C), art) + print(f" k={k:<4} H1 {kz(r1)}") + print(f" {'':<7}H2 {kz(r2)}") + + +if __name__ == "__main__": + main()