Files
AH-Oil-Trader/core/exit_model.py
T
Axel HocksandClaude Opus 5 4c1a68a7e1 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>
2026-07-31 13:02:02 +02:00

127 lines
6.3 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.
"""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