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>
This commit is contained in:
Axel Hocks
2026-08-08 15:01:44 +02:00
co-authored by Claude Opus 5
parent b5eff2a5d6
commit 010007f7a3
3 changed files with 175 additions and 1 deletions
+27
View File
@@ -4942,6 +4942,33 @@ Modell schliesst keine Trades.
„die Rangfolge ist kaputt" ist **nicht** belegt; belegt ist nur die „die Rangfolge ist kaputt" ist **nicht** belegt; belegt ist nur die
Basisraten-/Kalibrierungslücke. Basisraten-/Kalibrierungslücke.
## ⚠⚠ EXIT-ABSTAND AUS M15-ATR = FÄLLT DURCH (2026-08-08)
Zweiter Teil der M15-Idee: M5-Einstieg behalten, nur den EXIT gröber führen.
Gemessen (`backtest_exit_atr_tf.py`, 80k M5, feste geteilte Entry-Liste,
kanonischer Exit, Echtkosten). Schliesst zugleich den seit dem 31.07. in
`core/trailing.py` offenen Punkt („bisher misst er nur M5").
| | H1 ØR / PF / Worst | H2 ØR / PF / Worst |
|---|---|---|
| **M5-ATR (heute)** | **+0,219 / 1,41 / 2,17** | **+0,117 / 1,25 / 2,23** |
| M15-ATR (1,86× breiter) | +0,096 / 1,14 / **4,26** | +0,019 / 1,02 / **6,73** |
**Schlechter in BEIDEN Hälften — und das Tail-Risiko verdreifacht sich.**
Dasselbe Muster wie bei `backtest_sl_basis.py`: ein breiterer Exit kauft nichts
und kostet Tail. **Der Exit bleibt am M5-ATR.**
⚠⚠ **METHODEN-FALLE, die die Messung fast entwertet hätte** — zweimal:
**(1)** `simulate()` normiert R mit DEMSELBEN ATR, den es für SL/Trail/TP nutzt.
Mit dem 1,86× grösseren M15-ATR wäre dieselbe Dollar-Bewegung ein kleineres R —
die M15-Variante hätte mechanisch schlechter ausgesehen. Beide werden deshalb in
**M5-Risikoeinheiten** zurückgerechnet (`R × ATR_verwendet / ATR_M5`).
**(2)** Der erste Lauf sammelte die Entry-Liste über `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
über den additiven Rückruf **`on_entry(j, d, lvl, atr, komprimiert)`** in
`core/squeeze_scan.py` (Bitgleichheit weiter durch `tests/test_squeeze_scan.py`
belegt). ⚠ **Ein Haken, der mehr liefert als gedacht, ist eine stille
Populations-Änderung.**
## ⚠⚠ SQUEEZE AUF M15 = FÄLLT DURCH — kein M15-Modul gebaut (2026-08-08) ## ⚠⚠ SQUEEZE AUF M15 = FÄLLT DURCH — kein M15-Modul gebaut (2026-08-08)
Anlass: User „baue mir das M15-Empfehlungsmodul". Der Squeeze ist der einzige Anlass: User „baue mir das M15-Empfehlungsmodul". Der Squeeze ist der einzige
+137
View File
@@ -0,0 +1,137 @@
#!/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()
+11 -1
View File
@@ -76,7 +76,8 @@ def scan(H, L, C, A, SP, point, lo_i, hi_i, squeeze_mult, *,
box_n: int = BOX_N_STD, k: float = K_STD, atr_min: float = ATRMIN_STD, box_n: int = BOX_N_STD, k: float = K_STD, atr_min: float = ATRMIN_STD,
fenster: int = FENSTER_STD, cooldown: int = COOLDOWN_STD, fenster: int = FENSTER_STD, cooldown: int = COOLDOWN_STD,
max_hold: int = MAXH_STD, max_hold: int = MAXH_STD,
exit_fn: Callable | None = None) -> tuple[list, list]: exit_fn: Callable | None = None,
on_entry: Callable | None = None) -> tuple[list, list]:
"""Ein Durchlauf: sammelt R (netto) je Ausbruch. """Ein Durchlauf: sammelt R (netto) je Ausbruch.
Rueckgabe `(Rs_sq, Rs_any)` — `Rs_any` = ALLE Box-Ausbrueche (die Kontrolle), Rueckgabe `(Rs_sq, Rs_any)` — `Rs_any` = ALLE Box-Ausbrueche (die Kontrolle),
@@ -86,6 +87,13 @@ def scan(H, L, C, A, SP, point, lo_i, hi_i, squeeze_mult, *,
⚠ `point` wird nicht mehr benutzt (SP kommt bereits in Preis), bleibt aber in ⚠ `point` wird nicht mehr benutzt (SP kommt bereits in Preis), bleibt aber in
der Signatur, damit bestehende Aufrufe unveraendert funktionieren. der Signatur, damit bestehende Aufrufe unveraendert funktionieren.
⚠ `on_entry(j, d, lvl, atr, komprimiert)` ist ADDITIV (2026-08-08): ein
Rueckruf je Ausbruch, der auch sagt, ob er aus einer KOMPRESSION kam. Ohne
ihn ist `exit_fn` der einzige Haken — und der wird bei JEDEM Box-Ausbruch
gerufen, nicht nur bei den komprimierten. Wer darueber eine Entry-Liste
sammelt, bekommt still die Kontrollgruppe statt des Squeeze (real passiert:
519 statt 132 Einstiege). Verhalten ohne `on_entry` unveraendert.
""" """
ex = exit_fn or (lambda e, d, a, h, l, c, j: exit_legacy( ex = exit_fn or (lambda e, d, a, h, l, c, j: exit_legacy(
e, d, a, h, l, c, j, max_hold=max_hold)) e, d, a, h, l, c, j, max_hold=max_hold))
@@ -118,6 +126,8 @@ def scan(H, L, C, A, SP, point, lo_i, hi_i, squeeze_mult, *,
i += 1 i += 1
continue continue
j, d, lvl = treffer j, d, lvl = treffer
if on_entry is not None:
on_entry(j, d, lvl, atr, komprimiert)
R = ex(lvl, d, atr, H, L, C, j + 1) - kosten(j, atr) R = ex(lvl, d, atr, H, L, C, j + 1) - kosten(j, atr)
Rs_any.append(R) Rs_any.append(R)
if komprimiert: if komprimiert: