From 7009541c429f8bcefe7188f45ae3c4feaff85894 Mon Sep 17 00:00:00 2001 From: Axel Hocks Date: Wed, 5 Aug 2026 17:39:41 +0200 Subject: [PATCH] Squeeze-Trades bekommen den SL aus M5-ATR (User-Entscheidung), TP unveraendert trader._calc_sl_tp bekommt die ATR-Zeitebene als Parameter (Default SL_TF = M15); _send/_send_locked/open_long/open_short und place_stop reichen sie durch. Gesetzt wird sie an genau zwei Stellen: engine._open bei source="auto_squeeze" und _manage_squeeze_pending bei quelle="squeeze". Manuelle Trades und Auto-Signal bleiben bei M15. Grundlage backtest_sl_basis.py: auf Squeeze-Entries ist der Ertrag zwischen beiden Basen ein Wash (alle Varianten innerhalb eines Standardfehlers), der Worst-Case sinkt aber von -8,83 auf -2,44 xATR_M5 = Faktor 3. Auf der Wellen-Population ist der weite M15-SL in BEIDEN Haelften besser - deshalb bewusst keine globale Umstellung. Die Multiplikatoren (1,8-2,2 xATR) sind nicht angefasst, nur die Zeitebene. TP bewusst NICHT betroffen (User: "nur fuer SL, der TP soll mit dem Runner mitwachsen"). Geprueft statt angenommen: der von _calc_sl_tp zurueckgegebene TP wird von KEINEM Aufrufer gesendet, beide setzen nur req["sl"]. Das Ziel verwaltet trailing.py dynamisch (3,5 x ATR der Wellen-Zeitebene). Warnkommentar im Code, warum der TP dort nicht mit in die Order darf. 4 Szenarien getestet. Dabei eigene Fehlannahme korrigiert: ohne Pivot greift der 1,2-%-Fallback, der beim M15 vom MINDEST-Deckel aufgeweitet wird - die 2,2x-Obergrenze wird dort gar nicht erreicht. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 25 ++++++++++++++++++++++++- core/engine.py | 16 ++++++++++++++-- core/trader.py | 47 +++++++++++++++++++++++++++++++++++------------ 3 files changed, 73 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 617cd12..461e45b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1211,7 +1211,30 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: Eine saubere Umsetzung wäre **quellen-abhängig** (`_calc_sl_tp` nutzt M5-ATR nur bei `source="auto_squeeze"`) — strukturell begründet (Breakout- gegen Pullback-Einstieg haben verschiedene Nachlauf-Dynamik), nicht data-mined. - **Nicht gebaut** — die Entscheidung über Tail-Risiko gehört dem User. + ✅✅ **GEBAUT & LIVE (2026-08-05, User-Entscheidung „für Squeeze-Trades: ja").** + `trader._calc_sl_tp(sym, otype, entry, atr_tf=None)` bekommt die ATR-Zeitebene als + Parameter (Default `SL_TF` = M15); `_send`/`_send_locked`/`open_long`/`open_short` + und `place_stop` reichen sie durch. Gesetzt wird sie an genau zwei Stellen: + `engine._open` (`source == "auto_squeeze"` → M5) und `_manage_squeeze_pending` + (`quelle == "squeeze"` → M5). **Alles andere — manuelle Trades, Auto-Signal — + bleibt unverändert bei M15.** + ⚠ **Die Multiplikatoren sind NICHT angefasst** (1,8–2,2×ATR): nur die Zeitebene + wechselt, aus der der ATR kommt. Der Log macht es sichtbar („… ATR aus M5"). + ⚠⚠ **DER TP IST BEWUSST NICHT BETROFFEN** (User-Vorgabe: „nur für SL, der TP soll + mit dem Runner mitwachsen"). Geprüft statt angenommen: der von `_calc_sl_tp` + zurückgegebene TP wird von **KEINEM** Aufrufer gesendet — `_send_locked` und + `place_stop` setzen ausschliesslich `req["sl"]`. Das Ziel verwaltet + `core/trailing.py` dynamisch (`_TP_INIT_ATR`=3,5 × ATR der **Wellen**-Zeitebene) + und zieht es mit dem Lauf nach. Ein Warnkommentar im Code hält fest, warum der TP + dort nicht mit in die Order darf: er wäre sonst an die SL-Breite gekoppelt und + würde genau die Runner kappen, von denen der Squeeze lebt (43 % Treffer, Ertrag + im Tail). + Mit 4 Szenarien getestet (M5 kappt auf 2,2×ATR_M5=0,66 · M15 greift auf den + Mindest-Deckel 1,8×ATR=0,99 · M5 enger als M15 · SHORT spiegelbildlich). + ⚠ Beim Testen eine eigene Fehlannahme korrigiert: ohne Pivot greift der + 1,2-%-Fallback, der beim M15 vom **Mindest**-Deckel aufgeweitet wird — die + 2,2×-Obergrenze wird dort gar nicht erreicht. + Zurück: die beiden `mt5.TIMEFRAME_M5`-Zuweisungen in `engine.py` auf `None`. ⚠ Das reproduziert NICHT `backtest_sl_tf_mismatch.py` (23.07., dort war M5 in H2 schlechter): dort wurde am Bar-Close eingestiegen, hier am Level. Der Einstiegspreis hat den Befund gedreht. diff --git a/core/engine.py b/core/engine.py index a84cd76..6340979 100644 --- a/core/engine.py +++ b/core/engine.py @@ -1209,7 +1209,10 @@ class TradingEngine: if otype in vorhanden: continue tag = "SQZ-STOP" if quelle == "squeeze" else "SIG-STOP" - t, err = self.trader.place_stop(sym, otype, preis, comment=tag) + t, err = self.trader.place_stop( + sym, otype, preis, comment=tag, + # gleiche Regel wie beim Market-Pfad (s. `_open`) + atr_tf=(mt5.TIMEFRAME_M5 if quelle == "squeeze" else None)) if t: log.info(f"🎯 Pending-Entry ({quelle}) gesetzt: " f"{'BUY_STOP' if otype == mt5.ORDER_TYPE_BUY_STOP else 'SELL_STOP'}" @@ -2315,7 +2318,16 @@ class TradingEngine: log.info(f"[ORDER] {want} ohne passende Empfehlung (Signal={sig}) " f"— vom User bestätigt, wird eröffnet") self.trader.set_open_context(**self._last_rec) - err = fn(sym) + # ⚠ SL-ATR-Zeitebene quellen-abhaengig (2026-08-05, User-Entscheidung nach + # `backtest_sl_basis.py`): SQUEEZE bekommt **M5**, alles andere bleibt bei + # M15. Begruendung: auf Squeeze-Entries ist der Ertrag zwischen beiden + # Basen ein Wash (innerhalb eines Standardfehlers), der Worst-Case sinkt + # aber von −8,83 auf −2,44xATR_M5 = Faktor 3. Auf der WELLEN-Population + # ist der weite M15-SL dagegen in BEIDEN Halbjahren besser — deshalb + # bewusst KEINE globale Umstellung. Strukturell begruendet: Breakout- und + # Pullback-Einstiege haben verschiedene Nachlauf-Dynamik. + _atr_tf = mt5.TIMEFRAME_M5 if source == "auto_squeeze" else None + err = fn(sym, _atr_tf) if err: return err # Vom BOT eröffnete Trades markieren → nur für die greift die 15-Min-Regel diff --git a/core/trader.py b/core/trader.py index 078d585..f6f77cb 100644 --- a/core/trader.py +++ b/core/trader.py @@ -83,7 +83,25 @@ class TradeManager: self._si_cache: object = None self._si_cache_ts: float = 0.0 - def _calc_sl_tp(self, sym, otype, entry_price): + def _calc_sl_tp(self, sym, otype, entry_price, atr_tf=None): + """`atr_tf` = Zeitebene fuer den ATR-Deckel. None → `SL_TF` (M15, Default). + + ⚠ SQUEEZE-Trades bekommen seit 2026-08-05 **M5** (User-Entscheidung nach + `backtest_sl_basis.py`): der Ertrag ist dort ein Wash (alle Varianten + innerhalb eines Standardfehlers), aber der Worst-Case sinkt von −8,83 auf + −2,44×ATR_M5 — Faktor 3. Auf der WELLEN-Population ist der weite M15-SL + dagegen in beiden Halbjahren besser, deshalb bleibt er dort Default. + Die Multiplikatoren (1,8–2,2×ATR) sind unveraendert — nur die Zeitebene, + aus der der ATR kommt, wechselt. + + ⚠⚠ DER TP IST DAVON NICHT BETROFFEN — und das ist Absicht (User-Vorgabe + 2026-08-05: „nur fuer SL, der TP soll mit dem Runner mitwachsen"). + Der hier zurueckgegebene `tp` wird von KEINEM Aufrufer gesendet: sowohl + `_send_locked` als auch `place_stop` setzen ausschliesslich `req["sl"]`. + Das Ziel verwaltet `core/trailing.py` dynamisch (`_TP_INIT_ATR`=3,5 x ATR + der Wellen-Zeitebene) und zieht es mit dem Lauf nach. Wer hier je den TP + mit in die Order legt, koppelt ihn an die SL-Breite und kappt damit genau + die Runner, von denen der Squeeze lebt (43 % Treffer, Ertrag im Tail).""" si = mt5.symbol_info(sym) if not si: return 0.0, 0.0, "?" @@ -91,7 +109,7 @@ class TradeManager: # Risiko-Deckel: SL-Distanz max. INIT_SL_MAX_ATR × ATR(M15). # Pivot-SLs lagen teils ~80 Pips weg → Einzelverluste -30..-40 € # bei Durchschnittsgewinnen von ~+4 €. - atr = atr_value(sym) + atr = atr_value(sym, atr_tf) if atr_tf else atr_value(sym) max_dist = (INIT_SL_MAX_ATR * atr) if atr else None min_dist = (INIT_SL_MIN_ATR * atr) if atr else None capped = floored = False @@ -115,21 +133,24 @@ class TradeManager: sl = round(sl, si.digits) sl_dist = sl - entry_price tp = round(entry_price - INIT_TP_RR * sl_dist, si.digits) if sl_dist > 0 else 0.0 + _tfn = "M5" if atr_tf == mt5.TIMEFRAME_M5 else "M15" src_str = f"M15-Pivot {piv:.3f}" if piv else "Fallback 1.2%" + if atr_tf: + src_str += f", ATR aus {_tfn}" if capped: src_str += f", gekappt auf {INIT_SL_MAX_ATR}xATR={max_dist:.3f}" if floored: src_str += f", auf min {INIT_SL_MIN_ATR}xATR={min_dist:.3f} aufgeweitet" return sl, tp, src_str - def _send(self, sym, otype): + def _send(self, sym, otype, atr_tf=None): with mt5_lock(timeout=15) as got: if not got: self.last_error = "MT5 belegt — bitte gleich nochmal" return None, 0.0 - return self._send_locked(sym, otype) + return self._send_locked(sym, otype, atr_tf) - def _send_locked(self, sym, otype): + def _send_locked(self, sym, otype, atr_tf=None): tick = get_tick(sym) if not tick: self.last_error = "Kein Tick"; return None, 0.0 @@ -139,7 +160,7 @@ class TradeManager: # Initial-SL ≈ risk_pct der Equity). Margin bleibt Obergrenze. Fallback auf # margin-basiert, wenn risk_pct=0 oder Daten fehlen. Behebt die großen # EUR-Verluste aus 90 %-Margin × 2×ATR-SL. - sl, _tp, sl_src = self._calc_sl_tp(sym, otype, price) + sl, _tp, sl_src = self._calc_sl_tp(sym, otype, price, atr_tf) risk = get_risk_per_trade() sl_dist = abs(price - sl) if sl else None # Eine manuell gesetzte Einsatz-Margin hat VORRANG vor dem Risiko-Modus @@ -216,7 +237,8 @@ class TradeManager: return [o for o in orders if getattr(o, "magic", 0) == MAGIC] def place_stop(self, sym: str, otype: int, trigger: float, - comment: str = "Widget-SQZ") -> tuple[int | None, str]: + comment: str = "Widget-SQZ", + atr_tf=None) -> tuple[int | None, str]: """Pending BUY_STOP/SELL_STOP am Ausbruchs-Level. → (ticket, Fehlertext). Größe und SL werden mit DERSELBEN Logik bestimmt wie bei der Market-Order @@ -244,7 +266,8 @@ class TradeManager: return None, (f"unter Broker-Mindestabstand " f"({abs(trigger-bezug):.3f} < {mind:.3f})") sl, _tp, sl_src = self._calc_sl_tp(sym, mt5.ORDER_TYPE_BUY if ist_long - else mt5.ORDER_TYPE_SELL, trigger) + else mt5.ORDER_TYPE_SELL, trigger, + atr_tf) lots = calc_lots(sym, trigger, mt5.ORDER_TYPE_BUY if ist_long else mt5.ORDER_TYPE_SELL) if lots <= 0: @@ -282,11 +305,11 @@ class TradeManager: return "" return _retcode_msg(res) - def open_long(self, sym): + def open_long(self, sym, atr_tf=None): with self._lock: if self.ticket: return "Position bereits offen!" - t, e = self._send(sym, mt5.ORDER_TYPE_BUY) + t, e = self._send(sym, mt5.ORDER_TYPE_BUY, atr_tf) if not t: return self.last_error with self._lock: @@ -295,11 +318,11 @@ class TradeManager: self._log_open(t, sym, "BUY", e) return "" - def open_short(self, sym): + def open_short(self, sym, atr_tf=None): with self._lock: if self.ticket: return "Position bereits offen!" - t, e = self._send(sym, mt5.ORDER_TYPE_SELL) + t, e = self._send(sym, mt5.ORDER_TYPE_SELL, atr_tf) if not t: return self.last_error with self._lock: