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>
137 lines
6.0 KiB
Python
137 lines
6.0 KiB
Python
"""Box-Ausbruchs-Scan — die geteilte Mechanik hinter dem Squeeze-Setup.
|
||
|
||
⚠⚠ WARUM ES DIESES MODUL GIBT (2026-08-07). `core/engine.py` und
|
||
`weekly_review.py` importierten bis dahin **`backtest_breakout_squeeze`**, also
|
||
LIVE-Code, der von einem Backtest-Skript abhing. Zwei Folgen:
|
||
1. Die Abhaengigkeitsrichtung war verkehrt. „Backtests sind nur Dokumentation"
|
||
stimmte nicht — ein Fehler dort konnte den laufenden Bot treffen.
|
||
2. Schlimmer: die Aufrufer haben **Modul-Globale des Backtests mutiert**
|
||
(`BQ._N = ...; BQ._K = ...`), um Live-Parameter einzuschleusen. Das wirkt
|
||
prozessweit und damit auf jeden anderen Nutzer desselben Moduls im selben
|
||
Prozess.
|
||
Hier stehen die Parameter deshalb in der Signatur, nicht in Modul-Globalen.
|
||
|
||
⚠⚠ DER EXIT IST EIN PARAMETER, UND DAS IST ABSICHT. `exit_legacy` unten ist eine
|
||
BITGENAUE Kopie des Exits aus `backtest_breakout_squeeze.sim` — inklusive seiner
|
||
Abweichungen vom kanonischen Live-Exit (`core/exit_model.LIVE`):
|
||
Trail 1,5 statt 1,0 · KEINE Lock-Phase · KEIN Time-Stop · KEIN TP ·
|
||
max_hold 288 statt 200
|
||
Damit bleiben die dokumentierten Zahlen des Backtests UND das Verhalten des
|
||
B4-Monitors zunaechst unveraendert — der Umbau ist reine Struktur.
|
||
⚠ Dass der B4-Monitor damit gegen einen VERALTETEN Exit misst, ist ein separater,
|
||
offener Befund (s. CLAUDE.md). Ihn hier stillschweigend mitzuaendern haette die
|
||
Vergleichsgrundlage des Monitors verschoben, ohne dass es jemand merkt.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from typing import Callable
|
||
|
||
# Vorgaben = der bisherige Stand aus `backtest_breakout_squeeze.py`, damit
|
||
# Aufrufer ohne explizite Angabe exakt dasselbe bekommen wie vorher.
|
||
BOX_N_STD = 12 # Box-Laenge in Bars
|
||
FENSTER_STD = 24 # Fenster nach der Box, in dem der Ausbruch fallen muss
|
||
COOLDOWN_STD = 12 # Vorsprung nach einem Trade (Ueberlappungsschutz)
|
||
K_STD = 0.1 # Ausbruch k×ATR ueber/unter die Box-Grenze
|
||
ATRMIN_STD = 0.12
|
||
MAXH_STD = 288 # Haltedauer-Obergrenze des Legacy-Exits
|
||
|
||
|
||
def atr_series(H: list, L: list, C: list, p: int = 14) -> list:
|
||
"""Gleitender ATR — bitgenau wie `backtest_breakout_squeeze._atr_series`."""
|
||
tr = [0.0]
|
||
for i in range(1, len(C)):
|
||
tr.append(max(H[i] - L[i], abs(H[i] - C[i - 1]), abs(L[i] - C[i - 1])))
|
||
out = [None]
|
||
for i in range(1, len(C)):
|
||
seg = tr[max(1, i - p + 1):i + 1]
|
||
out.append(sum(seg) / len(seg))
|
||
return out
|
||
|
||
|
||
def exit_legacy(entry, d, atr, H, L, C, j0, *, sl_atr=2.0, trail=1.5,
|
||
trail_on=0.3, be_on=1.3, max_hold=MAXH_STD) -> float:
|
||
"""⚠ BITGENAUE Kopie von `backtest_breakout_squeeze.sim` — bewusst inklusive
|
||
ihrer Abweichungen vom kanonischen Exit (s. Modul-Docstring). Nicht fuer
|
||
neuen Code verwenden; dort gehoert `core.exit_model.simulate` hin."""
|
||
eff = entry - d * sl_atr * atr
|
||
hw = entry
|
||
end = min(j0 + max_hold, len(C) - 1)
|
||
exit_px = C[end]
|
||
for j in range(j0, end + 1):
|
||
hi, lo = H[j], L[j]
|
||
if (lo <= eff) if d > 0 else (hi >= eff):
|
||
exit_px = eff
|
||
break
|
||
hw = max(hw, hi) if d > 0 else min(hw, lo)
|
||
prof = (C[j] - entry) * d
|
||
if prof >= trail_on * atr:
|
||
cand = hw - d * trail * atr
|
||
if prof >= be_on * atr:
|
||
cand = max(cand, entry) if d > 0 else min(cand, entry)
|
||
eff = max(eff, cand) if d > 0 else min(eff, cand)
|
||
return (exit_px - entry) * d / atr
|
||
|
||
|
||
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,
|
||
fenster: int = FENSTER_STD, cooldown: int = COOLDOWN_STD,
|
||
max_hold: int = MAXH_STD,
|
||
exit_fn: Callable | None = None,
|
||
on_entry: Callable | None = None) -> tuple[list, list]:
|
||
"""Ein Durchlauf: sammelt R (netto) je Ausbruch.
|
||
|
||
Rueckgabe `(Rs_sq, Rs_any)` — `Rs_any` = ALLE Box-Ausbrueche (die Kontrolle),
|
||
`Rs_sq` = nur die aus einer KOMPRESSION (Box ≤ `squeeze_mult`×ATR). Der
|
||
Vergleich der beiden ist es, der den Squeeze ueberhaupt validiert hat.
|
||
`i` springt nach jedem Ausbruch um `cooldown` vor — fuer beide Listen gleich.
|
||
|
||
⚠ `point` wird nicht mehr benutzt (SP kommt bereits in Preis), bleibt aber in
|
||
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(
|
||
e, d, a, h, l, c, j, max_hold=max_hold))
|
||
|
||
def kosten(i, atr):
|
||
return (SP[i] if SP[i] > 0 else 0.0225) / atr
|
||
|
||
Rs_sq: list = []
|
||
Rs_any: list = []
|
||
i = max(lo_i, box_n + 15)
|
||
while i < min(hi_i, len(C) - max_hold - 1):
|
||
atr = A[i]
|
||
if not atr or atr < atr_min:
|
||
i += 1
|
||
continue
|
||
box_hi = max(H[i - box_n:i])
|
||
box_lo = min(L[i - box_n:i])
|
||
komprimiert = (box_hi - box_lo) <= squeeze_mult * atr
|
||
treffer = None
|
||
for j in range(i, min(i + fenster, len(C) - max_hold - 1)):
|
||
oben = box_hi + k * atr
|
||
unten = box_lo - k * atr
|
||
if H[j] >= oben:
|
||
treffer = (j, 1, oben)
|
||
break
|
||
if L[j] <= unten:
|
||
treffer = (j, -1, unten)
|
||
break
|
||
if treffer is None:
|
||
i += 1
|
||
continue
|
||
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)
|
||
Rs_any.append(R)
|
||
if komprimiert:
|
||
Rs_sq.append(R)
|
||
i = j + cooldown
|
||
return Rs_sq, Rs_any
|