Files
AH-Oil-Trader/backtest_exit_atr_tf.py
T
Axel HocksandClaude Opus 5 010007f7a3 Exit-Abstand aus M15-ATR gemessen = faellt durch, Exit bleibt am M5-ATR
Zweiter Teil der M15-Idee: M5-Einstieg behalten, nur den Exit groeber fuehren.
Schliesst zugleich den seit 31.07. in core/trailing.py offenen Punkt
("bisher misst er nur M5").

  M5-ATR (heute)  H1 +0,219 / PF 1,41 / Worst -2,17
                  H2 +0,117 / PF 1,25 / Worst -2,23
  M15-ATR         H1 +0,096 / PF 1,14 / Worst -4,26
                  H2 +0,019 / PF 1,02 / Worst -6,73

Schlechter in BEIDEN Haelften und das Tail-Risiko verdreifacht sich. Dasselbe
Muster wie bei backtest_sl_basis.py.

Zwei Methoden-Fallen, die die Messung fast entwertet haetten:

1) simulate() normiert R mit DEMSELBEN ATR, den es fuer SL/Trail/TP nutzt. Mit
   dem 1,86x groesseren M15-ATR waere dieselbe Dollar-Bewegung ein kleineres R -
   die M15-Variante haette mechanisch schlechter ausgesehen. Beide werden
   deshalb in M5-Risikoeinheiten zurueckgerechnet.

2) Der erste Lauf sammelte die Entry-Liste ueber exit_fn - das wird von scan()
   bei JEDEM Box-Ausbruch gerufen, nicht nur bei den komprimierten: 519 statt
   132 Einstiege, also die Kontrollgruppe statt des Squeeze. Behoben ueber den
   additiven Rueckruf on_entry(j, d, lvl, atr, komprimiert) in
   core/squeeze_scan.py; Bitgleichheit weiter durch tests/test_squeeze_scan.py
   belegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 15:01:44 +02:00

138 lines
5.9 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
"""Squeeze-Einstieg auf M5, EXIT-Abstände aus dem M15-ATR — trägt das?
ANLASS (2026-08-08). Der M15-EINSTIEG ist gemessen durchgefallen
(`backtest_squeeze_m15.py`: n=33 in H1, Nachbarn kippen). Offen bleibt die
andere Hälfte der Idee: den validierten M5-Einstieg behalten und nur den EXIT
gröber führen. Dafür spricht die Kostenseite (Median Spread/ATR: M15 **0,104**
gegen M5 0,193) und der dokumentierte Befund, dass der Verlust-Hebel im Exit und
im Sizing liegt, nicht im Signal.
⚠ IM CODE STEHT DIESE FRAGE SEIT DEM 31.07. OFFEN — `core/trailing.py`:
„Falls die Staffelung je zurück soll: erst `backtest_trailing.py` auf
M15-/M30-BASIS-Signalen laufen lassen — **bisher misst er nur M5**."
Live nimmt das Trailing den ATR der WELLEN-Zeitebene als Abstandsmaß; die
Heuristik landet häufig auf M15/M30 (31.07.: 19 von 33 Phasen). Welcher ATR das
sein SOLLTE, ist nie gemessen worden.
⚠⚠ DIE FALLE, DIE DIESE MESSUNG SONST WERTLOS MACHT: `simulate()` normiert das
Ergebnis mit DEMSELBEN ATR, den es für SL/Trail/TP benutzt (R = Gewinn/ATR). Mit
dem ~1,8× größeren M15-ATR würde dieselbe Dollar-Bewegung zu einem kleineren R —
die Varianten wären **nicht vergleichbar**, und die M15-Variante sähe
mechanisch schlechter aus. Beide werden deshalb in **M5-Risikoeinheiten**
zurückgerechnet: R_m5 = R_ergebnis × ATR_verwendet / ATR_M5.
⚠ NICHT dasselbe wie `backtest_sl_basis.py` (05.08.): dort wurde nur der
INITIAL-SL variiert (Ergebnis: Wash beim Squeeze, Tail-Risiko ×3, daraufhin
`_calc_sl_tp(atr_tf=M5)` für Squeeze-Trades gebaut). Hier hängt der GESAMTE Exit
am ATR — SL, Trail-Abstand, Breakeven, Lock-Schwellen und TP.
AUFBAU: feste, geteilte Entry-Liste (Squeeze auf M5, dokumentierte Parameter
Box 10 / k 0,1 / Floor 0,12), Touch-Fill, Echtkosten, kanonischer Exit-Kern,
2 Halbjahre. M15-ATR kausal aus den M5-Bars aggregiert.
⚠⚠ ENTSCHEIDUNGSREGEL, VORAB: die M15-Variante muss in BEIDEN Hälften mehr ΣR
(in M5-Einheiten) liefern UND darf den Worst-Case nicht verschlechtern. Ein
breiterer Exit kauft Ertrag sonst mit Tail-Risiko — genau die Abwägung, die bei
`backtest_sl_basis.py` gegen die Umstellung sprach.
Aufruf: python backtest_exit_atr_tf.py [n_M5_bars] (Default 80000)
"""
from __future__ import annotations
import sys
import MetaTrader5 as mt5
from core.exit_model import LIVE, simulate
from core.squeeze_scan import atr_series, scan
_FLOOR = 0.12 # dokumentierter M5-Floor (backtest_squeeze_touchfill.py)
_M15 = 3 # 3 M5-Bars = 1 M15-Bar
def atr_m15_auf_m5(H, L, C):
"""ATR aus M15-Bars, auf den M5-Index abgebildet.
⚠ LOOK-AHEAD-frei: an M5-Index i zählt nur der letzte ABGESCHLOSSENE
M15-Bar. Dieselbe Konstruktion wie `_htf_signs` in `backtest_auto_signal`."""
hh, hl, hc = [], [], []
for s in range(0, len(C) - _M15 + 1, _M15):
hh.append(max(H[s:s + _M15])); hl.append(min(L[s:s + _M15]))
hc.append(C[s + _M15 - 1])
a15 = atr_series(hh, hl, hc)
out = [None] * len(C)
for i in range(len(C)):
j = i // _M15 - 1
out[i] = a15[j] if 0 <= j < len(a15) else None
return out
def kz(Rs, label):
if not Rs:
return f" {label:<26} n=0"
n = len(Rs); w = sum(1 for x in Rs if x > 0); s = sum(Rs)
g = sum(x for x in Rs if x > 0); v = -sum(x for x in Rs if x < 0)
return (f" {label:<26} n={n:>4} Treffer={100*w/n:>3.0f}% ØR={s/n:+.3f} "
f"PF={(g/v if v > 0 else 9.99):>4.2f} ΣR={s:>+6.0f} Worst={min(Rs):+.2f}")
def main():
n = 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)
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]
A5 = atr_series(H, L, C)
A15 = atr_m15_auf_m5(H, L, C)
mid = len(C) // 2
gueltig = [(a15 / a5) for a5, a15 in zip(A5, A15) if a5 and a15 and a5 > 0]
gueltig.sort()
print("=" * 98)
print(f" EXIT-ABSTAND aus M5-ATR gegen M15-ATR — {sym}, {len(C)} M5-Bars")
print(f" Verhältnis ATR_M15/ATR_M5: Median {gueltig[len(gueltig)//2]:.2f}×")
print(" ⚠ Beide Varianten in M5-RISIKOEINHEITEN gerechnet, sonst unvergleichbar")
print("=" * 98)
for nm, lo, hi in (("H1", 40, mid), ("H2", mid, len(C) - LIVE.max_hold - 2)):
# ── EINE feste Entry-Liste (der Einstieg ist in beiden Varianten gleich)
eintraege = []
# ⚠ NUR komprimierte Ausbrueche = die Squeeze-Population. `exit_fn`
# allein taugt dafuer NICHT: es wird bei JEDEM Box-Ausbruch gerufen und
# lieferte still die Kontrollgruppe (519 statt 132 Einstiege).
def sammeln(j, d, lvl, atr, komprimiert, _l=eintraege):
if komprimiert:
_l.append((lvl, d, j + 1))
scan(H, L, C, A5, SP, 1.0, lo, hi, 2.5, box_n=10, k=0.1,
atr_min=_FLOOR, max_hold=LIVE.max_hold, on_entry=sammeln)
print(f"\n ── {nm} ── {len(eintraege)} feste Squeeze-Einstiege, "
f"beide Varianten identisch")
for label, quelle in (("M5-ATR (heute)", A5), ("M15-ATR", A15)):
Rs = []
for entry, d, j0 in eintraege:
i = j0 - 1
a5 = A5[i]
a = quelle[i]
if not a or not a5 or a5 <= 0:
continue
a = max(a, _FLOOR)
R = simulate(entry, d, a, H, L, C, j0, LIVE)
# ⚠ zurueck in M5-Einheiten: R × verwendeter ATR ÷ ATR_M5
r_m5 = R * a / max(a5, _FLOOR)
kosten = (SP[i] if SP[i] > 0 else 0.0225) / max(a5, _FLOOR)
Rs.append(r_m5 - kosten)
print(kz(Rs, label))
if __name__ == "__main__":
main()