User: "ueberpruefe die trailing logik und passe ggf an", nach drei Trades in
Folge, die per nachgezogenem Stop schlossen. Beide Aenderungen auf Zustimmung.
(1) DEPLOYMENT-DRIFT: der Trail-ATR kam aus der WELLEN-Zeitebene, und die
wechselt. Gemessen an 90 echten Aktivierungen im Log:
M5 Ø ATR 0,1877 n=51 -> 1,00x wie validiert
M15 Ø ATR 0,2586 n= 9 -> 1,38x
M30 Ø ATR 0,4751 n=30 -> 2,53x
In 43 % der Faelle lief der Trail also mit einem Abstand, der NIE gemessen
wurde. `backtest_trailmult.py` misst mult=1,0 auf M5-Bars mit M5-ATR; auf
M30 entspricht das effektiv ~2,5 in M5-Einheiten, und 2,5 ist dort KLAR
schlechter gemessen (H1 ΣR -1174 gegen -73 bei 1,0).
⚠⚠ Am 31.07. wurde `_MULT_BY_TF` GENAU gegen diese Fehlerklasse auf
durchgehend 1,0 geglaettet. Der Multiplikator war aber nie die Ursache - es
ist der ATR. Die Variabilitaet kam durch die Hintertuer zurueck.
`_atr_tf_override` bleibt unberuehrt: den setzt engine bewusst, das ist eine
explizite Vorgabe und kein Automatismus. Zurueck: vier auskommentierte
Zeilen in `_pick_tf` wieder aktivieren.
(2) trail_start 0,3 -> 1,3. `backtest_trail_start.py` (14.08.) zaehlt den Anteil
"war im Plus, schloss im Minus": 0,3 -> 47,6 % / 38,2 %, bei 1,3 -> 0,0 % /
0,5 %. ⚠ Der ERTRAG unterscheidet sich gepaart NICHT messbar - alle zwoelf
KI enthalten die Null. Es ist also eine KOMFORT-Entscheidung, und sie ist
gemessen gratis. Mechanik: der Trail sitzt mult×ATR (=1,0) hinter dem
Bestkurs und erreicht den Einstand erst bei 1,0×ATR Gewinn; mit Start bei
0,3 lag der Stop im Band dazwischen zwangslaeufig auf der VERLUSTSEITE.
⚠⚠ FOLGE FUER DIE BACKTESTS, ausdruecklich benannt: `LIVE` ist die GETEILTE
Quelle. Alle Skripte, die sie nutzen, rechnen ab jetzt mit trail_start 1,3 -
ihre dokumentierten Zahlen wurden mit 0,3 erzeugt und reproduzieren daher NICHT
mehr. Wer eine alte Zahl nachrechnen will, muss `LIVE.with_(trail_start=0.3)`
pinnen. Das ist der Preis der geteilten Quelle und zugleich ihr Zweck: Live und
Messung bewegen sich gemeinsam. Der veraltete Kommentar "start 0,3" in
exit_model.py wurde mitgezogen, damit die Begruendung nicht gegen die
Einstellung steht.
⚠ Am Verlust-Trade von heute frueh (-22,36) haette KEINE der beiden etwas
geaendert: er war nie im Plus und traf den regulaeren Initial-SL. Dort ist die
Schraube die Positionsgroesse, nicht der Exit.
91 Tests gruen, Deploy ueber tools/deploy.py --feld position_brk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
183 lines
10 KiB
Python
183 lines
10 KiB
Python
"""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)
|
||
# ⚠⚠ 0,3 → 1,3 am 2026-08-20 (User-Entscheidung nach drei Trades in Folge,
|
||
# die per nachgezogenem Stop schlossen). BEGRUENDUNG AUS DER MESSUNG:
|
||
# `backtest_trail_start.py` (14.08.) hat den Anteil "war im Plus, schloss
|
||
# im Minus" ausgezaehlt — 0,3 → 47,6 % / 38,2 %, bei 1,3 → 0,0 % / 0,5 %.
|
||
# ⚠ Der ERTRAG unterscheidet sich dabei NICHT messbar: der gepaarte Test
|
||
# (gleiche Einstiege, nur der Exit variiert) liefert fuer alle zwoelf
|
||
# Zellen ein 95-%-KI, das die Null enthaelt. Es ist also eine KOMFORT-,
|
||
# keine Edge-Entscheidung — und sie ist gemessen gratis.
|
||
# ⚠ MECHANIK, die man kennen muss: der Trail sitzt `mult`×ATR (=1,0)
|
||
# hinter dem Bestkurs und erreicht den Einstand daher erst bei 1,0×ATR
|
||
# Gewinn. Startete er schon bei 0,3, lag der Stop im Band dazwischen
|
||
# zwangslaeufig auf der VERLUSTSEITE. Mit 1,3 greift er erst oberhalb des
|
||
# Einstands; bis dahin schuetzt der Initial-SL.
|
||
# ZURUECK: 0.3
|
||
trail_start: float = 1.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 1,3 ⚠ WAR 0,3 (`backtest_trailing.py`: 0,6/1,0 schlechter). Am
|
||
# 2026-08-20 auf 1,3 gesetzt — s. Begruendung an `trail_start`.
|
||
# Der alte Befund ist NICHT widerlegt: er mass den ERTRAG, und der
|
||
# unterscheidet sich gepaart nicht messbar (alle 12 KI enthalten
|
||
# die Null). Geaendert wurde aus KOMFORT — der Anteil "war im
|
||
# Plus, schloss im Minus" faellt von 47,6 % auf 0,0 %.
|
||
# ⚠⚠ FOLGE FUER BACKTESTS: `LIVE` ist die geteilte Quelle, alle
|
||
# Skripte, die sie nutzen, rechnen ab jetzt mit 1,3. Ihre
|
||
# dokumentierten Zahlen wurden mit 0,3 erzeugt und reproduzieren
|
||
# daher NICHT mehr. Wer eine alte Zahl nachrechnen will, muss
|
||
# `LIVE.with_(trail_start=0.3)` pinnen. Das ist der Preis der
|
||
# geteilten Quelle — und genau ihr Zweck: Live und Messung
|
||
# bewegen sich gemeinsam (Deployment-Drift Fall 3).
|
||
# 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)
|