Live-Code von Backtest-Skript entkoppelt: core/squeeze_scan.py

core/engine.py (squeeze_monitor) und weekly_review.py importierten
backtest_breakout_squeeze - die Abhaengigkeitsrichtung war verkehrt:
ein Fehler im Backtest konnte den laufenden Bot treffen.

Schwerer wog, WIE die Parameter hineinkamen:

    BQ._N = _SQ_N; BQ._K = _SQ_K

Das mutiert Modul-Globale eines Backtests, wirkt prozessweit und damit auf
jeden anderen Nutzer desselben Moduls im selben Prozess. Die Werte weichen
real ab (Backtest _N=12, live _SQ_N=10).

Geloest ueber core/squeeze_scan.py - Parameter stehen in der Signatur.
backtest_breakout_squeeze.py leitet nur noch weiter, damit es EINE
Implementierung gibt statt zweier (der sim_run/_ema_series-Fehler).

Der Exit ist bewusst NICHT mitrepariert: exit_legacy ist eine bitgenaue
Kopie inkl. seiner Abweichungen vom kanonischen Exit (Trail 1,5 statt 1,0,
kein Lock/Time-Stop/TP, max_hold 288 statt 200). Damit misst der B4-Monitor
gegen einen veralteten Exit - separater, offener Befund. Ihn hier still
mitzuaendern haette die Vergleichsgrundlage verschoben.

Bitgenauigkeit bewiesen (tests/test_squeeze_scan.py): die alte Fassung liegt
eingefroren im Test und laeuft gegen die neue ueber 500 synthetische Reihen
x 3 Multiplikatoren. Ein Vorher/Nachher-Lauf waere ungueltig gewesen - der
Backtest zieht Bars live aus MT5, das Fenster verschiebt sich staendig.

Test selbst per Mutationsprobe geprueft: 5 von 6 eingebauten Fehlern schlagen
an, der sechste (d>0 -> d>=0) ist beweisbar aequivalent, da d nur +-1 ist.

Live-Gegenprobe auf 9000 M5-Bars: n=112, Treffer 44%, OeR +0,178, PF 1,26.

Aktivierung beim naechsten Neustart - bei offener Position kein Neustart fuer
eine verhaltensneutrale Aenderung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Axel Hocks
2026-08-07 07:21:45 +02:00
co-authored by Claude Opus 5
parent a35a0e7006
commit fd3c7f57a5
6 changed files with 398 additions and 51 deletions
+11 -4
View File
@@ -3524,12 +3524,19 @@ class TradingEngine:
try:
m5 = self._fetch_m5_monitor()
if m5 and len(m5["C"]) >= 300:
import backtest_breakout_squeeze as BQ
# ⚠ Frueher: `import backtest_breakout_squeeze as BQ` PLUS
# `BQ._N = ...; BQ._K = ...`. Damit haing LIVE-Code an einem
# Backtest-Skript, und die Live-Parameter wurden ueber MUTIERTE
# MODUL-GLOBALE eingeschleust (prozessweit wirksam). Jetzt liegt
# die Mechanik in `core/` und die Parameter stehen in der
# Signatur. Verhalten unveraendert (bitgenau geprueft,
# `tests/test_squeeze_scan.py`).
from core.squeeze_scan import atr_series, scan
from core.wave_rec import _SQ_N, _SQ_MULT, _SQ_K
BQ._N = _SQ_N; BQ._K = _SQ_K
H, L, C, SP = m5["H"], m5["L"], m5["C"], m5["SP"]
A = BQ._atr_series(H, L, C)
sq, _ = BQ.scan(H, L, C, A, SP, 1.0, 0, len(C), _SQ_MULT)
A = atr_series(H, L, C)
sq, _ = scan(H, L, C, A, SP, 1.0, 0, len(C), _SQ_MULT,
box_n=_SQ_N, k=_SQ_K)
n = len(sq)
if n:
w = sum(1 for x in sq if x > 0); s = sum(sq)
+126
View File
@@ -0,0 +1,126 @@
"""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) -> 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.
"""
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
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