SL-Breite gemessen: Confounder bestaetigt, aber keine Aenderung (Regel greift)
Anlass: analyze_manual_close.py fand +1.229 EUR auf der Verlustseite, mit dem explizit benannten Confounder, dass simulate() 2,0xATR_M5 ansetzt waehrend live ATR_M15 gilt. backtest_sl_width.py trennt beides: gegatete Live-Population (1.224 Entries, Reversal aus, k=0,3), kanonischer Exit, variiert wird ausschliesslich die SL-Breite. ATR_M15 / ATR_M5 = 1,80 im Median -> der Live-SL ist rund 3,6xATR_M5 breit, nicht 2,0. Genau daraus entstanden die 1.229 EUR. Confounder bestaetigt. SL-Basis H1 OR/SR H2 OR/SR Worst H1/H2 2,0xATR_M5 -0,092 / -44 -0,024 / -18 -2,33 / -2,29 2,0xATR_M15 LIVE -0,145 / -69 +0,064 / +48 -4,77 / -5,64 1,5xATR_M5 -0,081 / -38 -0,038 / -28 -1,83 / -1,85 2,5xATR_M5 -0,121 / -58 -0,013 / -10 -2,83 / -2,79 3,0xATR_M5 -0,130 / -62 +0,018 / +13 -3,33 / -3,29 KEINE engere Variante schlaegt den Live-SL in BEIDEN Haelften - H2 bevorzugt den weiten Stop klar. Die vorab fixierte Regel ist nicht erfuellt: keine Aenderung. Das reproduziert backtest_sl_tf_mismatch.py (23.07., dort auf Squeeze-Entries mit eigenem Exit-Nachbau) jetzt auf der gegateten Population mit kanonischem Exit - der alte Schluss war korrekt. Der echte Unterschied ist das TAIL-RISIKO und damit eine SIZING-Frage: Worst-Case -5,64xATR (live) gegen -1,85xATR (bei 1,5xATR_M5), Faktor 3. Bei ATR 0,30 sind das ~1,7 $; unter 95 %-Margin auf ~800 EUR Konto ist ein solcher Trade ~19 % des Kontos. Der weite Stop kauft die H2-Performance mit genau diesem Tail. Wer ihn begrenzen will, senkt margin_buffer_pct - nicht die SL-Breite, die kostet gemessen H2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3ec4d53022
commit
20137a3dd1
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SL-Breite: ATR_M15 (live) gegen ATR_M5 (kanonisch) — 2026-08-04.
|
||||
|
||||
Anlass: `analyze_manual_close.py` zeigte, dass die 69 per Broker-SL geschlossenen
|
||||
Trades mit dem kanonischen Exit **1.229 € weniger** verloren hätten. Der dortige
|
||||
Confounder war explizit benannt: `simulate()` setzt den Initial-SL auf
|
||||
**2,0×ATR_M5**, live kommt er aus **ATR_M15** (`_calc_sl_tp`, SL_TF fix M15) und
|
||||
ist damit oft deutlich weiter — unter Margin-Sizing schlägt das voll durch.
|
||||
Diese Messung trennt beides.
|
||||
|
||||
⚠ `backtest_sl_tf_mismatch.py` hat die M5-Umstellung 2026-07-23 schon geprüft und
|
||||
wegen H2 verworfen — aber NUR auf Squeeze-Entries und mit einem eigenen
|
||||
Exit-Nachbau. Hier: die **gegatete Live-Signal-Population** und der **kanonische**
|
||||
Exit. Damit ist es keine Wiederholung, sondern die Messung unter den Bedingungen,
|
||||
unter denen der Bot tatsächlich läuft.
|
||||
|
||||
Variiert wird AUSSCHLIESSLICH die Breite des Initial-SL; Trailing, Breakeven,
|
||||
Time-Stop und TP bleiben identisch — sonst wäre nicht zuzuordnen, was wirkt.
|
||||
Alle Ergebnisse in **ATR_M5-Einheiten**, damit die Varianten vergleichbar sind.
|
||||
|
||||
Aufruf: python backtest_sl_width.py [bars]
|
||||
"""
|
||||
import dataclasses
|
||||
import statistics as stx
|
||||
import sys
|
||||
|
||||
import MetaTrader5 as mt5
|
||||
|
||||
from core.analysis import calc_trend_angle
|
||||
from core.exit_model import LIVE, simulate
|
||||
from core.wave_rec import (WaveRecommender, _ANGLE_LR, _EMA_FAST, _EMA_SLOW,
|
||||
_HTF_DEADBAND, _ema_series)
|
||||
|
||||
_ATRMIN = 0.06
|
||||
_COOL = 3
|
||||
_PIVK = 3
|
||||
_PIV_LOOK = 300
|
||||
|
||||
|
||||
class _TU:
|
||||
def snapshot(self):
|
||||
return {}
|
||||
|
||||
|
||||
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])))
|
||||
return [(sum(t[max(1, i - p + 1):i + 1]) / max(1, len(t[max(1, i - p + 1):i + 1])))
|
||||
if i else None for i in range(len(C))]
|
||||
|
||||
|
||||
def _atr_m15(H, L, C, N):
|
||||
"""ATR_M15 je M5-Bar (kausal): aus 3er-Aggregaten, auf M5 zurückgemappt."""
|
||||
HH = [max(H[i:i + 3]) for i in range(0, N - 2, 3)]
|
||||
LL = [min(L[i:i + 3]) for i in range(0, N - 2, 3)]
|
||||
CC = [C[min(i + 2, N - 1)] for i in range(0, N - 2, 3)]
|
||||
a15 = _atr_series(HH, LL, CC)
|
||||
out = [None] * N
|
||||
for j, v in enumerate(a15):
|
||||
for k in range(j * 3, min(j * 3 + 3, N)):
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _htf_sign(C, i, n, atr):
|
||||
lo = max(0, i - n * (_EMA_SLOW + 5))
|
||||
sub = C[lo:i + 1:n]
|
||||
if len(sub) < _EMA_SLOW + 2 or not atr:
|
||||
return 0
|
||||
d = (_ema_series(sub, _EMA_FAST)[-1] - _ema_series(sub, _EMA_SLOW)[-1]) / atr
|
||||
return 0 if abs(d) < _HTF_DEADBAND else (1 if d > 0 else -1)
|
||||
|
||||
|
||||
def st(Rs):
|
||||
if not Rs:
|
||||
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)
|
||||
return dict(n=n, wr=100 * w / n, oR=s / n, pf=(up / dn if dn > 0 else 9.99),
|
||||
sum=s, worst=min(Rs))
|
||||
|
||||
|
||||
def main():
|
||||
n_req = int(sys.argv[1]) if len(sys.argv) > 1 else 80000
|
||||
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_M5, 0, n_req)
|
||||
point = mt5.symbol_info(sym).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]
|
||||
N = len(C); mid = N // 2
|
||||
A = _atr_series(H, L, C)
|
||||
A15 = _atr_m15(H, L, C, N)
|
||||
EF = _ema_series(C, _EMA_FAST); ES = _ema_series(C, _EMA_SLOW)
|
||||
|
||||
verh = [A15[i] / A[i] for i in range(200, N)
|
||||
if A[i] and A15[i] and A[i] >= _ATRMIN]
|
||||
print(f"ATR_M15 / ATR_M5 — Median {stx.median(verh):.2f} "
|
||||
f"10./90. Perzentil {sorted(verh)[len(verh)//10]:.2f} / "
|
||||
f"{sorted(verh)[9*len(verh)//10]:.2f}")
|
||||
print(f"→ Der Live-SL (2,0×ATR_M15) ist im Median rund "
|
||||
f"{2.0*stx.median(verh):.1f}×ATR_M5 breit, der kanonische 2,0.\n")
|
||||
|
||||
print("Vorberechnung (Winkel, HTF, Pivots) ...", flush=True)
|
||||
AD = [90.0] * N
|
||||
for i in range(_ANGLE_LR + 2, N):
|
||||
AD[i] = calc_trend_angle(C[i - (_ANGLE_LR + 1):i + 1], _ANGLE_LR)
|
||||
HTF = [0] * N; H1 = [0] * N
|
||||
for i in range(N):
|
||||
if A[i]:
|
||||
HTF[i] = _htf_sign(C, i, 6, A[i]); H1[i] = _htf_sign(C, i, 12, A[i])
|
||||
PH = [j for j in range(_PIVK, N - _PIVK) if H[j] == max(H[j - _PIVK:j + _PIVK + 1])]
|
||||
PL = [j for j in range(_PIVK, N - _PIVK) if L[j] == min(L[j - _PIVK:j + _PIVK + 1])]
|
||||
|
||||
w = WaveRecommender(_TU(), mt5.TIMEFRAME_M5)
|
||||
w.set_reversal_enabled(False); w.set_entry_room(0.6)
|
||||
w.set_dead_hours([]); w.set_breakout_k(0.3)
|
||||
|
||||
# Entries EINMAL bestimmen — alle Varianten sehen dieselbe Liste (sonst wäre
|
||||
# nicht der SL verglichen, sondern eine andere Trade-Folge).
|
||||
print("Entries bestimmen ...", flush=True)
|
||||
entries = []
|
||||
pend = None
|
||||
i = _PIV_LOOK + _EMA_SLOW + 5
|
||||
while i < N - LIVE.max_hold - 2:
|
||||
atr = A[i]
|
||||
if not atr or atr < _ATRMIN or not A15[i]:
|
||||
i += 1; continue
|
||||
w._pb_feats = {"atr": max(atr, 0.12)}
|
||||
w._pb_levels = {"ph": [C[j] for j in PH if i - _PIV_LOOK <= j <= i - _PIVK],
|
||||
"pl": [C[j] for j in PL if i - _PIV_LOOK <= j <= i - _PIVK]}
|
||||
rec, _ = w._build(EF[i], ES[i], C[i], atr, "M5", 5,
|
||||
htf_trend=HTF[i], h1_trend=H1[i], angle=AD[i])
|
||||
sig = rec.get("signal")
|
||||
if sig in ("LONG", "SHORT"):
|
||||
d = 1 if sig == "LONG" else -1
|
||||
if pend is None or pend["dir"] != d:
|
||||
pend = {"dir": d, "level": C[i] + d * 0.3 * atr,
|
||||
"invalid": C[i] - d * 0.3 * atr, "confirmed": False, "bar": i}
|
||||
if not pend["confirmed"]:
|
||||
broke = (C[i] >= pend["level"]) if d > 0 else (C[i] <= pend["level"])
|
||||
if broke:
|
||||
pend["confirmed"] = True
|
||||
else:
|
||||
if ((C[i] <= pend["invalid"]) if d > 0 else (C[i] >= pend["invalid"])) \
|
||||
or (i - pend["bar"]) > 12:
|
||||
pend = {"dir": d, "level": C[i] + d * 0.3 * atr,
|
||||
"invalid": C[i] - d * 0.3 * atr,
|
||||
"confirmed": False, "bar": i}
|
||||
i += 1; continue
|
||||
rec = w._room_gate(rec, C[i])
|
||||
if rec.get("signal") in ("LONG", "SHORT"):
|
||||
entries.append((i, d, C[i], atr, A15[i]))
|
||||
pend = None
|
||||
i += LIVE.timestop_bars # fester Vorlauf, für alle Varianten gleich
|
||||
continue
|
||||
elif sig == "WARTEN":
|
||||
pend = None
|
||||
i += 1
|
||||
print(f"{len(entries)} Entries (gegatet, Reversal aus, k=0,3)\n")
|
||||
|
||||
varianten = [
|
||||
("A SL 2,0×ATR_M5 (kanonisch)", lambda a5, a15: 2.0),
|
||||
("B SL 2,0×ATR_M15 (LIVE)", lambda a5, a15: 2.0 * a15 / a5),
|
||||
("C SL 1,5×ATR_M5", lambda a5, a15: 1.5),
|
||||
("D SL 2,5×ATR_M5", lambda a5, a15: 2.5),
|
||||
("E SL 3,0×ATR_M5", lambda a5, a15: 3.0),
|
||||
]
|
||||
print(f"{'Variante':<32}{'Hälfte':<10}{'n':>6}{'WR':>6}{'ØR':>9}{'PF':>7}"
|
||||
f"{'ΣR':>9}{'Worst':>8}")
|
||||
print("-" * 88)
|
||||
for lbl, f in varianten:
|
||||
for hl, lo, hi in (("H1 (alt)", 0, mid), ("H2 (neu)", mid, N)):
|
||||
Rs = []
|
||||
for (i, d, px, a5, a15) in entries:
|
||||
if not (lo <= i < hi):
|
||||
continue
|
||||
p = dataclasses.replace(LIVE, sl_atr=max(0.5, f(a5, a15)))
|
||||
r, _ = simulate(px, d, a5, H, L, C, i + 1, p,
|
||||
timestop=True, use_tp=True, ret_bar=True)
|
||||
Rs.append(r - (SP[i] if SP[i] > 0 else 0.0225) / a5)
|
||||
s = st(Rs)
|
||||
if s:
|
||||
print(f"{lbl:<32}{hl:<10}{s['n']:>6}{s['wr']:>5.0f}%{s['oR']:>+9.3f}"
|
||||
f"{s['pf']:>7.2f}{s['sum']:>+9.0f}{s['worst']:>+8.2f}")
|
||||
print()
|
||||
print(" Regel (vorab): eine engere SL-Basis wird nur empfohlen, wenn sie in BEIDEN")
|
||||
print(" Hälften mehr ΣR liefert UND der Worst-Case nicht schlechter wird.")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user