Files
AH-Oil-Trader/core/exit_model.py
T
Axel HocksandClaude Opus 5 bb17ecff0a Beide offenen Vorschlaege umgesetzt: Trail 1,0 + Legacy-Recheck
VORSCHLAG 1 - Trail-Multiplikator 1,5 -> 1,0 (core/exit_model.py LIVE.mult),
live verifiziert ("AKTIVIERT ... mult=1.0x").
Voller Sweep mit FIXEM SL 2,0 (backtest_trailmult.py, 8 Werte 0,5-3,0):
enger ist MONOTON besser. Wave-Signal H1/H2 SigmaR:
    0,5 +1258/+2418 · 0,8 +852/+1668 · 1,0 +80/+1360
    1,5 -220/+1305 · 2,0 -394/+1198 · 2,5 -1072/+1327
Optimum am RAND = Warnsignal, deshalb Gegentest auf SQUEEZE-Entries mit
Echtkosten (--squeeze): das Setup lebt von Laeufern, muesste also
dagegenhalten - tut es NICHT (1,0: H1 -0,202/H2 +0,010 · 1,5:
-0,219/-0,032). Deshalb 1,0 (besser als 1,5 in beiden Haelften auf BEIDEN
Signalmengen), aber NICHT 0,5: Randwert, und die Sim modelliert keine
Exit-Slippage - ein engerer Trail loest viel haeufiger aus und ist davon
staerker betroffen (real bis 0,75xATR ueber den Stop).

VORSCHLAG 2 - backtest_legacy_recheck.py (NEU): die beiden Legacy-Befunde
nachgerechnet, die live ECHTE Gates steuern.
  EIA-Blackout BESTAETIGT: auch mit echtem Exit + Echtkosten in BEIDEN
  Haelften schlechter als der Rest (-0,097 / -0,119). Gate ist gedeckt.
  DEAD-HOURS reproduzieren NICHT als selektiver Befund: 19 von 22 Stunden
  sind in beiden Haelften negativ, keine robust positive Stunde. Die alte
  Auswahl (0-7, 12, 16) ist damit nicht mehr gestuetzt. Praktisch folgenlos
  (dead_hours ist leer), aber eine Reaktivierung auf der alten Begruendung
  waere nicht gedeckt.

Einschraenkung selbst benannt: der Recheck handelt die UNGEGATETE
EMA-Richtung (ohne min_conf, Breakout-Bestaetigung, HTF-Filter, Raum-Gate).
Fuer den Vergleich neutral, die absoluten Werte sind nicht das Live-Signal.

Konsequenz fuer den TF-Churn-Fix: "WARTEN Richtung 43 %" ist eine
Haeufigkeits-, keine Ertragsgroesse - mehr Signale sind nur dann besser,
wenn die freigegebenen Setups auch tragen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:09:32 +02:00

139 lines
7.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.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) -> 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