backtest_candles.py: Kerzen-Anatomie gemessen - Kernaussagen invertiert

User-Vorgabe "Kerzengroesse und Dochtlaenge als Kontext fuer die
Empfehlung". Getestet werden die KONDITIONALEN Behauptungen (nicht
"Kerzenmuster funktionieren", sondern "sie funktionieren im Kontext"):
Docht am Level vs Range-Mitte, Volumen >= 2x, mit/gegen Trend.

Informations-Messung (Forward-Return 60 min in xATR), 80k M5-Bars,
2 Haelften. Wichtig: gerichtete Klassen werden gegen die DRIFT-NULL
(bm x E[d]) gelesen, nicht gegen die Baseline - sonst liest man reine
Regime-Drift als Edge.

Robust in BEIDEN Haelften, aber INVERTIERT gegenueber der Quelle:
 * Langer Koerper -> Fortsetzung: -0,059 / -0,076 (laeuft GEGEN die
   Koerperrichtung). Mit Trend sogar -0,132 / -0,042 -> "im Trend =
   Fortsetzung" ist genau falsch, es ist die Klimax-Kerze.
 * "2x Volumen = echt": -0,117 / -0,204 vs -0,065 / -0,035 bei normalem
   Volumen. Monoton in die falsche Richtung - mehr Volumen = staerkere
   Gegenbewegung.
 * "Docht am Level zuverlaessig": in beiden Haelften SCHLECHTER als in
   der Range-Mitte.
 * mit/gegen Trend beim Docht kippt zwischen den Haelften = Rauschen.

Einziger Lead: Fade eines volumenstarken langen Koerpers, +0,117/+0,204
brutto - noch unter den realen Kosten (0,265xATR), aber erstmals nur um
Faktor ~1,5 statt einer Groessenordnung. Folgetest mit Live-Exit statt
starrem 60-min-Fenster waere der naechste Schritt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Axel Hocks
2026-07-31 08:05:50 +02:00
co-authored by Claude Opus 5
parent 5f70d58828
commit 7873bf2fb7
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env python3
"""Kerzen-Anatomie als Vorhersage — trägt sie, und WANN? (User-Vorgabe 2026-07-31)
Getestet werden die vier KONDITIONALEN Behauptungen des Users (nicht „Kerzenmuster
funktionieren", sondern „sie funktionieren IM KONTEXT"):
1. Langer Docht zählt **am Level**, in der Range-Mitte ist er Noise.
2. Langer Körper **ohne Volumen** ist ein Fake-Out; mit 2× Ø-Volumen ist er echt.
3. Auf M15 sind Dochte Noise — erst H1 bestätigt.
4. Langer Körper GEGEN den Trend = Erschöpfung, MIT dem Trend = Fortsetzung.
METHODE: reine INFORMATIONS-Messung (Forward-Return in ×ATR über N Bars), kein
Exit/Sizing — die Frage ist, ob die Kerzenklasse überhaupt Richtungsinformation
trägt. Erst wenn ja, lohnt ein Trade-Backtest mit Exit+Kosten.
⚠ Die Baseline ist entscheidend: H1 war ein Abwärts-, H2 ein Aufwärts-Regime. Ein
„+0,10 ØR"-Bucket ist wertlos, wenn ALLE Bars in dieser Hälfte +0,10 liefern.
Deshalb wird jeder Bucket **relativ zur Regime-Baseline derselben Hälfte** gelesen
(Lehre aus `backtest_volume_profile.py`).
Urteil „trägt" nur, wenn ein Bucket in BEIDEN Hälften relativ zur Baseline
richtungs-konsistent UND deutlich abweicht.
"""
import sys
from bisect import bisect_left
from statistics import median
import MetaTrader5 as mt5
_FWD = 12 # Forward-Fenster in M5-Bars (1 h) — wie beim P(break)-Training
_ATRMIN = 0.06 # Live-Floor
_M30 = 6
_PIV_K = 3
_PIV_LOOKBACK = 50
_NEAR_ATR = 0.5 # „am Level" = näher als X×ATR
_VOL_N = 20 # Fenster für Ø-Volumen und Ø-Body
def _atr_series(H, L, C, p=14):
t = [0.0]
for i in range(1, len(C)):
t.append(max(H[i] - L[i], abs(H[i] - C[i - 1]), abs(L[i] - C[i - 1])))
out = []
for i in range(len(C)):
if not i:
out.append(None); continue
w = t[max(1, i - p + 1):i + 1]
out.append(sum(w) / max(1, len(w)))
return out
def _ema(v, p):
k = 2.0 / (p + 1); o = []; e = v[0]
for i, x in enumerate(v):
e = x if i == 0 else x * k + e * (1 - k)
o.append(e)
return o
def build_levels(H, L):
"""M30-Pivots k=3, kausal (Pivot erst 3 M30-Bars später bekannt) — dieselbe
Quelle wie der Live-Bot seit 2026-07-30."""
from collections import deque
nm = len(H) // _M30
mh = [max(H[i * _M30:(i + 1) * _M30]) for i in range(nm)]
ml = [min(L[i * _M30:(i + 1) * _M30]) for i in range(nm)]
born = [[] for _ in range(nm)]
for p in range(_PIV_K, nm - _PIV_K):
if mh[p] == max(mh[p - _PIV_K:p + _PIV_K + 1]) and p + _PIV_K < nm:
born[p + _PIV_K].append(mh[p])
if ml[p] == min(ml[p - _PIV_K:p + _PIV_K + 1]) and p + _PIV_K < nm:
born[p + _PIV_K].append(ml[p])
lv = []; win = deque(); cur = []
for m in range(nm):
win.append(born[m]); cur.extend(born[m])
while len(win) > _PIV_LOOKBACK:
for x in win.popleft():
try: cur.remove(x)
except ValueError: pass
lv.append(sorted(cur))
return lv
def classify(o, h, l, c, body_ref):
"""Kerzenklasse + implizierte Richtung.
body_ref = Median-Body der letzten _VOL_N Bars (Massstab für „lang").
Rückgabe: (klasse, richtung) — richtung 0 = keine implizierte Richtung.
"""
rng = h - l
if rng <= 0:
return None, 0
body = abs(c - o)
up_w = h - max(o, c)
lo_w = min(o, c) - l
br = body / rng
d = 1 if c > o else -1 if c < o else 0
long_body = body_ref > 0 and body >= 1.5 * body_ref
if br >= 0.90:
return "Marubozu", d
if br < 0.10:
return "Doji", 0
if br < 0.30 and up_w / rng > 0.30 and lo_w / rng > 0.30:
return "SpinningTop", 0
if up_w / rng >= 0.50:
return "LangerDochtOben", -1 # Ablehnung nach oben → Short impliziert
if lo_w / rng >= 0.50:
return "LangerDochtUnten", 1
if long_body and br >= 0.60:
return "LangerKoerper", d
return None, 0
def st(xs):
if not xs:
return " n=0"
n = len(xs); m = sum(xs) / n
w = sum(1 for x in xs if x > 0)
return f"n={n:>6} ØR={m:+.3f} WR={100*w/n:>3.0f}%"
def main():
n = int(sys.argv[1]) if len(sys.argv) > 1 else 80000
mt5.initialize(); sym = None
for cand in ("SpotCrude", "USOIL", "WTI", "XTIUSD"):
if mt5.symbol_info(cand):
sym = cand; break
bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, n)
mt5.shutdown()
O = [float(b["open"]) 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]
AT = _atr_series(H, L, C)
EF = _ema(C, 12); ES = _ema(C, 50)
LV = build_levels(H, L)
N = len(C); mid = N // 2
halves = [("H1", 100, mid), ("H2", mid, N - _FWD - 1)]
print("=" * 100)
print(f" KERZEN-ANATOMIE als Vorhersage — {sym} M5 ({N} Bars), "
f"Forward {_FWD} Bars = {_FWD*5} min")
print(" Alle Werte in ×ATR. ⚠ Buckets IMMER gegen die Regime-Baseline derselben")
print(" Hälfte lesen (H1/H2 hatten unterschiedliche Drift).")
print("=" * 100)
for lbl, a, b in halves:
# ── Baseline: durchschnittlicher Forward-Return ALLER Bars, long-normiert
base = []
for i in range(a, b):
atr = AT[i]
if not atr:
continue
base.append((C[i + _FWD] - C[i]) / max(atr, _ATRMIN))
bm = sum(base) / len(base) if base else 0.0
print(f"\n{'='*100}\n{lbl} — Regime-Baseline (alle Bars, LONG-Sicht): "
f"ØR={bm:+.3f} (n={len(base)})\n{'='*100}")
# Buckets sammeln
by_cls: dict = {}
by_cond: dict = {}
dsum: dict = {}
dcond: dict = {}
for i in range(a, b):
atr = AT[i]
if not atr:
continue
atr = max(atr, _ATRMIN)
body_ref = median([abs(C[k] - O[k]) for k in range(max(0, i - _VOL_N), i)]) \
if i > _VOL_N else 0.0
cls, d = classify(O[i], H[i], L[i], C[i], body_ref)
if not cls:
continue
fwd = (C[i + _FWD] - C[i]) / atr
# in IMPLIZIERTER Richtung; ohne Richtung (Doji/Spinning) long-normiert
r = fwd * d if d else fwd
# Richtungs-Bilanz mitführen: bei gerichteten Klassen ist die korrekte
# NULL-Linie nicht die Baseline, sondern bm×E[d] — die Regime-Drift hebt
# sich auf, wenn Long-/Short-Kerzen ausgewogen sind. Ohne das liest man
# reine Drift als „Edge" (der Fehler, der `backtest_volume_profile.py`
# fast gekippt hätte).
by_cls.setdefault(cls, []).append(r)
dsum.setdefault(cls, []).append(d)
if d == 0:
continue
# ── Bedingung 1: am Level?
lv = LV[min(i // _M30, len(LV) - 1)]
near = False
if lv:
j = bisect_left(lv, C[i])
for k in (j - 1, j):
if 0 <= k < len(lv) and abs(lv[k] - C[i]) <= _NEAR_ATR * atr:
near = True
# ── Bedingung 2: Volumen
vref = sum(V[max(0, i - _VOL_N):i]) / max(1, len(V[max(0, i - _VOL_N):i]))
hot = vref > 0 and V[i] >= 2.0 * vref
# ── Bedingung 3: mit/gegen M30-Trend (EMA12 vs EMA50 auf M5 als Proxy)
trend = 1 if EF[i] > ES[i] else -1
withtrend = (d == trend)
key = "Docht" if cls.startswith("LangerDocht") else \
("Koerper" if cls in ("LangerKoerper", "Marubozu") else None)
if key:
for cond in ("am Level" if near else "Range-Mitte",
"Vol>=2x" if hot else "Vol normal",
"mit Trend" if withtrend else "gegen Trend"):
by_cond.setdefault((key, cond), []).append(r)
dcond.setdefault((key, cond), []).append(d)
def line(label, xs, ds):
"""Zeigt ØR, die DRIFT-NULL (bm×E[d]) und den Überschuss darüber."""
null = bm * (sum(ds) / len(ds)) if ds else 0.0
m = sum(xs) / len(xs)
print(f" {label:<24} {st(xs)} Null={null:+.3f} Ueberschuss={m-null:+.3f}")
print("\n -- Klassen (in implizierter Richtung; Doji/SpinningTop long-normiert) --")
print(" 'Null' = reine Regime-Drift dieser Klasse; nur der UEBERSCHUSS ist Information.")
for cls in ("LangerKoerper", "Marubozu", "LangerDochtOben", "LangerDochtUnten",
"SpinningTop", "Doji"):
xs = by_cls.get(cls)
if xs:
line(cls, xs, dsum.get(cls, [0] * len(xs)))
print("\n -- Konditional (die 4 User-Behauptungen) --")
for key in ("Docht", "Koerper"):
for cond in ("am Level", "Range-Mitte", "Vol>=2x", "Vol normal",
"mit Trend", "gegen Trend"):
xs = by_cond.get((key, cond))
if xs:
line(f"{key} {cond}", xs, dcond.get((key, cond), [0] * len(xs)))
if __name__ == "__main__":
main()