diff --git a/CLAUDE.md b/CLAUDE.md index 7d49624..96f9252 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -574,6 +574,35 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: P(break)-Modell (hat „mit-Trend" als Merkmal) macht das schon richtig; es mit dem groben EMA-Signal zu übersteuern kostet ~⅔ des S/R-Close-Edges. **12. verworfener Signal-Eingriff.** + - **⚠⚠ AUTO-FLIP-CLOSE = TROTZ NEGATIVER MESSUNG GEBAUT (User-Vorgabe 2026-07-31 + „schließe den Trade automatisch beim Close-Signal"), `[trading] auto_flip_close=true`, + `auto_flip_close_min_r=0.5`.** Dreht die Empfehlung GEGEN die offene Position und + steht sie ≥0,5×ATR im Plus → `trader.close(reason="flip_close")`. + **NEU GEMESSEN mit dem ECHTEN Exit (`backtest_flipclose2.py`, 80k M5, 2 Halbjahre, + Echtkosten, Exit aus `core/exit_model.py`) — bestätigt das alte Urteil, deutlicher:** + | Variante | Δ H1 | Δ H2 | + |---|---|---| + | Flip ab 0,0×ATR | −126 | −221 | + | Flip ab 0,3×ATR | −77 | −216 | + | **Flip ab 0,5×ATR** | **−92** | **−160** ← mildeste, deshalb Default | + | Flip auch im Minus | −205 | −364 | + Basis ohne Flip: H1 ΣR −537 · H2 −140. **JEDE Variante ist in BEIDEN Hälften + schlechter.** Mechanik: Trefferquote steigt 39 → 45 %, Ertrag fällt = + **Gewinner-Kappen** (die nachlaufende EMA dreht oft mitten im Pullback). + ⚠ **Warum überhaupt neu gemessen:** das alte `backtest_flipclose.py` nutzte ein + **VIERTES Exit-Modell** (Trail fest 1,5 · KEINE Lock-Phase · KEIN Time-Stop · + KEIN Initial-TP · `_MAXH` 288 · Phasen über den Bar-Close statt über das + High-Water) — auf dieser Grundlage wäre das Urteil für den Live-Betrieb nicht + bindend gewesen. Für die Neumessung bekam `exit_model.simulate()` einen + **`stop_when`-Hook** (+ `ret_bar`), damit die Phasen-Mechanik NICHT wieder kopiert + werden musste; Rückwärtskompatibilität verifiziert (ohne Hook bitgenau identisch). + Umsetzung `engine._check_flip_close` im `_pos_loop`: nur im PLUS, 1× je Ticket, + Startup-Schonfrist beachtet, Log 🔄 + Telegram, Snapshot `flip_close`/ + `flip_close_min_r`/`flip_close_count`. Mit **10 synthetischen Szenarien** getestet + (beide Richtungen · aus · unter Schwelle · gleichgerichtet · WARTEN · im Minus · + flat · Schonfrist · Ticket-Dedup). Der Alarm (Blinken/Ton/Telegram) bleibt + unabhängig davon. **B4/B5-Pflicht: Live-Ertrag gegen die Erwartung halten — + driftet es weg, `auto_flip_close=false`.** - **Auto-Flip-Close = VERWORFEN (gemessen `backtest_flipclose.py`, 2 Halbjahre, sequentielle 1-Positions-Sim, 2026-07-16):** User-Taktik-Kern „Position im Plus automatisch schließen, wenn die Empfehlung dreht" (heute nur Alarm). KEINE Variante diff --git a/backtest_flipclose2.py b/backtest_flipclose2.py new file mode 100644 index 0000000..dca03ea --- /dev/null +++ b/backtest_flipclose2.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Auto-Flip-Close NEU gemessen — mit dem ECHTEN Exit (User-Wunsch 2026-07-31). + +FRAGE: „Schließe den Trade automatisch beim Close-Signal" — also: dreht die +Empfehlung gegen die offene Position, sofort raus (heute nur Alarm + Ton). + +⚠ DAS WURDE SCHON EINMAL VERWORFEN (`backtest_flipclose.py`, 2026-07-16): keine +Variante hielt in beiden Hälften, H2 kostete 17–32 % des Ertrags („Gewinner-Kappen", +weil die nachlaufende EMA oft mitten im Pullback dreht). + +WARUM TROTZDEM NEU: Beim Stufe-3-Refactor (2026-07-31) kam heraus, dass das alte +Skript ein **viertes, eigenes Exit-Modell** benutzt, das dem Live-System in mindestens +fünf Punkten widerspricht: + Trail hart 1,5 (live seit heute **1,0**) + KEINE Lock-Phase (live: ab 3,5×ATR enger) + KEIN Time-Stop (live: 120 min in Phase Init) + KEIN Initial-TP (live: 3,5×ATR) + _MAXH 288 (live/kanonisch 200) + + Phasen-Entscheidung über den BAR-CLOSE statt über das High-Water. +Ein Urteil auf dieser Grundlage ist für den Live-Betrieb nicht bindend. + +HIER: derselbe sequentielle 1-Positions-Aufbau, aber der Exit kommt aus +`core/exit_model.simulate()` — inklusive des neuen `stop_when`-Hooks für den Flip, +damit die Phasen-Mechanik NICHT noch einmal kopiert wird (genau diese Kopiererei +hat die vier divergierenden Modelle erzeugt). Echtkosten, 2 Halbjahre. + +VARIANTEN: Flip erst ab minR Gewinn (0 / 0,3 / 0,5×ATR) sowie „auch im Minus". +Urteil „bauen" nur, wenn eine Variante in BEIDEN Hälften ≥ Basis liegt. +""" +import sys + +import MetaTrader5 as mt5 + +from core.analysis import calc_trend_angle +from core.exit_model import LIVE, simulate +from core.wave_rec import (_EMA_FAST, _EMA_SLOW, _N_BARS, _ANGLE_LR, _ANGLE_DEAD, + _REVERSAL_STRETCH, _STRETCH_MAX) + +_ATRMIN = 0.12 + + +def _ema_series(v, p): + k = 2.0 / (p + 1); o = []; e = v[0] + for i, x in enumerate(v): + e = x if i == 0 else x * k + e * (1 - k) + o.append(e) + return o + + +def _atr_series(H, L, C, p=14): + t = [0.0] + for i in range(1, len(C)): + t.append(max(H[i] - L[i], abs(H[i] - C[i - 1]), abs(L[i] - C[i - 1]))) + out = [] + for i in range(len(C)): + if not i: + out.append(None); continue + w = t[max(1, i - p + 1):i + 1] + out.append(sum(w) / max(1, len(w))) + return out + + +def signal_series(H, L, C, EF, ES, AT): + """Richtung je Bar (+1/−1/0) — dieselbe Regel wie im Live-Signal.""" + TH = _REVERSAL_STRETCH + sig = [0] * len(C) + for i in range(_N_BARS, len(C)): + atr = AT[i] + if not atr or atr <= 0: + continue + atr = max(atr, _ATRMIN); es = ES[i]; ef = EF[i] + stretch = (C[i] - es) / atr + ang = calc_trend_angle(C[i - _ANGLE_LR - 2:i], _ANGLE_LR); ad = ang - 90.0 + if stretch <= -TH and ad >= _ANGLE_DEAD: sig[i] = 1 + elif stretch >= TH and ad <= -_ANGLE_DEAD: sig[i] = -1 + elif abs(stretch) < _STRETCH_MAX: sig[i] = 1 if ef > es else -1 if ef < es else 0 + return sig + + +def run(sig, H, L, C, AT, SP, lo, hi, flip=False, min_r=0.0, flip_neg=False): + """Sequentiell: eine Position, Entry bei Signal, Exit aus dem GEMEINSAMEN Kern.""" + Rs = [] + i = lo + while i < hi: + d = sig[i] + if d == 0: + i += 1; continue + atr = max(AT[i] or 0, _ATRMIN) + cost = (SP[i] if SP[i] > 0 else 0.0225) / atr + hook = None + if flip: + def hook(j, prof_atr, _d=d): + if sig[j] != -_d: + return False + return flip_neg or prof_atr > min_r + R, xb = simulate(C[i], d, atr, H, L, C, i + 1, LIVE, + stop_when=hook, ret_bar=True) + Rs.append(R - cost) + i = xb + 1 + return Rs + + +def st(Rs): + if not Rs: + return " —" + n = len(Rs); w = sum(1 for x in Rs if x > 0); s = sum(Rs) + g = sum(x for x in Rs if x > 0); l = -sum(x for x in Rs if x < 0) + return (f"n={n:>4} WR={100*w/n:>3.0f}% ØR={s/n:+.3f} PF={(g/l if l > 0 else 9.99):>4.2f} " + f"ΣR={s:>+6.0f} Worst={min(Rs):+.2f}") + + +def main(): + n = int(sys.argv[1]) if len(sys.argv) > 1 else 80000 + mt5.initialize(); sym = None + for c in ("SpotCrude", "USOIL", "WTI", "XTIUSD"): + if mt5.symbol_info(c): + sym = c; break + bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, n) + si = mt5.symbol_info(sym); point = si.point + mt5.shutdown() + H = [float(b["high"]) for b in bars]; L = [float(b["low"]) for b in bars] + C = [float(b["close"]) for b in bars]; SP = [float(b["spread"]) * point for b in bars] + EF = _ema_series(C, _EMA_FAST); ES = _ema_series(C, _EMA_SLOW); AT = _atr_series(H, L, C) + sig = signal_series(H, L, C, EF, ES, AT) + mid = len(C) // 2 + halves = [("H1 (alt)", _N_BARS, mid), ("H2 (neu)", mid, len(C) - LIVE.max_hold - 1)] + + print("=" * 96) + print(f" AUTO-FLIP-CLOSE — {sym} M5 ({len(C)} Bars), Echtkosten") + print(f" Exit aus core/exit_model.py: SL {LIVE.sl_atr} · Trail {LIVE.mult} · " + f"BE {LIVE.be} · Lock {LIVE.lock_start} · Time-Stop {LIVE.timestop_bars}") + print(" (das alte Urteil von 2026-07-16 fusste auf einem abweichenden Exit)") + print("=" * 96) + + variants = [("Basis — kein Flip-Close", dict(flip=False)), + ("Flip ab 0,0×ATR Gewinn", dict(flip=True, min_r=0.0)), + ("Flip ab 0,3×ATR Gewinn", dict(flip=True, min_r=0.3)), + ("Flip ab 0,5×ATR Gewinn", dict(flip=True, min_r=0.5)), + ("Flip AUCH im Minus", dict(flip=True, flip_neg=True))] + base = {} + for name, kw in variants: + print(f"\n{name}") + for lbl, a, b in halves: + Rs = run(sig, H, L, C, AT, SP, a, b, **kw) + s = sum(Rs) + if not kw.get("flip"): + base[lbl] = s + delta = f" Δ vs Basis {s - base.get(lbl, 0):+.0f}" if kw.get("flip") else "" + print(f" {lbl} {st(Rs)}{delta}") + + +if __name__ == "__main__": + main() diff --git a/core/engine.py b/core/engine.py index a460c28..d4fccee 100644 --- a/core/engine.py +++ b/core/engine.py @@ -318,6 +318,17 @@ class TradingEngine: self._open_pos_dir = None # Richtung der aktuell offenen Position (LONG/SHORT) self._pos_close_ts = 0.0 # Zeit + Richtung des letzten Positions-Close (JEDE self._news_block_logged = None # Dedup für die News-Blackout-Logzeile + # Auto-Flip-Close (User-Wunsch 2026-07-31) — ⚠ GEMESSEN NEGATIV in beiden + # Hälften (`backtest_flipclose2.py`), s. `_check_flip_close`. Default-Schwelle + # 0,5×ATR = die mildeste der gemessenen Varianten. + _tf_cfg = self.cfg["trading"] + self._flip_close = _tf_cfg.get("auto_flip_close", "false").lower() == "true" + try: + self._flip_close_min_r = float(_tf_cfg.get("auto_flip_close_min_r", "0.5")) + except (TypeError, ValueError): + self._flip_close_min_r = 0.5 + self._flip_close_done_ticket = None + self._flip_close_count = 0 self._pos_close_dir = None # Art) → Squeeze-Wiedereinstiegs-Sperre (gegen die # Open→Instant-Close-Kaskade bei zu engem Notfall-Stop) self._sr_close_min_gain_armed_ticket = None # Ticket, für das die %-Schwelle schon @@ -605,6 +616,7 @@ class TradingEngine: self._check_circuit_breaker() # Tagesverlust-Stopp (Überleben zuerst) self._check_auto_close() self._check_adverse15() # 15-Min-Regel (nur Bot-Trades) + self._check_flip_close() # Empfehlung dreht → schließen self._check_sr_close() self._check_auto_squeeze() # autonomer Entry auf Squeeze-Ausbruch self._check_auto_signal() # autonomer Entry auf die Empfehlung @@ -1420,6 +1432,85 @@ class TradingEngine: "news_score": None, } + def _check_flip_close(self): + """Auto-Flip-Close: dreht die Empfehlung GEGEN die offene Position und steht + sie mindestens `min_r`×ATR im Plus → schließen. (`[trading] auto_flip_close`) + + ⚠⚠ **GEMESSEN NEGATIV — auf ausdrücklichen User-Wunsch trotzdem gebaut** + (2026-07-31 „schließe den Trade automatisch beim Close-Signal"). + `backtest_flipclose2.py` (80k M5, 2 Halbjahre, Echtkosten, Exit aus + `core/exit_model.py`) — JEDE Variante ist in BEIDEN Hälften schlechter als + gar kein Flip-Close: + Basis (kein Flip) H1 ΣR −537 · H2 −140 + Flip ab 0,0×ATR Δ H1 −126 · H2 −221 + Flip ab 0,3×ATR Δ H1 −77 · H2 −216 + Flip ab 0,5×ATR Δ H1 −92 · H2 −160 ← die MILDESTE, deshalb Default + Flip auch im Minus Δ H1 −205 · H2 −364 + Mechanik: die Trefferquote steigt (39 → 45 %), der Ertrag fällt — + **Gewinner-Kappen**, weil die nachlaufende EMA oft mitten im Pullback dreht + und der Trade weitergelaufen wäre. + ⚠ Das bestätigt das ältere Urteil (`backtest_flipclose.py`, 2026-07-16). Die + Neumessung war trotzdem nötig: das alte Skript nutzte ein VIERTES Exit-Modell + (Trail fest 1,5 · keine Lock-Phase · kein Time-Stop · kein Initial-TP · + `_MAXH` 288 · Phasen über den Bar-Close statt über das High-Water) — auf + dieser Grundlage wäre das Urteil für den Live-Betrieb nicht bindend gewesen. + **B4/B5-Pflicht:** Live-Ertrag gegen die Erwartung halten; driftet es weg → + `auto_flip_close=false`. Der Alarm (Blinken/Ton/Telegram) bleibt unabhängig + davon bestehen. + """ + if not self._flip_close or self._in_startup_grace(): + return + try: + ps = self.trader.snapshot() + ticket = ps.get("ticket") + if not ticket: + self._flip_close_done_ticket = None + return + if ticket == self._flip_close_done_ticket: + return # für dieses Ticket schon ausgelöst + pnl = float(ps.get("pnl") or 0.0) + if pnl <= 0: + return # nie im Minus (gemessen die schlechteste Variante) + sig = (self.wave.signal() or {}).get("signal") + if sig not in ("LONG", "SHORT"): + return + is_long = ps.get("order_type") == mt5.ORDER_TYPE_BUY + if (is_long and sig != "SHORT") or (not is_long and sig != "LONG"): + return # kein Gegen-Signal + # Mindestgewinn in ×ATR (M5, wie im Backtest) + atr = float(((self.wave.snapshot() or {}).get("pb_feats") or {}).get("atr") or 0.0) + entry = float(ps.get("entry_price") or 0.0) + bid = float((self.data.snapshot() or {}).get("bid") or 0.0) + if atr > 0 and entry and bid: + d = 1 if is_long else -1 + prof_atr = (bid - entry) * d / max(atr, 0.06) + if prof_atr < self._flip_close_min_r: + return + else: + return # ohne ATR keine Schwelle → nicht schließen + self._flip_close_done_ticket = ticket + log.info(f"🔄 AUTO-FLIP-CLOSE: Empfehlung dreht auf {sig} — " + f"{'LONG' if is_long else 'SHORT'} bei P&L {pnl:+.2f} € " + f"({prof_atr:.2f}×ATR) geschlossen") + err = self.trader.close(reason="flip_close") + if err: + log.error(f"Auto-Flip-Close fehlgeschlagen: {err}") + self._flip_close_done_ticket = None # nächster Tick erneut + return + self._flip_close_count += 1 + tg = self.cfg["telegram"] + if tg.get("enabled", "false").lower() == "true" and tg.get("bot_token"): + try: + send_telegram( + f"🔄 AUTO-FLIP-CLOSE\n{self.data.symbol or ''} " + f"{'LONG' if is_long else 'SHORT'} geschlossen — Empfehlung " + f"dreht auf {sig}.\nGewinn {pnl:+.2f} € ({prof_atr:.2f}×ATR)", + tg["bot_token"], tg["chat_id"]) + except Exception as e: + log.warning(f"Flip-Close-Telegram: {e}") + except Exception as e: + log.error(f"_check_flip_close: {e}", exc_info=True) + def _check_close_alert(self, rec: dict): """Telegram-CLOSE-Alarm NUR bei Signal-Flip gegen die Position (echter Wende-Grund). Max. 1×/5 min (`_CLOSE_ALERT_COOLDOWN_S`) — sonst spammt es. @@ -2871,6 +2962,11 @@ class TradingEngine: "signal_entry_count": self._signal_entry_count, "adverse15_atr": self._adverse15_atr, "adverse15_count": self._adverse15_count, + # Auto-Flip-Close (gemessen negativ, auf User-Wunsch gebaut) — für den + # B4-Monitor sichtbar machen, wie oft er zuschlägt. + "flip_close": self._flip_close, + "flip_close_min_r": self._flip_close_min_r, + "flip_close_count": self._flip_close_count, "last_trades": last_trades, # Wirtschaftskalender: {blackout, next, enabled, n} — Anzeige + Grund, # warum der Bot gerade keine autonome Order eröffnet. diff --git a/core/exit_model.py b/core/exit_model.py index 6b3d401..d4187d4 100644 --- a/core/exit_model.py +++ b/core/exit_model.py @@ -88,7 +88,9 @@ 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: + 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. @@ -105,6 +107,14 @@ def simulate(entry: float, d: int, atr: float, (`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 @@ -113,21 +123,29 @@ def simulate(entry: float, d: int, atr: float, 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 (sl - entry) * d / atr + return _out(sl, j) if tp is not None and ((hi >= tp) if d > 0 else (lo <= tp)): - return (tp - entry) * d / atr + 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 (C[j] - entry) * d / atr + 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) @@ -135,4 +153,4 @@ def simulate(entry: float, d: int, atr: float, 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 + return _out(C[end], end)