Files
AH-Oil-Trader/core/exit_model.py
T
Axel HocksandClaude Opus 5 fea91fddb3 Auto-Flip-Close gebaut (User-Wunsch) - trotz erneut negativer Messung
User: "schliesse den trade automatisch beim close signal". Dreht die
Empfehlung gegen die offene Position und steht sie >=0,5xATR im Plus ->
close(reason="flip_close").

VORHER NEU GEMESSEN, weil das alte Urteil (backtest_flipclose.py,
16.07.) auf einem VIERTEN Exit-Modell fusste: Trail fest 1,5 (live jetzt
1,0), keine Lock-Phase, kein Time-Stop, kein Initial-TP, _MAXH 288 statt
200, Phasen ueber den Bar-Close statt ueber das High-Water.

backtest_flipclose2.py (80k M5, 2 Halbjahre, Echtkosten, Exit aus
core/exit_model.py) bestaetigt das alte Urteil - deutlicher:
    Basis ohne Flip      H1 SigmaR -537 · H2 -140
    Flip ab 0,0xATR      Delta H1 -126 · H2 -221
    Flip ab 0,3xATR      Delta H1  -77 · H2 -216
    Flip ab 0,5xATR      Delta H1  -92 · H2 -160   <- mildeste, Default
    Flip auch im Minus   Delta H1 -205 · H2 -364
JEDE Variante ist in BEIDEN Haelften schlechter. Trefferquote steigt
39 -> 45 %, Ertrag faellt = Gewinner-Kappen (die nachlaufende EMA dreht
oft mitten im Pullback).

Gebaut wurde die mildeste Variante (0,5xATR Mindestgewinn), abschaltbar
ueber [trading] auto_flip_close.

Fuer die Neumessung bekam exit_model.simulate() einen stop_when-Hook
(+ ret_bar), damit die Phasen-Mechanik nicht zum fuenften Mal kopiert
werden musste. Rueckwaertskompatibilitaet verifiziert: ohne Hook bitgenau
identisch (die zunaechst gemeldeten Abweichungen kamen allein aus
LIVE.mult 1,5 -> 1,0, gegengeprueft mit mult=1.5 -> identisch).

Mit 10 synthetischen Szenarien getestet: beide Richtungen, aus, unter
Schwelle, gleichgerichtet, WARTEN, im Minus, flat, Startup-Schonfrist,
Ticket-Dedup - alle korrekt.

B4/B5-Pflicht: Live-Ertrag gegen die Erwartung halten, bei Drift
auto_flip_close=false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:55:06 +02:00

157 lines
8.2 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.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,82,2, Ø-R +32 % ggü. 1,21,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,
stop_when=None,
ret_bar: bool = False):
"""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.
`stop_when(j, profit_atr)` — optionaler Rueckruf fuer einen ZUSAETZLICHEN Exit
(z. B. Flip-Close: „Signal dreht"). Gibt er True zurueck, wird zum Close von Bar
`j` geschlossen. Ohne ihn ist das Ergebnis bitgenau wie zuvor. Damit laesst sich
eine Exit-Idee messen, OHNE die Phasen-Mechanik zu kopieren — genau diese
Kopiererei hat im Projekt VIER divergierende Exit-Modelle erzeugt.
`ret_bar=True` liefert `(R, exit_bar)` statt nur `R` (fuer sequentielle Sims,
die nach dem Exit weiterlaufen muessen).
"""
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)
def _out(px, bar):
r = (px - entry) * d / atr
return (r, bar) if ret_bar else r
for j in range(j0, end + 1):
hi, lo = H[j], L[j]
if (lo <= sl) if d > 0 else (hi >= sl):
return _out(sl, j)
if tp is not None and ((hi >= tp) if d > 0 else (lo <= tp)):
return _out(tp, j)
hw = max(hw, hi) if d > 0 else min(hw, lo)
profit = (hw - entry) * d
# Zusaetzlicher Exit (Flip-Close o. AE.) — NACH SL/TP, denn die haben im
# selben Bar Vorrang (pessimistisch), und VOR der Trail-Nachfuehrung.
if stop_when is not None and stop_when(j, (C[j] - entry) * d / atr):
return _out(C[j], j)
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 _out(C[j], j)
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 _out(C[end], end)