Files
AH-Oil-Trader/core/squeeze_scan.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

137 lines
6.0 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.
"""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