Files
AH-Oil-Trader/backtest_liquidity_sweep.py
T
Axel HocksandClaude Opus 4.8 919fe61dc3 Entry-Checkliste im Order-Dialog + Liquiditaets-Pfeile rechtsbuendig (v=120, MQL5 v1.14)
1) Entry-Checkliste (User: "blende einen Hinweis ein wenn die Checkliste gegen
   meine Eroeffnung ist"): engine._entry_checklist prueft 7 Punkte mit Live-Daten
   -- Signal-Deckung, HTF-Trend M30+H1, Entry-Raum, News-Konflikt, Kosten,
   Verlust-Serie heute (Mental-Check), Nacht-Stunde. Snapshot checklist{long,short},
   Anzeige im Bestaetigungs-Dialog, blockt nicht. Kommt jetzt auch bei vorhandenem
   Signal (alter Dialog nur bei WARTEN/Gegen-Signal).
   Real beim Einbau: beide Richtungen "stop" (11 Verluste heute = 80% des Kontos).

2) MQL5 v1.14 (User: "blende die liquiditaetslinien aus, nur die Pfeile sollen
   rechtsbuendig angezeigt werden"): LQ-Linien + Text-Labels raus, nur noch der
   Pfeil auf dem Wand-Preis, rechtsbuendig am aktuellen Rand (in RepositionLabels
   nachgefuehrt, bleibt beim Scrollen rechts). Details im Tooltip. Pinke
   Liquiditaets-Trendlinie unveraendert. Kompiliert.

3) backtest_liquidity_sweep.py (SMC Equal Highs/Lows + Swing Liquidity, Multi-TF-
   und Session-Achse): faellt durch -- Details folgen in der Doku.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 18:15:03 +02:00

317 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""backtest_liquidity_sweep.py — Liquiditäts-Sweeps (SMC/ICT) messen (B2).
User-Vorgabe 2026-07-30 (mit Chart-Beispielen): die drei „wichtigsten Liquiditäts-
muster" für WTI in die Gesamtempfehlung aufnehmen —
A) **Equal Highs → Sell-Side-Sweep**: zwei ~gleiche Hochs bilden eine Stop-Zone;
der Kurs schießt KURZ darüber (Sweep), schließt zurück darunter → SHORT.
B) **Equal Lows → Buy-Side-Sweep**: Spiegelbild → LONG.
C) **Swing Liquidity**: markanter Swing-Punkt wird gesweept/retestet, dann Trade
in Richtung des höheren Timeframe-Trends (hier M30-EMA12/50 als HTF-Proxy).
⚠ WARUM das trotz 15 verworfener Signal-Ideen einen eigenen Test verdient: Der
**Sweep ist ein FEHLAUSBRUCH** (Level kurz durchstochen, Schluss zurück im Bereich) —
eine andere Mechanik als die bereits verworfenen Muster (`backtest_doubletop.py` misst
den Nackenlinien-BRUCH, nicht den Fehlausbruch) und als die Struktur-/Volume-Profile-
Klasse. Verwandt ist er mit dem validierten P(break)-Modell: ein Sweep ist genau der
Fall „Level wird berührt/überschritten, hält aber" — dort ist die Vorhersage bereits
belegt (AUC 0,72), allerdings nur als EXIT, nicht als Entry.
Methodik (wie alle Tests hier): Signal-TF M5, sequentielle 1-Positions-Sim, Live-Exit
(SL 2,0×ATR · Trail 1,5 ab 0,3 · Breakeven 1,3 · Time-Stop 120 min), Kosten = echter
Bar-Spread/ATR, 2 Halbjahre. Zusätzlich wird der SMC-eigene Exit geprüft
(Stop über/unter dem Sweep-Docht, Ziel = gegenüberliegende Liquidität), weil das
Setup laut Lehre damit steht und fällt.
Maßstab: der einzige validierte Auto-Entry (Squeeze) liefert ØR +0,14…+0,23 · PF>1
in BEIDEN Hälften. Alles darunter ist kein Kandidat für die Gesamtempfehlung.
Die vom User nachgereichten WTI-Praxisregeln sind als ACHSEN eingebaut, statt sie
zu glauben:
· **Multi-TF** („Liquidität auf 4H/1H identifizieren, Entry auf 15M/5M") → die
Level-TF ist variabel (M5 / M30 / H1). Passt zum eigenen Befund von 2026-07-30
(`backtest_level_tf.py`): M30-Level schlagen M5-Level deutlich.
· **Session-Timing** („Sweeps um 8:3010:30 NY = 14:3016:30 Berlin, oder 24 Uhr
asiatische Liquiditätsjagd") → Session-Filter als eigene Variante.
· **„Nicht jeder Sweep dreht — warte auf den Close zurück"** → ist die harte
Bedingung dieses Tests (Close muss im selben Bar zurück in der Zone sein).
· **DXY-Korrelation** → NICHT eingebaut: die Inter-Market-Klasse (Brent/DXY) wurde
2026-07-19 gemessen und verworfen (`backtest_intermarket.py`, kippt zwischen den
Hälften). Ohne neuen Beleg keine zweite Runde.
· **„Sweep-Wick 0,501,00 $, kleiner sizen"** → im Test implizit über den
SMC-Exit (Stop am Docht) abgebildet; Sizing ist hier neutral (R-Rechnung).
Aufruf: python backtest_liquidity_sweep.py [n_bars]
"""
from __future__ import annotations
import sys
import MetaTrader5 as mt5
# ── Muster-Parameter (bewusst wenige, sonst Overfitting) ─────────────────────
_PIV_K = 3 # Swing-Pivot-Fenster
_EQ_TOL = 0.15 # „gleich" = beide Hochs/Tiefs innerhalb x×ATR
_EQ_MINGAP = 3 # min. Bars zwischen den beiden Pivots (sonst dieselbe Zacke)
_EQ_MAXGAP = 60 # max. Bars — danach ist die Zone kalt
_SWEEP_MIN = 0.05 # Sweep muss das Level um ≥ x×ATR überschießen (echter Stop-Grab)
_SWEEP_MAX = 1.00 # aber nicht mehr als x×ATR (sonst echter Ausbruch, kein Sweep)
_BACK_BARS = 3 # innerhalb so vieler Bars muss der Close zurück in die Zone
_LOOKBACK = 300 # Pivot-Suchfenster
_COOL = 12 # Cooldown zwischen Trades (Bars)
# Session-Fenster in BERLIN-Stunden (Broker ist UTC+3 → Berlin = Broker 1 h).
# NY 8:3010:30 ET ≈ 14:3016:30 Berlin (EIA/Market-Open), Asia-Jagd 24 Uhr.
_SESS = {"NY": (14, 15, 16), "Asia": (2, 3, 4), "NY+Asia": (2, 3, 4, 14, 15, 16)}
_LEVEL_TFS = (("M5", 1), ("M30", 6), ("H1", 12)) # Level-Quelle (Multi-TF-Regel)
# ── Exit (Live-Modell, identisch zu den anderen Backtests) ───────────────────
_SL_ATR, _TRAIL, _TRAIL_ON, _BE_ON, _TIMESTOP, _MAXH = 2.0, 1.5, 0.3, 1.3, 24, 288
_ATRMIN = 0.06
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 _ema_series(v, p):
k = 2.0 / (p + 1); o = [v[0]]
for x in v[1:]:
o.append(x * k + o[-1] * (1 - k))
return o
def _sim_live(entry, d, atr, H, L, C, j0):
"""Live-Exit: SL 2×ATR, Trailing 1,5 ab 0,3, Breakeven 1,3, Time-Stop."""
eff = entry - d * _SL_ATR * atr; hw = entry; started = False
end = min(j0 + _MAXH, len(C) - 1); px = C[end]
for j in range(j0, end + 1):
if (L[j] <= eff) if d > 0 else (H[j] >= eff): 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: px = C[j]; break
return (px - entry) * d / atr
def _sim_smc(entry, d, atr, stop, target, H, L, C, j0):
"""SMC-Exit laut Lehre: Stop über/unter dem Sweep-Docht, Ziel = gegenüber-
liegende Liquidität. Ohne Ziel → Live-Exit-Fallback."""
if target is None:
return _sim_live(entry, d, atr, H, L, C, j0)
end = min(j0 + _MAXH, len(C) - 1); px = C[end]
for j in range(j0, end + 1):
hit_stop = (L[j] <= stop) if d > 0 else (H[j] >= stop)
hit_tgt = (H[j] >= target) if d > 0 else (L[j] <= target)
# konservativ: bei beidem im selben Bar zählt der Stop
if hit_stop: px = stop; break
if hit_tgt: px = target; break
return (px - entry) * d / atr
def _rep(name, Rs):
if not Rs or len(Rs) < 10:
print(f" {name:<40} 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:<40} n={n:>4} WR={100*w/n:>3.0f}% ØR={s/n:+.3f} "
f"PF={pf:.2f} ΣR={s:+.0f} worst={min(Rs):+.1f}")
return s / n
def find_pivots(H, L, step=1):
"""Pivots auf der aggregierten Level-TF; Index = M5-Index, ab dem der Pivot
BEKANNT ist (Bestätigung braucht k Bars danach) → kein Look-ahead."""
if step == 1:
ph = [(i, H[i]) for i in range(_PIV_K, len(H) - _PIV_K)
if H[i] == max(H[i - _PIV_K:i + _PIV_K + 1])]
pl = [(i, L[i]) for i in range(_PIV_K, len(L) - _PIV_K)
if L[i] == min(L[i - _PIV_K:i + _PIV_K + 1])]
return ph, pl
hh, ll = [], []
for s0 in range(0, len(H) - step + 1, step):
hh.append(max(H[s0:s0 + step])); ll.append(min(L[s0:s0 + step]))
ph, pl = [], []
for j in range(_PIV_K, len(hh) - _PIV_K):
avail = (j + _PIV_K + 1) * step
if hh[j] == max(hh[j - _PIV_K:j + _PIV_K + 1]): ph.append((avail, hh[j]))
if ll[j] == min(ll[j - _PIV_K:j + _PIV_K + 1]): pl.append((avail, ll[j]))
return ph, pl
def scan(H, L, C, SP, A, EF30, ES30, lo_i, hi_i, mode, smc_exit,
step=1, hours=None, BH=None):
"""mode: 'eqh' (Equal Highs→Short), 'eql' (Equal Lows→Long), 'swing' (HTF-Trend).
`step` = Level-TF (1=M5, 6=M30, 12=H1) · `hours` = erlaubte Berlin-Stunden
(None = alle). Ein Pivot ist erst ab (idx+k+1) bekannt → kein Look-ahead."""
ph, pl = find_pivots(H, L, step)
Rs = []
def cost(i, atr): return (SP[i] if SP[i] > 0 else 0.0225) / atr
i = max(lo_i, _LOOKBACK)
hi_i = min(hi_i, len(C) - _MAXH - 2)
while i < hi_i:
atr = A[i]
if not atr or atr < _ATRMIN: i += 1; continue
if hours is not None and BH is not None and BH[i] not in hours:
i += 1; continue
tol = _EQ_TOL * atr
# ── Liquiditätszone bestimmen ────────────────────────────────────────
level = None; d = 0; zone_lo = None
if mode in ("eqh", "swing"):
# bekannte Hoch-Pivots im Fenster (bestätigt vor i)
hs = [(ix, p) for ix, p in ph if i - _LOOKBACK <= ix <= i - _PIV_K - 1]
if mode == "eqh" and len(hs) >= 2:
# zwei ~gleiche Hochs mit passendem Abstand
for a in range(len(hs) - 1, 0, -1):
for b in range(a - 1, -1, -1):
gap = hs[a][0] - hs[b][0]
if gap < _EQ_MINGAP: continue
if gap > _EQ_MAXGAP: break
if abs(hs[a][1] - hs[b][1]) <= tol:
level = max(hs[a][1], hs[b][1]); d = -1; break
if level is not None: break
elif mode == "swing" and hs:
level = hs[-1][1]; d = -1
if mode in ("eql", "swing") and level is None:
ls = [(ix, p) for ix, p in pl if i - _LOOKBACK <= ix <= i - _PIV_K - 1]
if mode == "eql" and len(ls) >= 2:
for a in range(len(ls) - 1, 0, -1):
for b in range(a - 1, -1, -1):
gap = ls[a][0] - ls[b][0]
if gap < _EQ_MINGAP: continue
if gap > _EQ_MAXGAP: break
if abs(ls[a][1] - ls[b][1]) <= tol:
level = min(ls[a][1], ls[b][1]); d = 1; break
if level is not None: break
elif mode == "swing" and ls:
level = ls[-1][1]; d = 1
if level is None or d == 0: i += 1; continue
# ── Sweep prüfen: Bar i durchsticht das Level, Close kommt zurück ────
if d < 0: # Equal Highs: High über Level, Close zurück darunter
over = H[i] - level
swept = (_SWEEP_MIN * atr <= over <= _SWEEP_MAX * atr) and C[i] < level
wick = H[i]
else: # Equal Lows: Low unter Level, Close zurück darüber
over = level - L[i]
swept = (_SWEEP_MIN * atr <= over <= _SWEEP_MAX * atr) and C[i] > level
wick = L[i]
if not swept:
i += 1; continue
# „Close zurück in die Zone" darf auch 1_BACK_BARS Bars dauern; hier ist er
# bereits im Sweep-Bar erfüllt (strengste, sauberste Variante).
# Swing-Modus: nur MIT dem HTF-Trend (M30-EMA12/50) handeln
if mode == "swing":
htf = 1 if EF30[i] > ES30[i] else -1 if EF30[i] < ES30[i] else 0
if htf != d: i += 1; continue
entry = C[i]
stop = wick + d * -1 * (0.05 * atr) # knapp jenseits des Sweep-Dochts
stop = (wick - 0.05 * atr) if d > 0 else (wick + 0.05 * atr)
# Ziel = gegenüberliegende Liquidität (nächster Pivot in Trade-Richtung)
if d < 0:
cands = [p for ix, p in pl if i - _LOOKBACK <= ix <= i - _PIV_K - 1
and p < entry - 0.3 * atr]
target = max(cands) if cands else None
else:
cands = [p for ix, p in ph if i - _LOOKBACK <= ix <= i - _PIV_K - 1
and p > entry + 0.3 * atr]
target = min(cands) if cands else None
R = (_sim_smc(entry, d, atr, stop, target, H, L, C, i + 1) if smc_exit
else _sim_live(entry, d, atr, H, L, C, i + 1)) - cost(i, atr)
Rs.append(R)
i += _COOL
return Rs
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]; SP = [float(b["spread"]) * point for b in bars]
A = _atr_series(H, L, C)
# HTF-Proxy für den Swing-Modus: M30 aus M5 aggregiert (6 Bars), auf M5 gemappt
c30 = [C[s + 5] for s in range(0, len(C) - 5, 6)]
e12 = _ema_series(c30, 12); e50 = _ema_series(c30, 50)
EF30 = [0.0] * len(C); ES30 = [0.0] * len(C)
for i in range(len(C)):
j = i // 6 - 1
if 0 <= j < len(e12): EF30[i] = e12[j]; ES30[i] = e50[j]
# Berlin-Stunde je Bar (Broker UTC+3 → Berlin = Broker 1 h, Sommerzeit)
from datetime import datetime, timezone
BH = [(datetime.fromtimestamp(int(b["time"]), timezone.utc).hour - 1) % 24
for b in bars]
N = len(C); mid = N // 2
print("=" * 96)
print(f" LIQUIDITÄTS-SWEEPS (SMC) — {sym} M5 ({N} Bars, 2 Halbjahre)")
print(f" Sweep = Level um {_SWEEP_MIN}{_SWEEP_MAX}×ATR durchstochen, Close zurück in der Zone")
print(f" Maßstab: validierter Squeeze ØR +0,14…+0,23 · PF>1 in BEIDEN Hälften")
print("=" * 96)
verdict = {}
MODES = (("eqh", "A) Equal Highs -> SHORT"),
("eql", "B) Equal Lows -> LONG"),
("swing", "C) Swing-Liquidity (HTF)"))
# ── Achse 1: LEVEL-TF (Multi-TF-Regel „Liquidität auf höherem TF") × Exit ──
for tf_lbl, step in _LEVEL_TFS:
print("\n" + "=" * 96)
print(f"### LEVEL-TF {tf_lbl} — Liquidität auf {tf_lbl} identifiziert, Entry auf M5")
for exit_lbl, smc in (("Live-Exit", False), ("SMC-Exit", True)):
print(f"\n --- EXIT: {exit_lbl} ---")
for mode, mlabel in MODES:
for hl, lo, hi in (("H1", _LOOKBACK, mid), ("H2", mid, N)):
Rs = scan(H, L, C, SP, A, EF30, ES30, lo, hi, mode, smc, step)
oR = _rep(f"{mlabel} [{hl}]", Rs)
verdict.setdefault((f"{tf_lbl}/{exit_lbl}", mlabel), []).append(oR)
# ── Achse 2: SESSION-Filter (Level-TF M30, Live-Exit) ────────────────────
print("\n" + "=" * 96)
print("### SESSION-FILTER (Level-TF M30, Live-Exit) — „Sweeps passieren um 1416 / 24 Uhr\"")
for sess, hours in _SESS.items():
print(f"\n --- Session: {sess} (Berlin-Stunden {hours}) ---")
for mode, mlabel in MODES[:2]:
for hl, lo, hi in (("H1", _LOOKBACK, mid), ("H2", mid, N)):
Rs = scan(H, L, C, SP, A, EF30, ES30, lo, hi, mode, False, 6, hours, BH)
oR = _rep(f"{mlabel} [{hl}]", Rs)
verdict.setdefault((f"M30/Sess-{sess}", mlabel), []).append(oR)
print("\n" + "=" * 96)
print(" URTEIL (Kandidat für die Gesamtempfehlung nur, wenn ØR in BEIDEN Hälften")
print(" > 0 UND ≥ +0,14 = Squeeze-Band):")
any_ok = False
for (ex, m), vals in verdict.items():
if len(vals) != 2 or any(v is None for v in vals):
print(f" {m:<38} {ex[:22]:<24} 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" {m:<38} {ex[:22]:<24} H1 {vals[0]:+.3f} · H2 {vals[1]:+.3f} {mark}")
print("\n " + ("→ Kandidat gefunden: Parameter-Robustheit prüfen, dann als Verdict-Modul."
if any_ok else
"→ KEINE Variante trägt beidhälftig im Squeeze-Band → nicht in die "
"Gesamtempfehlung (höchstens als Anzeige)."))
if __name__ == "__main__":
main()