Files
Axel HocksandClaude Opus 5 4ebc55c49b Trailing: ATR fest auf M5 (Drift behoben) + trail_start 0,3 -> 1,3
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>
2026-08-20 08:01:20 +02:00

183 lines
10 KiB
Python
Raw Permalink 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)
# ⚠⚠ 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,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 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)