diff --git a/CLAUDE.md b/CLAUDE.md index cce3b6d..6b2be75 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1240,6 +1240,33 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: klein & kaum handelbar; NY Fed: SKS nicht profitabel; out-of-sample schwach) UND der Struktur-Klasse (Muster = Swings+Trendlinien = die gerade verworfene Klasse). **11. verworfener Signal-Eingriff — am Signal ist nichts zu holen.** +- **Volume Profile / „Liquiditätslinien" (POC/HVN/LVN, Value Area) = VERWORFEN + (gemessen `backtest_volume_profile.py`, 2026-07-30, User-Frage „System zur besseren + Vorhersage — Liquiditätsnachfragelinien / wahrsch. Verkaufslinie?"):** Getestet, WEIL + es die einzige Klasse mit einer **anderen Datenquelle** war (nicht „wo war ein Docht" + = Pivots/Swings, sondern **wo wurde tatsächlich gehandelt** = Volumen) — SMC/ICT- + „Order Blocks"/Liquidity-Linien selbst sind strukturell Pivot-Zonen und damit dieselbe + 5× verworfene Klasse. Aufbau: feste Preis-Bins 0,05 $, rollierendes 24-h-Fenster + (288 M5-Bars), Bar-Volumen (`tick_volume` — CFD liefert `real_volume=0`) gleichmäßig + über [low, high], Value Area 70 %; 80k M5-Bars, 2 Halbjahre, Live-Exit + Echtkosten. + **Ergebnis: nichts trägt.** Am eindeutigsten fällt genau die *Kern-Idee* durch: + **Reversion am POC („der Kurs prallt an der Liquiditätslinie ab") ist in BEIDEN + Hälften negativ** (ØR −0,178/−0,121, PF 0,79/0,84, n≈400 je Hälfte) — der POC hält + NICHT. HVN-Reversion kippt (−0,067/+0,044), LVN-Momentum ist Breakeven-Rauschen + (−0,031/+0,003). **Informations-Test (Teil 1, Forward-Return 2 h ohne Exit/Kosten, + relativ zur `mid`-Baseline gelesen — H1 war Abwärts-, H2 Aufwärts-Regime, absolute + Werte sind daher Drift-verzerrt):** KEIN Bucket weicht konsistent ab — LVN H1 −0,11 + / H2 +0,09 relativ, HVN H1 +0,08 / H2 −0,06 → beide kippen. **Tiefere Einsicht: die + Volume-Profile-Position ist ein TREND-PROXY, keine unabhängige Information** + („außerhalb Value Area" = Ausbruch läuft = folgt dem Regime: H2 +0,304 vs +0,092 in + VA, H1 umgekehrt −0,122 vs −0,057) — sie erbt damit exakt die Regime-Anfälligkeit von + Momentum/Struktur. **15. verworfener Signal-Eingriff.** ⚠ Was BLEIBT: das bereits + validierte **P(break)-Modell** ist die einzige belegt-prädiktive „Liquiditäts"-Aussage + im Projekt (AUC 0,71 out-of-sample, kalibriert; ~63 % der Level-Touches prallen ab) — + und auch das trägt nur als **EXIT**, nicht als Entry. Einzige noch ungetestete Klasse + wäre **echter Order-Flow/L2-Orderbuch** (Pepperstone = Market Maker, liefert keins; + Hyperliquid hätte `l2Book` — bräuchte erst wochenlange Datensammlung UND einen Beleg, + dass HL-Orderbuch-Signale auf den CFD übertragbar sind). - **Chartmuster-Kachel `#card-patterns` (`core/patterns.py`, 2026-07-27, User-Idee „Visuelle Muster-Erkennung"): REINE ANZEIGE, KEIN Signal.** Erkennt mechanisch aus den M30-Swing-Pivots (k=3, wie `structure.py`): **Doppeltop/-boden, Kopf-Schulter/ diff --git a/backtest_volume_profile.py b/backtest_volume_profile.py new file mode 100644 index 0000000..85395cb --- /dev/null +++ b/backtest_volume_profile.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""backtest_volume_profile.py — Volume Profile / echte Liquiditätszonen (B2, +User-Frage 2026-07-30 „System zur besseren Vorhersage — Liquiditätslinien?"). + +WARUM diese Klasse überhaupt getestet wird (die anderen sind durch): Order Blocks / +SMC-„Liquiditätslinien" sind strukturell Pivot-/Swing-Zonen — dieselbe Klasse, die +hier schon 5× durchfiel (`backtest_structure.py`, `backtest_doubletop.py`, +`backtest_patterns.py`, P(break)-Entry, `backtest_gaps.py`). Das Volume Profile ist +eine ANDERE Datenquelle: nicht „wo war ein Docht", sondern **wo wurde tatsächlich +gehandelt** (Akzeptanz). Damit ist es legitim testbar statt Runde 6. + +Begriffe: + · POC (Point of Control) = Preis-Bin mit dem MEISTEN Volumen im Fenster + · HVN (High Volume Node) = viel Volumen → „Akzeptanz", Kurs verweilt/prallt ab + · LVN (Low Volume Node) = wenig Volumen → „Ablehnung", Kurs läuft schnell durch + · VA (Value Area) = engster Preisbereich, der ~70 % des Volumens enthält + +Aufbau: feste globale Preis-Bins (`_BIN`) → rollierendes Fenster (`_WIN` Bars), +Bar-Volumen gleichmäßig über [low, high] verteilt (Standard-Approximation). +Volumen = `tick_volume` (CFD liefert kein real_volume — bei MT5-CFDs Standard-Proxy, +korreliert gut mit echtem Volumen). + +TEIL 1 — INFORMATION (hat die Volumen-Position überhaupt Vorhersagewert?): + Forward-Return (2 h, in ATR) gebucketet nach Kursposition im Profil. Kein Exit, + keine Kosten → isoliert die reine Information. Trägt nur, wenn ein Bucket in + BEIDEN Hälften konsistent abweicht. + +TEIL 2 — HANDELBAR (sequenzielle 1-Positions-Sim, Live-Exit, Echtkosten): + (A) Mean-Reversion am POC/HVN: Kurs erreicht die Zone → Gegenrichtung + (= die „Liquiditätslinie hält"-Idee) + (B) Momentum durch LVN: Kurs betritt eine Leerzone → in Bewegungsrichtung + (= „Liquidity Void, läuft schnell durch") + Maßstab: der validierte Squeeze (ØR +0,14…+0,23, PF>1). Beide Hälften positiv, + sonst raus. + +Aufruf: python backtest_volume_profile.py [n_bars] +""" +from __future__ import annotations +import sys +from collections import defaultdict + +import MetaTrader5 as mt5 + +_BIN = 0.05 # Preis-Bin-Größe ($) — bei WTI ~85 = 0,06 %, ~0,25×ATR +_WIN = 288 # Profil-Fenster (M5-Bars) = 24 h +_FWD = 24 # Forward-Return-Horizont (Bars) = 2 h +_MAXH = 288 # Sim-Horizont +_ATRMIN = 0.06 +_SL_ATR, _TRAIL, _TRAIL_ON, _BE_ON, _TIMESTOP = 2.0, 1.5, 0.3, 1.3, 24 +_COOL = 12 # Cooldown zwischen Trades (Bars) +_VA_FRAC = 0.70 # Value Area = 70 % des Volumens + + +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] * len(C); run = 0.0 + for i in range(1, len(C)): + run += tr[i] + if i > p: run -= tr[i - p] + out[i] = run / min(i, p) + return out + + +def _bins_of(lo, hi): + """Bin-Indizes, die eine Bar-Range überdeckt (mind. 1).""" + a, b = int(lo / _BIN), int(hi / _BIN) + return range(a, b + 1) + + +class Profile: + """Rollierendes Volume Profile über feste Preis-Bins (effizient: add/remove).""" + def __init__(self): + self.vol = defaultdict(float) + + def add(self, lo, hi, v): + bs = _bins_of(lo, hi); share = v / max(1, len(bs)) + for b in bs: self.vol[b] += share + + def remove(self, lo, hi, v): + bs = _bins_of(lo, hi); share = v / max(1, len(bs)) + for b in bs: + self.vol[b] -= share + if self.vol[b] <= 1e-9: del self.vol[b] + + def stats(self): + """(poc_bin, total, sorted_bins_desc) — None wenn leer.""" + if not self.vol: return None + items = sorted(self.vol.items(), key=lambda kv: -kv[1]) + return items[0][0], sum(self.vol.values()), items + + def classify(self, price): + """Wo liegt `price` im Profil? → (label, rel_vol, in_va) + rel_vol = Volumen des Preis-Bins / POC-Volumen (1,0 = am POC).""" + s = self.stats() + if not s: return None, 0.0, False + poc, total, items = s + pv = items[0][1] + b = int(price / _BIN) + v = self.vol.get(b, 0.0) + rel = v / pv if pv > 0 else 0.0 + # Value Area: Bins absteigend nach Volumen aufsummieren bis _VA_FRAC + acc = 0.0; va = set() + for bb, vv in items: + va.add(bb); acc += vv + if acc >= _VA_FRAC * total: break + in_va = b in va + if b == poc: lab = "POC" + elif rel >= 0.60: lab = "HVN" + elif rel <= 0.15: lab = "LVN" + else: lab = "mid" + return lab, rel, in_va + + +def _sim(entry, d, atr, H, L, C, j0): + eff = entry - d * _SL_ATR * atr; hw = entry; started = False + end = min(j0 + _MAXH, len(C) - 1); exit_px = C[end] + for j in range(j0, end + 1): + if (L[j] <= eff) if d > 0 else (H[j] >= eff): exit_px = eff; break + hw = max(hw, H[j]) if d > 0 else min(hw, L[j]) + prof = (C[j] - entry) * d + if prof >= _TRAIL_ON * atr: + started = True + cand = hw - d * _TRAIL * atr + if prof >= _BE_ON * atr: + cand = max(cand, entry) if d > 0 else min(cand, entry) + eff = max(eff, cand) if d > 0 else min(eff, cand) + if not started and (j - j0) >= _TIMESTOP: exit_px = C[j]; break + return (exit_px - entry) * d / atr + + +def _rep(name, Rs, kind="R"): + if not Rs or len(Rs) < 5: + print(f" {name:<30} n={len(Rs):>4} (zu wenige)"); return None + n = len(Rs); w = sum(1 for x in Rs if x > 0); s = sum(Rs) + up = sum(x for x in Rs if x > 0); dn = -sum(x for x in Rs if x < 0) + pf = up / dn if dn > 0 else 9.99 + print(f" {name:<30} n={n:>5} pos={100*w/n:>3.0f}% Ø{kind}={s/n:+.3f} " + f"PF={pf:.2f} Σ={s:+.0f}") + return s / n + + +def main(): + n = int(sys.argv[1]) if len(sys.argv) > 1 else 80000 + mt5.initialize() + sym = None + for c in ("SpotCrude", "USOIL", "WTI", "XTIUSD"): + if mt5.symbol_info(c): sym = c; break + bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, n) + si = mt5.symbol_info(sym); point = si.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]; V = [float(b["tick_volume"]) for b in bars] + SP = [float(b["spread"]) * point for b in bars] + A = _atr_series(H, L, C) + N = len(C); mid = N // 2 + + print("=" * 92) + print(f" VOLUME PROFILE / Liquiditätszonen — {sym} M5 ({N} Bars)") + print(f" Bin={_BIN}$ · Fenster={_WIN} Bars (24h) · Volumen=tick_volume · VA={_VA_FRAC:.0%}") + print("=" * 92) + + # ── Profil einmal durchlaufen und je Bar die Klassifikation cachen ────────── + prof = Profile() + lab_at = [None] * N; rel_at = [0.0] * N; poc_at = [None] * N; inva_at = [False] * N + for i in range(N): + prof.add(L[i], H[i], V[i]) + if i >= _WIN: prof.remove(L[i - _WIN], H[i - _WIN], V[i - _WIN]) + if i >= _WIN: + lab, rel, in_va = prof.classify(C[i]) + lab_at[i] = lab; rel_at[i] = rel; inva_at[i] = in_va + s = prof.stats() + poc_at[i] = (s[0] + 0.5) * _BIN if s else None + + # ── TEIL 1: INFORMATION (Forward-Return je Bucket, ohne Exit/Kosten) ──────── + print("\nTEIL 1 — INFORMATION: Forward-Return 2 h (in ATR) je Kursposition im Profil") + print(" (positiv/negativ = Richtungs-Drift; 'pos%' = Anteil Aufwärts)") + for label, lo, hi in (("H1 (alt)", _WIN, mid), ("H2 (neu)", mid, N - _FWD - 1)): + print(f"\n{label}:") + buckets = defaultdict(list) + for i in range(lo, hi): + atr = A[i] + if not atr or atr < _ATRMIN or lab_at[i] is None: continue + fwd = (C[i + _FWD] - C[i]) / atr + buckets[lab_at[i]].append(fwd) + buckets["in VA" if inva_at[i] else "außerhalb VA"].append(fwd) + for k in ("POC", "HVN", "mid", "LVN", "in VA", "außerhalb VA"): + if k in buckets: _rep(k, buckets[k], kind="fwd") + + # ── TEIL 2: HANDELBAR ────────────────────────────────────────────────────── + print("\n" + "=" * 92) + print("TEIL 2 — HANDELBAR (seq. Sim · Live-Exit · Echtkosten) · Maßstab Squeeze +0,14…+0,23") + def cost(i, atr): return (SP[i] if SP[i] > 0 else 0.0225) / atr + + verdict = defaultdict(list) + for label, lo, hi in (("H1 (alt)", _WIN, mid), ("H2 (neu)", mid, N)): + print(f"\n{label}:") + # (A) Mean-Reversion am POC/HVN — Kurs erreicht die Zone von außen + for zone in ("POC", "HVN"): + Rs = []; i = max(lo, _WIN + 2) + while i < min(hi, N - _MAXH - 2): + atr = A[i] + if not atr or atr < _ATRMIN or lab_at[i] is None: i += 1; continue + # frischer Eintritt in die Zone (vorher NICHT drin) + if lab_at[i] == zone and lab_at[i - 1] != zone: + # Gegenrichtung zur Anlaufbewegung (prallt ab) + d = -1 if C[i] > C[i - 3] else 1 + Rs.append(_sim(C[i], d, atr, H, L, C, i + 1) - cost(i, atr)) + i += _COOL; continue + i += 1 + verdict[f"A {zone}-Reversion"].append(_rep(f"A) Reversion am {zone}", Rs)) + # (B) Momentum durch LVN — Kurs betritt Leerzone, läuft weiter + Rs = []; i = max(lo, _WIN + 2) + while i < min(hi, N - _MAXH - 2): + atr = A[i] + if not atr or atr < _ATRMIN or lab_at[i] is None: i += 1; continue + if lab_at[i] == "LVN" and lab_at[i - 1] != "LVN": + d = 1 if C[i] > C[i - 3] else -1 # in Bewegungsrichtung + Rs.append(_sim(C[i], d, atr, H, L, C, i + 1) - cost(i, atr)) + i += _COOL; continue + i += 1 + verdict["B LVN-Momentum"].append(_rep("B) Momentum durch LVN", Rs)) + + print("\n" + "=" * 92) + print(" URTEIL (trägt nur, wenn ØR in BEIDEN Hälften > 0 UND >= +0,14 = Squeeze-Band):") + any_ok = False + for k, vals in verdict.items(): + if len(vals) != 2 or any(v is None for v in vals): + print(f" {k:<28} unvollständig"); continue + ok = all(v > 0 for v in vals); band = all(v >= 0.14 for v in vals) + mark = "✅ TRÄGT" if (ok and band) else ("⚠ positiv, unter Band" if ok else "❌ fällt durch") + if ok and band: any_ok = True + print(f" {k:<28} H1 {vals[0]:+.3f} · H2 {vals[1]:+.3f} {mark}") + print("\n " + ("→ Kandidat gefunden: Parameter-Robustheit prüfen, dann klein live (B4)." + if any_ok else + "→ KEINE Variante trägt beidhälftig im Squeeze-Band → Volume Profile " + "bleibt Kontext, kein Signal.")) + + +if __name__ == "__main__": + main()