"""core/exit_model.py — EINE Quelle der Wahrheit für den Exit (Stufe 3, 2026-07-31). WARUM ES DAS GIBT — Deployment-Drift, Fall 3: Der Trailing-Multiplikator lief live auf 1,5–3,0 (je Zeitebene), während JEDER Backtest 1,5 annahm. Gemessen war das 5,5× schlechteres H1 und ein 67 % größerer Einzelverlust. Niemand hat es bemerkt, weil `trailing.py` (live) und die Backtests zwei getrennte Zahlenwelten waren. Dieses Modul ist die gemeinsame Basis: * `LIVE` — die Exit-Parameter als **einzige Quelle**. `core/trailing.py` importiert sie; ändert sie jemand dort, ändern sich die Backtests mit. Genau das hätte Fall 3 unmöglich gemacht. * `simulate()` — die kanonische, bar-basierte Nachbildung des Live-Exits für Backtests (Phasen Init→Trail→Lock, HW-Ratsche, Breakeven-Boden, Time-Stop). ⚠⚠ BEFUND BEIM BAU (2026-07-31): Die Backtests des Projekts nutzen **mindestens DREI materiell verschiedene Exit-Modelle**: (A) Phasen-Modell ≈ live — `backtest_trailing/_exit/_atrfloor/_candle_fade/…` (B) **Einfach** (`_TPTRAIL=0.5`, KEIN Breakeven, KEIN Lock, KEIN Time-Stop, `_MAXH=240`) — `backtest_hourly`, `_hourly_split`, `_bounce`, `_events`, `_deadhour`, `_chopgate` (C) Phasen mit `be_on=1.0` statt 1,3 — `backtest_breakout`, `_confluence_angle` Damit ruhen mehrere dokumentierte Befunde (Dead-Hours, EIA-Blackout, Bounce, Chop-Gate, Stunden-Analyse) auf einem Exit, der dem Live-System NICHT entspricht. Diese Skripte werden **bewusst NICHT stillschweigend umgestellt** — das würde historische Schlussfolgerungen rückwirkend verändern. Sie sind in `LEGACY_SIMPLE` bzw. `LEGACY_BE10` als Altlast markiert; eine Neubewertung ist eine eigene, begründete Entscheidung. MIGRATIONS-REGEL: Ein Backtest darf nur dann auf `simulate()` umgestellt werden, wenn er danach **dieselben Zahlen** liefert wie vorher (Variante A) — sonst ist es keine Refaktorierung, sondern eine unbemerkte Änderung der Messgrundlage. """ from __future__ import annotations from dataclasses import dataclass, replace @dataclass(frozen=True) class ExitParams: """Alle Größen in ×ATR, Zeiten in Bars der jeweiligen Basis-TF.""" sl_atr: float = 2.0 # Initial-SL (Live-Band 1,8–2,2 → Ziel 2,0) trail_start: float = 0.3 # Phase Init→Trail mult: float = 1.0 # Trail-Abstand HW ∓ mult×ATR (1,5 → 1,0 am 2026-07-31) be: float = 1.3 # Breakeven-Boden (Entry) ab diesem Profit lock_start: float = 3.5 # Phase Trail→Lock lock_scale: float = 0.6 # mult×scale in der Lock-Phase lock_min: float = 1.2 # Untergrenze des Lock-Multiplikators tp_init: float = 3.5 # Initial-TP (Phase Init) timestop_bars: int = 24 # Time-Stop: nach N Bars noch in Phase Init → raus max_hold: int = 200 # harte Obergrenze der Haltedauer def with_(self, **kw) -> "ExitParams": return replace(self, **kw) # ── DIE Live-Parameter. `core/trailing.py` importiert sie von hier. ─────────── # Werte = der jeweils GEMESSENE Stand: # sl 2,0 `backtest_exit.py` (Band 1,8–2,2, Ø-R +32 % ggü. 1,2–1,5) # trail 1,0 `backtest_trailmult.py` (2026-07-31) — SAUBER gemessen mit FIXEM # SL 2,0 (der alte `backtest_trailing.py` koppelte den SL an mult und # vermengte damit zwei Änderungen). Enger ist MONOTON besser, und zwar # auf BEIDEN Signalmengen und in BEIDEN Hälften: # Wave-Signal 1,0: H1 +80/H2 +1360 · 1,5: −220/+1305 · 2,0: −394/+1198 # Squeeze(Kost) 1,0: H1 −0,202/H2 +0,010 · 1,5: −0,219/−0,032 # Der Squeeze ist der Gegentest: er lebt von LÄUFERN — wäre „enger" nur # „macht weniger", müsste er dagegenhalten. Tut er nicht. # ⚠ 0,5 wäre in der Messung NOCH besser (Wave H1 +1258/H2 +2418), liegt # aber am RAND des Suchbereichs → bewusst nicht genommen. Zusätzlicher # Grund: die Sim modelliert **keine Exit-Slippage**; ein engerer Trail # löst deutlich häufiger aus und ist damit stärker davon betroffen # (real gemessen: bis 0,75 ATR über den Stop hinaus). # start 0,3 `backtest_trailing.py` (0,6/1,0 schlechter) # be 1,3 `backtest_exit.py be` (0,6 → 21 % Breakeven-Scratches) # timestop 24 `backtest_timestop.py` (120 min; 30/60 min kippten) LIVE = ExitParams() # Altlasten — NUR zur Dokumentation, nicht zum Verwenden in neuem Code. # (B) Der einfache Exit ohne Breakeven/Lock/Time-Stop, 240 Bars Haltedauer. LEGACY_SIMPLE = ("backtest_hourly.py", "backtest_hourly_split.py", "backtest_bounce.py", "backtest_events.py", "backtest_deadhour.py", "backtest_chopgate.py") # (C) Phasen-Modell, aber Breakeven bei 1,0 statt 1,3. LEGACY_BE10 = ("backtest_breakout.py", "backtest_confluence_angle.py") def simulate(entry: float, d: int, atr: float, H: list, L: list, C: list, j0: int, p: ExitParams = LIVE, timestop: bool = True, use_tp: bool = True) -> float: """Kanonische Exit-Simulation → Ergebnis in R (×ATR), Kosten NICHT enthalten. `d` = +1 LONG / −1 SHORT · `j0` = erster Bar NACH dem Einstieg. Pessimistisch: wird in EINEM Bar sowohl SL als auch TP berührt, zählt der SL. Die Phasen-Ratsche (`rank`) verhindert das Zurückfallen in eine frühere Phase — identisch zur Live-Mechanik in `core/trailing.py`. ⚠ `timestop` / `use_tp` machen die **Teilmodelle explizit**. Beim Bau (2026-07-31) zeigte sich, dass selbst die „Phasen-Modell"-Backtests untereinander abweichen: `backtest_trailing.py` TP ja · Time-Stop NEIN `backtest_candle_fade.py` TP NEIN · Time-Stop ja `backtest_atrfloor.py` TP ja · Time-Stop NEIN · liefert PUNKTE statt R Live hat BEIDES (`_TP_INIT_ATR=3.5`, `_TIMESTOP_MIN=120`) — die Vollausstattung (`timestop=True, use_tp=True`) ist also die einzige live-treue Variante. Die Flags erlauben es, ein bestehendes Skript BITGENAU zu migrieren und dabei sichtbar zu machen, welchen Ausschnitt es misst, statt ihn zu verstecken. """ if atr <= 0: return 0.0 sl = entry - d * p.sl_atr * atr tp = (entry + d * p.tp_init * atr) if use_tp else None hw = entry rank = 0 end = min(j0 + p.max_hold, len(C) - 1) for j in range(j0, end + 1): hi, lo = H[j], L[j] if (lo <= sl) if d > 0 else (hi >= sl): return (sl - entry) * d / atr if tp is not None and ((hi >= tp) if d > 0 else (lo <= tp)): return (tp - entry) * d / atr hw = max(hw, hi) if d > 0 else min(hw, lo) profit = (hw - entry) * d ph = 0 if profit < p.trail_start * atr else ( 1 if profit < p.lock_start * atr else 2) if ph < rank: ph = rank rank = ph if timestop and rank == 0 and (j - j0) >= p.timestop_bars: return (C[j] - entry) * d / atr if ph == 0: continue m = p.mult if ph == 1 else max(p.lock_min, p.mult * p.lock_scale) cand = hw - d * m * atr if profit >= p.be * atr or ph == 2: cand = max(cand, entry) if d > 0 else min(cand, entry) sl = max(sl, cand) if d > 0 else min(sl, cand) return (C[end] - entry) * d / atr