Stufe 3: geteilter Exit-Kern (core/exit_model.py) + Korrektur der Stufe-1-Begruendung

core/exit_model.py (NEU): LIVE:ExitParams als EINZIGE Quelle der Exit-
Parameter; core/trailing.py leitet _TRAIL_START_ATR, _BREAKEVEN_ATR,
_PHASE4_* und _MULT_BY_TF jetzt davon ab statt eigene Zahlen zu halten.
Aendert jemand LIVE.mult, aendern sich Live-Verhalten UND Messung gemeinsam
-> Deployment-Drift-Fall 3 ist konstruktiv unmoeglich geworden. Dazu die
kanonische simulate() fuer Backtests mit Flags timestop/use_tp, um
Teilmodelle EXPLIZIT zu machen statt zu verstecken.

BEFUNDE BEIM REFACTOR - schlimmer als angenommen:
(1) Es gibt mindestens DREI materiell verschiedene Exit-Modelle:
    A) Phasen ~ live (_trailing/_exit/_atrfloor/_candle_fade)
    B) EINFACH - _TPTRAIL=0.5, kein Breakeven, kein Lock, kein Time-Stop,
       _MAXH=240 (_hourly, _hourly_split, _bounce, _events, _deadhour,
       _chopgate)
    C) Phasen mit be_on=1.0 statt 1,3 (_breakout, _confluence_angle)
    => Dead-Hours, EIA-Blackout, Bounce, Chop-Gate, Stunden-Analyse und
    breakout_k ruhen auf einem Exit, der dem Live-System nicht entspricht.
    Bewusst NICHT stillschweigend umgestellt (wuerde historische Schluesse
    rueckwirkend aendern); als LEGACY_SIMPLE / LEGACY_BE10 markiert.
(2) Selbst die "Phasen"-Skripte weichen voneinander ab: _trailing hat TP
    aber keinen Time-Stop, _candle_fade Time-Stop aber kein TP, _atrfloor
    liefert Punkte statt R.
(3) backtest_trailing.py koppelt den Initial-SL an mult (sl = entry -
    d*mult*atr) statt fix 2,0.

KORREKTUR DER STUFE-1-BEGRUENDUNG: die "67 % groesserer Einzelverlust" war
ein Artefakt von (3) - dort war der Worst-Case per Konstruktion gleich dem
Multiplikator. Sauber nachgemessen mit fixem SL (backtest_trailmult.py, NEU,
80k Bars, 2 Halbjahre):
    Trail 1,0  H1  -73 · H2 +1346 · Worst -2,00
    Trail 1,5  H1 -325 · H2 +1294 · Worst -2,00   (live)
    Trail 2,0  H1 -446 · H2 +1195 · Worst -2,00
    Trail 2,5  H1 -1174 · H2 +1223 · Worst -2,00
    Trail 3,0  H1 -1088 · H2 +1397 · Worst -2,00
Der Worst-Case ist bei JEDEM Multiplikator identisch -2,00. Die Entscheidung
bleibt richtig (1,5 schlaegt 2,0 und 2,5 in beiden Haelften), nur die
Tail-Begruendung war falsch.

NEU UND OFFEN: Trail 1,0 schlaegt 1,5 in BEIDEN Haelften - eigener
Vorschlag, bewusst nicht ungefragt umgesetzt.

Aequivalenz verifiziert (500 synthetische Kursreihen je Fall): _candle_fade
und _atrfloor sind bitgenau identisch zur neuen simulate().
Live verifiziert: "AKTIVIERT ATR=0.5796 (M30) mult=1.5x".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Axel Hocks
2026-07-31 13:02:02 +02:00
co-authored by Claude Opus 5
parent 97617cd81d
commit 4c1a68a7e1
4 changed files with 304 additions and 16 deletions
+126
View File
@@ -0,0 +1,126 @@
"""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,53,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,82,2 → Ziel 2,0)
trail_start: float = 0.3 # Phase Init→Trail
mult: float = 1.5 # Trail-Abstand HW ∓ mult×ATR
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,82,2, Ø-R +32 % ggü. 1,21,5)
# trail 1,5 `backtest_trailing.py` (2,0/2,5 messbar schlechter, s. Stufe 1)
# 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
+16 -12
View File
@@ -56,14 +56,21 @@ _ATR_MIN = 0.06 # Untergrenze ATR. 0,12→0,06 gesenkt (gemessen,
# breit). 0,06 in BEIDEN Hälften besser (H1 153→−27 Pts,
# H2 +512→+554); ganz ohne Floor nur marginal besser →
# 0,06 als Schutz gegen Absurd-ATR (Dead-Hours) behalten.
_TRAIL_START_ATR = 0.3 # Phase 1→2: ab diesem Profit startet HW-Trailing
_BREAKEVEN_ATR = 1.3 # Entry-Floor (Breakeven) ab diesem Profit. 0,6 war zu
# ── Exit-Parameter: EINE Quelle für Live UND Backtests (Stufe 3, 2026-07-31) ──
# Diese Werte kamen bis dahin hier UND in ~20 Backtests getrennt vor. Genau daraus
# entstand Deployment-Drift-Fall 3: `_MULT_BY_TF` lief live auf bis zu 3,0, während
# jeder Backtest 1,5 annahm. Jetzt liegen sie in `core/exit_model.py`; ändert sie
# jemand dort, ändern sich Live-Verhalten UND Messung gemeinsam.
from core.exit_model import LIVE as _EXIT
_TRAIL_START_ATR = _EXIT.trail_start # 0,3 — Phase 1→2: ab hier HW-Trailing
_BREAKEVEN_ATR = _EXIT.be # 1,3 — Entry-Floor (Breakeven). 0,6 war zu
# eng: 21 % der Trades wurden auf Breakeven gescratcht
# (backtest_exit.py be). 1,3 = gemessenes Plateau-Optimum:
# Scratch 5 %, Ø-R +15 %, ohne Tail-Risiko (SL-gedeckelt).
_PHASE4_ATR = 3.5 # Phase 2→3: ab hier engeres Trailing zum Lock-In
_PHASE4_MULT_SCALE = 0.6 # Multiplikator-Faktor in Phase 3
_PHASE4_MULT_MIN = 1.2 # Untergrenze Mult in Phase 3
_PHASE4_ATR = _EXIT.lock_start # 3,5 — Phase 2→3: engeres Trailing (Lock-In)
_PHASE4_MULT_SCALE = _EXIT.lock_scale # 0,6 — Multiplikator-Faktor in Phase 3
_PHASE4_MULT_MIN = _EXIT.lock_min # 1,2 — Untergrenze Mult in Phase 3
_PARTIAL_TP_ATR = 1.5 # Teil-Exit: ab diesem Profit 1× die Hälfte sichern
_PARTIAL_TP_FRAC = 0.0 # Anteil beim Teil-Exit. 0 = AUS → kompletter Trade
@@ -122,13 +129,10 @@ _TF_LABELS = {
# häufig auf M15/M30 landet (31.07.: 19 von 33 Phasen).
# ⚠ Falls die Staffelung je zurück soll: erst `backtest_trailing.py` auf
# M15-/M30-BASIS-Signalen laufen lassen — bisher misst er nur M5.
_MULT_BY_TF = {
mt5.TIMEFRAME_M1: 1.5,
mt5.TIMEFRAME_M5: 1.5,
mt5.TIMEFRAME_M15: 1.5,
mt5.TIMEFRAME_M30: 1.5,
mt5.TIMEFRAME_H1: 1.5,
}
# ⚠ Werte kommen aus `core/exit_model.py` (Stufe 3) — NICHT hier hart eintragen.
_MULT_BY_TF = {tf: _EXIT.mult for tf in (
mt5.TIMEFRAME_M1, mt5.TIMEFRAME_M5, mt5.TIMEFRAME_M15,
mt5.TIMEFRAME_M30, mt5.TIMEFRAME_H1)}
# 0,0 statt 0,5: der Aufschlag hob Breakout-Trades auf mind. 2,0 — gemessen
# schlechter als 1,5 (s. o.) und für Breakouts nie separat validiert.
_MULT_BREAKOUT_ADD = 0.0