diff --git a/backtest_squeeze_frozen.py b/backtest_squeeze_frozen.py new file mode 100644 index 0000000..28c466a --- /dev/null +++ b/backtest_squeeze_frozen.py @@ -0,0 +1,158 @@ +"""Wandert die Squeeze-Marke dem Kurs entgegen — und was kostet das? (2026-08-20) + +ANLASS (User, live beobachtet): "warum hat der breakout squeeze ausgeloest, ich +kann keinen breakout erkennen". Die Logspur vom 20.08. gibt ihm recht: + + 07:25:08 BUY_STOP @ 85.457 + 07:30:01 BUY_STOP @ 85.432 + 07:35:04 BUY_STOP @ 85.417 + 07:40:03 BUY_STOP @ 85.342 <- 11,5 Cent tiefer als 15 min zuvor + 07:40:12 FILL @ 85.344 <- 9 Sekunden spaeter + +Der Kurs stieg in dem Fenster von 85,176 auf 85,355 - er hat die Marke NICHT +durchbrochen, die MARKE kam ihm entgegen. + +URSACHE. `box_hi` ist das Hoch der letzten `_SQ_N` abgeschlossenen M5-Bars, also +ein ROLLIERENDES Fenster. Faellt der Kurs, rollen die alten hohen Bars hinten +heraus und das Box-Dach sinkt mit; die ruhende Order wird nachgezogen. + +⚠⚠ DAS VERLETZT DAS PRINZIP, AUF DEM DER PENDING-UMBAU VOM 05.08. BERUHT und das +im Projekt woertlich steht: "Ein Level ist nur dann ein Ausfuehrungs-Vorteil, +wenn es STILLSTEHT." Genau daran ist der SIG-Pending-Pfad gescheitert +(OeR -0,392/-0,338, abgeschaltet) - `_pend` wandert, und die Order faengt +systematisch die Fehlausbrueche ein. Der Squeeze galt als der Fall, wo das Level +STEHT: `backtest_squeeze_touchfill.py` misst den Ausbruch aus einer Box, die im +Moment des Ausbruchs feststeht. Live gleitet sie. + +WAS HIER GEMESSEN WIRD - zwei Varianten auf DENSELBEN Bars: + + A) NACHGEZOGEN (= live): die Marke wird jede Bar neu aus der aktuellen Box + berechnet; gefuellt wird, sobald der Kurs die JEWEILS AKTUELLE Marke + beruehrt. Bildet das beobachtete Verhalten nach. + B) EINGEFROREN: die Marke wird EINMAL gesetzt, wenn die Kompression beginnt, + und bleibt stehen, bis die Kompression endet. Gefuellt wird nur bei + Beruehrung DIESER Marke. Das ist, was der Backtest unterstellt. + +Zusaetzlich wird in A ausgezaehlt, wie viele Fills reine NACHLAEUFER sind: die +Marke lag beim Fill TIEFER (LONG) bzw. HOEHER (SHORT) als bei der Armierung, und +der Kurs hat die urspruengliche Marke nie erreicht. Genau der Fall des Users. + +⚠ KEIN NACHBAU: die Kompression kommt aus der echten `wave_rec._squeeze_one`, +der Exit aus `exit_model.simulate` (kanonisch), Kosten aus dem Bar-Spread. + +REGEL VORAB FIXIERT: "Einfrieren" ist nur dann eine Verbesserung, wenn B in +BEIDEN Halbjahren einen hoeheren OeR liefert UND die Nachlaeufer-Quote in A +substanziell ist (>10 %). Ist die Quote klein, ist der Live-Fall ein Einzelfall +und nichts zu aendern. + +Aufruf: python backtest_squeeze_frozen.py [bars] +""" +import sys + +import MetaTrader5 as mt5 + +from backtest_cost_gate import kennzahlen +from core.exit_model import LIVE, simulate +from core.squeeze_scan import atr_series +from core.wave_rec import WaveRecommender + +ATRMIN = 0.06 # LIVE-Wert (nicht der Modul-Default 0,12) +MAX_WARTE = 24 # Bars, die eine armierte Marke hoechstens gilt + + +def zustand(H, L, C, A, i): + """Echte `_squeeze_one`-Auswertung fuer Bar i (Fenster wie live).""" + lo = max(0, i - 40) + return WaveRecommender._squeeze_one(H[lo:i + 1], L[lo:i + 1], + C[lo:i + 1], A[i]) + + +def lauf(H, L, C, A, SP, von, bis, ende, modus): + """modus 'nach' = Marke wandert (live) · 'fix' = beim Armieren eingefroren.""" + rs, nachlaeufer, gesamt = [], 0, 0 + i = von + marke = None # (preis, richtung, bar_armiert, preis_bei_armierung) + while i < bis: + atr = A[i] + if not atr or atr < ATRMIN: + i += 1; marke = None; continue + z = zustand(H, L, C, A, i) + if z.get("state") is None: + marke = None; i += 1; continue + lvl, d = z.get("level"), (1 if z.get("dir") == "LONG" else -1) + if lvl is None: + i += 1; continue + if marke is None or marke[1] != d or i - marke[2] > MAX_WARTE: + marke = (lvl, d, i, lvl) # neu armieren + elif modus == "nach": + marke = (lvl, d, marke[2], marke[3]) # Marke nachziehen + # Fill? Beruehrung der AKTUELL gueltigen Marke im naechsten Bar + preis = marke[0] + j = i + 1 + if j >= ende: + break + if ((H[j] >= preis) if d > 0 else (L[j] <= preis)) and j + 1 < ende: + gesamt += 1 + # Nachlaeufer: Marke ist dem Kurs entgegengekommen UND die + # urspruengliche Marke wurde nie erreicht. + urspr = marke[3] + entgegen = (preis < urspr - 1e-9) if d > 0 else (preis > urspr + 1e-9) + nie_erreicht = (max(H[marke[2]:j + 1]) < urspr) if d > 0 \ + else (min(L[marke[2]:j + 1]) > urspr) + if entgegen and nie_erreicht: + nachlaeufer += 1 + r = simulate(preis, d, atr, H, L, C, j + 1, LIVE, + timestop=True, use_tp=True) + rs.append(r - ((SP[j] if SP[j] > 0 else 0.0225) / atr)) + marke = None + i = j + 3 # kleiner Cooldown wie live + continue + i += 1 + return rs, nachlaeufer, gesamt + + +def zeig(lbl, k, nl, ges): + if not k: + print(" {:<24} --".format(lbl)); return + ci = k.get("ci"); m = "" + if ci: + m = (" [{:+.3f} .. {:+.3f}]".format(ci[0], ci[1]) + + ("" if ci[0] * ci[1] > 0 else " ~0")) + quote = (100.0 * nl / ges) if ges else 0.0 + print(" {:<24} n={:>4} WR={:>3.0f}% OeR={:+.3f} PF={:.2f}{} Nachlaeufer {:.0f} %" + .format(lbl, k["n"], k["wr"], k["oer"], k["pf"], m, quote)) + + +def main(): + n = int(sys.argv[1]) if len(sys.argv) > 1 else 40000 + mt5.initialize() + sym = next((c for c in ("SpotCrude", "USOIL", "WTI", "XTIUSD") + if mt5.symbol_info(c)), None) + bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, n) + point = mt5.symbol_info(sym).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] + A = atr_series(H, L, C) + N = len(C); mid = N // 2; ende = N - LIVE.max_hold - 1 + + print("=" * 100) + print(" WANDERNDE gegen EINGEFRORENE Squeeze-Marke — {} M5 ({} Bars)".format(sym, N)) + print(" echte `_squeeze_one` · kanonischer Exit · Echtkosten · 95-%-KI") + print("=" * 100) + for modus, titel in (("nach", "A) NACHGEZOGEN (= live)"), + ("fix", "B) EINGEFROREN beim Armieren")): + print("\n " + titel) + print(" " + "-" * 96) + for lbl, (lo, hi) in (("H1", (0, mid)), ("H2", (mid, ende))): + rs, nl, ges = lauf(H, L, C, A, SP, lo, hi, ende, modus) + zeig(lbl, kennzahlen(rs), nl, ges) + print("\n " + "=" * 96) + print(" LESART: 'Nachlaeufer' = die Marke kam dem Kurs ENTGEGEN und die") + print(" urspruengliche Marke wurde nie erreicht — also kein Ausbruch.") + print(" Ist die Quote klein, war der Live-Fall ein Einzelfall.") + + +if __name__ == "__main__": + main() diff --git a/core/engine.py b/core/engine.py index 4c34476..72c76fa 100644 --- a/core/engine.py +++ b/core/engine.py @@ -865,6 +865,7 @@ class TradingEngine: # erkannt und die Benachrichtigung vorgemerkt. self._check_close_notify() # Telegram bei jedem Close ab Schwelle self._check_open_notify() # Telegram bei jeder Eröffnung + self._retry_tag_nachtrag() # gescheiterte setup-Nachtraege self._check_slot_reichweite() # meldet ungedeckte 2. Position self._check_adverse15() # 15-Min-Regel (nur Bot-Trades) self._check_flip_close() # Empfehlung dreht → schließen @@ -949,6 +950,41 @@ class TradingEngine: return False return self._circuit_tripped_date == datetime.now(_BERLIN).date() + def _retry_tag_nachtrag(self): + """Gescheiterte `setup`-Nachträge erneut versuchen (2026-08-20). + + ⚠ Der Fill wird aus dem Positions-Snapshot erkannt, die DB-Zeile + schreibt aber ein anderer Pfad. Ist der beim ersten Versuch noch nicht + durch, trifft das UPDATE keine Zeile. Ohne Wiederholung bliebe der Trade + dauerhaft `setup=NULL` — und damit unsichtbar für B4-Monitor, + `squeeze_b5` und `squeeze_entry_gap`. + ⚠ Nach 60 Versuchen (~1 min) aufgeben und LAUT melden: eine Zeile, die + nach einer Minute nicht existiert, kommt nicht mehr — dann ist etwas + anderes kaputt, und das soll nicht still bleiben. + """ + offen = getattr(self, "_tag_nachtrag", None) + if not offen or not self.history: + return + if not hasattr(self, "_tag_versuche"): + self._tag_versuche = {} + for tk in list(offen): + setup = offen[tk] + try: + if self.history.tag_bot_trade(int(tk), setup): + offen.pop(tk, None) + self._tag_versuche.pop(tk, None) + log.info(f"✅ DB-Nachtrag erfolgreich: T={tk} → {setup}") + continue + except Exception as e: + log.debug(f"_retry_tag_nachtrag: {e}") + n = self._tag_versuche.get(tk, 0) + 1 + self._tag_versuche[tk] = n + if n >= 60: + offen.pop(tk, None) + self._tag_versuche.pop(tk, None) + log.warning(f"⚠ DB-Nachtrag T={tk} nach {n} Versuchen aufgegeben " + f"— der Trade bleibt setup=NULL und fehlt in B4/B5.") + def _check_open_notify(self): """Telegram bei JEDER Eröffnung — beide Slots (User-Vorgabe 2026-08-19). @@ -1481,6 +1517,22 @@ class TradingEngine: self._pending_tagged.add(tk) self._pending_tickets.pop(int(tk), None) ok = self.history.tag_bot_trade(int(tk), setup) if self.history else False + # ⚠⚠ RENNEN, real am 2026-08-20: der Fill wird hier aus + # `trader_brk.snapshot()` erkannt, die DB-ZEILE schreibt aber ein + # anderer Pfad (`trader._refresh_locked` → `log_trade_open`). Ist + # der noch nicht durch, trifft das UPDATE keine Zeile und der + # Nachtrag scheitert STILL — der Trade bleibt `setup=NULL` und ist + # von einem manuellen nicht unterscheidbar. Genau die + # Buchfuehrungs-Luecke, gegen die `tag_bot_trade` gebaut wurde + # (Deployment-Drift Fall 8), nur eine Ebene tiefer. + # ⚠ Der Fall ist NICHT theoretisch: T=50371599 lag danach mit + # `setup=NULL` in der DB, obwohl die Zeile Sekunden spaeter da war. + # Deshalb nachreichen statt aufgeben — dasselbe Retry-Muster wie + # beim Gewinn-Close (`_sr_close_min_gain_armed_ticket`, 23.07.). + if not ok: + if not hasattr(self, "_tag_nachtrag"): + self._tag_nachtrag = {} + self._tag_nachtrag[int(tk)] = setup # Dieselbe Buchfuehrung wie beim Market-Pfad, damit B4/B5 zaehlen self._bot_open_ticket = tk self._bot_open_source = "auto_squeeze"