Files
AH-Oil-Trader/backtest_trailing_sr.py
Axel HocksandClaude Opus 5 5f70d58828 Nachtest S/R-Trailing auf Squeeze-Entries: klar schlechter, Frage geschlossen
backtest_trailing_sr.py --squeeze: gleiche Sim, aber nur SQUEEZE-Ausbrueche
(Regel exakt wie live via core.wave_rec _SQ_N/_SQ_MULT/_SQ_K, Einstieg am
Ausbruchslevel, Echtkosten aus dem Bar-Spread).

Basis H1 +0,102 / H2 +0,108 - KEINE Variante besteht. Anders als im
Wave-Lauf ist das kein Rauschen: der reine S/R-Trail wird monoton
schlechter, je breiter der Puffer (0,10 -> 0,25 -> 0,50, in BEIDEN
Haelften). Mechanismus: der Squeeze lebt von wenigen Laeufern (43 %
Treffer, Ertrag im Tail) - ein ans naechste Level gebundener Stop holt
genau dort raus, wo der Lauf beginnt. Bei Wave-Signalen faellt das kaum
auf, daher das umgekehrte Vorzeichen zwischen beiden Laeufen.

Staerkster Gewinner-Kappen-Beleg des Projekts: die Kontrollvariante
(fester TP am Gegenlevel) dreht den Squeeze von +0,102/+0,108 auf
-0,066/-0,004 = Edge vollstaendig zerstoert.

Abgrenzung: der laufende S/R-Auto-Close ist P(break)-GEGATED und war auf
Squeeze-Trades separat ein Wash (backtest_squeeze_srclose.py). Das Gate
ist der Unterschied zwischen "Wash" und "Edge zerstoert".

Damit ist die User-Idee in allen drei Teilen durchgemessen; der
Live-Exit-Stack bleibt unveraendert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 07:54:10 +02:00

308 lines
12 KiB
Python
Raw Permalink 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
"""S/R-verankertes TRAILING statt fixem ATR-Abstand (User-Idee 2026-07-31).
FRAGE: Der Live-Trail zieht den SL stur `mult×ATR` hinter das Hoch. Wäre es besser,
ihn hinter das **nächste S/R-Level** zu legen (LONG: knapp unter die Unterstützung)?
Und/oder einen TP ans Gegenlevel zu setzen?
⚠ ABGRENZUNG — zwei der drei Teil-Ideen sind SCHON gemessen und durchgefallen:
* **Initial-SL an S/R** → `backtest_sl_method.py`: gedeckelt identisch zu fix
2,0×ATR (Ø-R 0,1960,201), der Band-Cap dominiert; ungedeckelt Worst 11,4×ATR.
* **Fester TP am Gegenlevel** → `backtest_srclose.py` (2×): verdoppelt die WR,
verschlechtert aber PF/ΣR in BEIDEN Hälften = Gewinner-Kappen.
* **TRAILING-SL an S/R** = hier NEU gemessen (bisher nur ATR-Abstände getestet,
`backtest_trailing.py`).
Deshalb wird der TP-Teil hier NUR als Kontrollvariante mitgeführt (Erwartung: fällt
durch — dient als Sanity-Check, dass die Sim mit dem alten Befund übereinstimmt).
AUFBAU (identisch zu `backtest_trailing.py`, damit die Zahlen vergleichbar sind):
echte Phasen-Mechanik (Init→Trail→Lock, HW-Ratsche, Breakeven-Boden 1,3), Signal-Set
Trend+Reversal, 2 Halbjahre. Level-Quelle = **M30-Pivots** (k=3) — dieselbe Quelle,
auf die der Live-Bot am 2026-07-30 umgestellt hat; kausal (ein Pivot ist erst 3 M30-
Bars später bekannt).
Urteil nur „trägt", wenn eine Variante in BEIDEN Hälften ≥ Live-Basis liegt UND der
Worst-Case nicht davonläuft (die Lehre aus `backtest_sl_method.py`).
"""
import sys
from bisect import bisect_left
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (_EMA_FAST, _EMA_SLOW, _N_BARS, _ANGLE_LR, _ANGLE_DEAD,
_REVERSAL_STRETCH, _STRETCH_MAX)
_MAXH = 200; _ATRMIN = 0.12; _TP_INIT = 3.5
_LOCK_START = 3.5; _LOCK_SCALE = 0.6; _LOCK_MIN = 1.2
_BE = 1.3 # Breakeven-Boden (live)
_M30 = 6 # M5-Bars je M30-Bar
_PIV_K = 3 # Pivot-Fenster (wie live `pb_levels30`)
_PIV_LOOKBACK = 50 # letzte N M30-Bars (wie live)
# Sicherheits-Deckel für den S/R-Trail: ohne den droht der 11,4×ATR-Fall aus
# `backtest_sl_method.py` (Level zu weit weg = Kontokiller unter Margin-Sizing).
_SL_MIN_ATR = 0.5
_SL_MAX_ATR = 2.0
def _ema_series(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 _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 build_levels(H, L):
"""M30-Pivots (k=3) je M5-Bar-Index — KAUSAL.
Liefert `lv[m]` = sortierte Level-Liste, die zum Beginn von M30-Bar `m` bereits
bekannt ist (ein Pivot bei M30-Index p ist erst ab p+k bestätigt).
"""
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)]
# Pivot bei p → bekannt ab p + _PIV_K
born: list[list[float]] = [[] for _ in range(nm)]
for p in range(_PIV_K, nm - _PIV_K):
seg_h = mh[p - _PIV_K:p + _PIV_K + 1]
seg_l = ml[p - _PIV_K:p + _PIV_K + 1]
if mh[p] == max(seg_h):
if p + _PIV_K < nm:
born[p + _PIV_K].append(mh[p])
if ml[p] == min(seg_l):
if p + _PIV_K < nm:
born[p + _PIV_K].append(ml[p])
# rollierendes Fenster der letzten _PIV_LOOKBACK M30-Bars
lv: list[list[float]] = []
from collections import deque
win: deque = deque()
cur: list[float] = []
for m in range(nm):
win.append(born[m])
cur.extend(born[m])
while len(win) > _PIV_LOOKBACK:
old = win.popleft()
for x in old:
try:
cur.remove(x)
except ValueError:
pass
lv.append(sorted(cur))
return lv
def _sr_below(levels, px):
i = bisect_left(levels, px)
return levels[i - 1] if i > 0 else None
def _sr_above(levels, px):
i = bisect_left(levels, px)
return levels[i] if i < len(levels) else None
def sim(entry, d, atr, H, L, C, j0, LV, mode="atr", mult=1.5,
buf=0.25, trail_start=0.3, use_sr_tp=False):
"""Phasen-Trailing. `mode`:
'atr' — Live: SL = HW mult×ATR
'sr' — SL = nächstes Level in Gegenrichtung ∓ buf×ATR (auf [0,5..2,0]×ATR gedeckelt)
'hybrid' — der ENGERE von beiden (schützt Gewinne früher)
'sr_wide'— der WEITERE von beiden (gibt dem Trade mehr Luft)
"""
sl = entry - d * 2.0 * atr # Initial-SL wie live (2,0×ATR)
tp = entry + d * _TP_INIT * atr
if use_sr_tp:
lv = LV[min(j0 // _M30, len(LV) - 1)]
t = _sr_above(lv, entry) if d > 0 else _sr_below(lv, entry)
if t is not None:
tp = t
hw = entry; rank = 0
end = min(j0 + _MAXH, len(C) - 1)
for j in range(j0, end + 1):
hi, lo = H[j], L[j]
if (lo <= sl) if d > 0 else (hi >= sl):
return (sl - entry) * d / atr # pessimistisch: SL vor TP im selben Bar
if (hi >= tp) if d > 0 else (lo <= tp):
return (tp - entry) * d / atr
hw = max(hw, hi) if d > 0 else min(hw, lo)
profit = (hw - entry) * d
ph = 0 if profit < trail_start * atr else (1 if profit < _LOCK_START * atr else 2)
if ph < rank:
ph = rank
rank = ph
if ph == 0:
continue
m = mult if ph == 1 else max(_LOCK_MIN, mult * _LOCK_SCALE)
cand_atr = hw - d * m * atr
cand = cand_atr
if mode != "atr":
lv = LV[min(j // _M30, len(LV) - 1)]
lev = _sr_below(lv, C[j]) if d > 0 else _sr_above(lv, C[j])
if lev is not None:
cand_sr = lev - d * buf * atr
# Deckel: nie enger als 0,5×ATR (Rauschen) / weiter als 2,0×ATR (Tail)
dist = (C[j] - cand_sr) * d
if dist < _SL_MIN_ATR * atr:
cand_sr = C[j] - d * _SL_MIN_ATR * atr
elif dist > _SL_MAX_ATR * atr:
cand_sr = C[j] - d * _SL_MAX_ATR * atr
if mode == "sr":
cand = cand_sr
elif mode == "hybrid": # der engere = höher (LONG)
cand = max(cand_atr, cand_sr) if d > 0 else min(cand_atr, cand_sr)
elif mode == "sr_wide":
cand = min(cand_atr, cand_sr) if d > 0 else max(cand_atr, cand_sr)
if profit >= _BE * atr or ph == 2:
cand = max(cand, entry) if d > 0 else min(cand, entry)
sl = max(sl, cand) if d > 0 else min(sl, cand)
return (C[end] - entry) * d / atr
def st(Rs):
if not Rs:
return " -"
n = len(Rs); w = sum(1 for r in Rs if r > 0)
g = sum(r for r in Rs if r > 0); ls = -sum(r for r in Rs if r < 0)
return (f"WR={100*w/n:>3.0f}% Ø-R={sum(Rs)/n:+.3f} PF={(g/ls if ls>0 else 99):>4.2f} "
f"Worst={min(Rs):+.2f} ΣR={sum(Rs):+.0f}")
def squeeze_signals(H, L, C, AT, lo, hi):
"""Nur SQUEEZE-Ausbrüche (das einzige validierte Auto-Setup) — Nachtest
2026-07-31: der Squeeze ist runner-abhängig (nur 43 % Treffer, der Ertrag
kommt aus wenigen Läufern), dort könnte ein level-verankerter Stop mehr
bewegen als über ALLE Wave-Signale gemittelt.
Regel exakt wie live (`wave._squeeze_one`): Box = Spanne der letzten _SQ_N
abgeschlossenen M5-Bars ≤ _SQ_MULT×ATR, Ausbruch um _SQ_K×ATR. Einstieg AM
Ausbruchslevel (nicht am Bar-Close), Cooldown wie in den Squeeze-Backtests.
"""
from core.wave_rec import _SQ_N, _SQ_MULT, _SQ_K
_W = 24; _COOL = 12
out = []
i = max(lo, _N_BARS, _SQ_N + 15)
while i < min(hi, len(C) - _MAXH - 1):
atr = AT[i]
if not atr or atr < _ATRMIN:
i += 1; continue
boxHi = max(H[i - _SQ_N:i]); boxLo = min(L[i - _SQ_N:i])
if (boxHi - boxLo) > _SQ_MULT * atr: # keine Kompression
i += 1; continue
hit = None
for j in range(i, min(i + _W, len(C) - _MAXH - 1)):
up = boxHi + _SQ_K * atr; dn = boxLo - _SQ_K * atr
if H[j] >= up:
hit = (j, 1, up); break
if L[j] <= dn:
hit = (j, -1, dn); break
if hit is None:
i += 1; continue
j, d, lvl = hit
out.append((j, d, atr, lvl))
i = j + _COOL
return out
def signals(H, L, C, ES, EF, AT, lo, hi, step):
TH = _REVERSAL_STRETCH; out = []
for i in range(max(lo, _N_BARS), min(hi, len(C) - _MAXH - 1), step):
atr = AT[i]
if not atr or atr <= 0:
continue
atr = max(atr, _ATRMIN); es = ES[i]; ef = EF[i]
stretch = (C[i] - es) / atr
ang = calc_trend_angle(C[i - _ANGLE_LR - 2:i], _ANGLE_LR); ad = ang - 90.0
d = 0
if stretch <= -TH and ad >= _ANGLE_DEAD:
d = 1
elif stretch >= TH and ad <= -_ANGLE_DEAD:
d = -1
elif abs(stretch) < _STRETCH_MAX:
d = 1 if ef > es else -1 if ef < es else 0
if d:
out.append((i, d, atr))
return out
def main():
squeeze = "--squeeze" in sys.argv
args = [a for a in sys.argv[1:] if not a.startswith("--")]
n = int(args[0]) if args else 80000
step = int(args[1]) if len(args) > 1 else 2
mt5.initialize(); sym = None
for c in ("SpotCrude", "USOIL", "WTI", "XTIUSD"):
if mt5.symbol_info(c):
sym = c; break
bars = None
for req in (n, 100000, 80000, 60000, 40000):
bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, req)
if bars is not None and len(bars) > 2000:
break
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]
EF = _ema_series(C, _EMA_FAST); ES = _ema_series(C, _EMA_SLOW); AT = _atr_series(H, L, C)
LV = build_levels(H, L)
mid = len(C) // 2
halves = [("H1 (alt)", _N_BARS, mid), ("H2 (neu)", mid, len(C))]
print("=" * 96)
kind = "NUR SQUEEZE-Ausbrüche (Echtkosten)" if squeeze else "Signal = Trend+Reversal"
print(f" S/R-VERANKERTES TRAILING — {sym} M5 ({len(C)} Bars) {kind}")
print(f" Level = M30-Pivots k={_PIV_K}, Fenster {_PIV_LOOKBACK} M30-Bars (kausal), "
f"Deckel [{_SL_MIN_ATR}..{_SL_MAX_ATR}]×ATR")
if squeeze:
print(" Erwartung Squeeze-Entry-Edge (validiert): ØR +0,14…+0,23 · PF>1")
print("=" * 96)
if squeeze:
SIG = {lbl: squeeze_signals(H, L, C, AT, a, b) for lbl, a, b in halves}
else:
SIG = {lbl: [(i, d, atr, C[i]) for (i, d, atr)
in signals(H, L, C, ES, EF, AT, a, b, step)] for lbl, a, b in halves}
for lbl, _, _ in halves:
print(f" {lbl}: {len(SIG[lbl])} Signale")
def cost(i, atr):
"""Echte Kosten = Bar-Spread/ATR (nur im Squeeze-Modus; für den reinen
VARIANTEN-Vergleich neutral, da alle Varianten dieselben Entries teilen —
macht das ØR aber mit der +0,14…+0,23-Erwartung vergleichbar."""
if not squeeze:
return 0.0
return (SP[i] if SP[i] > 0 else 0.0225) / atr
variants = [
("LIVE ATR-Trail 1,5 (Basis)", dict(mode="atr", mult=1.5)),
("S/R-Trail Puffer 0,10", dict(mode="sr", buf=0.10)),
("S/R-Trail Puffer 0,25", dict(mode="sr", buf=0.25)),
("S/R-Trail Puffer 0,50", dict(mode="sr", buf=0.50)),
("Hybrid (engerer von beiden)", dict(mode="hybrid", buf=0.25)),
("Hybrid weit (weiterer)", dict(mode="sr_wide", buf=0.25)),
("KONTROLLE: TP am Gegenlevel", dict(mode="atr", mult=1.5, use_sr_tp=True)),
]
for name, kw in variants:
print(f"\n{name}")
for lbl, _, _ in halves:
Rs = [sim(entry, d, atr, H, L, C, i + 1, LV, **kw) - cost(i, atr)
for (i, d, atr, entry) in SIG[lbl]]
print(f" {lbl} {st(Rs)}")
if __name__ == "__main__":
main()