diff --git a/CLAUDE.md b/CLAUDE.md index 0ba480b..a9b819b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1882,6 +1882,64 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: entry_gap.py` rechnet DST-korrekt über `zoneinfo` und liefert **+0,275 / +0,686** (n=34 unverändert). Gültig sind die zweiten Werte; die Schlussfolgerung ändert sich nicht. + - **⚠⚠ EIN PENDING-FILL LIEF AN DER GESAMTEN BUCHFÜHRUNG VORBEI (Fix 2026-08-06, + User-Auftrag „überprüfe die heutigen autotrades").** Der Befund kam nur zustande, + weil die Frage überhaupt gestellt wurde: die DB meldete für den 06.08. **null + Bot-Trades**, das Log dagegen vier gefüllte `SQZ-STOP`-Orders. Beides stimmte. + **Mechanismus:** eine ruhende Stop-Order füllt **im Broker**, nicht über + `engine._open`. Die Position wird erst später von `trader._refresh_locked` per + magic-match **adoptiert** — und dieser Pfad ruft `history.log_trade_open()` nur mit + Ticket/Symbol/Richtung/Lots/Preis auf. Es fehlten damit **alle** Felder, die der + Market-Pfad setzt: `setup`, `sl_at_entry` und die **16 `ctx_*`-Spalten** der + Telemetrie-Stufe 1. Vier reale Trades waren so von manuellen **nicht + unterscheidbar**. + ⚠ **Die Tragweite ist größer als „ein NULL-Feld":** genau die Messpipeline, die den + Pending-Umbau vom 05.08. kontrollieren soll, war **blind für ihn** — B4-Monitor, + `squeeze_b5` (Abbruchregel!) und `squeeze_entry_gap` zählten null Trades, und + `sl_at_entry` fehlte ausgerechnet bei den Trades, an denen die M5-SL-Umstellung zu + prüfen war. **Deployment-Drift Fall 8**, und die erste Variante, bei der nicht die + Strategie driftet, sondern **ihre Beobachtbarkeit**. Ein Umbau, der den Bestellweg + ändert, ändert auch, welcher Code die Bücher führt — das gehört beim Umbau geprüft. + **Fix:** `history.tag_bot_trade(ticket, setup)` (UPDATE nur wenn `setup IS NULL`, + stiller Fail-open — `core/history.py` hat bewusst keinen Logger) + + `engine._pending_tickets` (Ticket → Quelle, beim Platzieren gesetzt, beim + Stornieren/Taggen entfernt) + `_check_pending_fill()` im `_pos_loop` VOR dem + Pending-Manager. Die Zuordnung ist eindeutig, weil bei einem Pending-Fill die + **Positions-Nummer identisch mit der Order-Nummer** ist (an allen vier + Broker-Datensätzen des 06.08. verifiziert) — kein Zeitfenster-Raten. Der Fix setzt + zusätzlich `_bot_open_ticket`/`_bot_open_source` und die Entry-Zähler, damit + 15-Min-Regel, B4-Monitor und Snapshot wieder dieselbe Wahrheit sehen. + Mit **6 Szenarien getestet**, darunter die beiden gefährlichen: fremde/manuelle + Position wird NICHT getaggt, und derselbe Fill zählt bei wiederholten Ticks nur + **einmal**. Die vier Trades des 06.08. wurden nachgetragen (DB-Backup + `oil_widget_history.db.bak-2026-08-06`). + ⚠ **Bewusst NICHT nachgetragen: `ctx_*` und `sl_at_entry`.** Die stehen für den + Zustand **im Moment des Fills** und lassen sich nachträglich nur rekonstruieren, + nicht messen — eine rekonstruierte Zahl in einer Telemetrie-Spalte wäre später von + einer gemessenen nicht mehr unterscheidbar. Ab jetzt sind sie für Pending-Fills + weiterhin leer; wer sie braucht, muss den Kontext beim **Platzieren** der Order + einfrieren (offen, nicht gebaut). + - **⚠ OFFEN: Pending-Churn im SIGNAL-Pfad (gefunden 06.08., nicht behoben).** Am + 06.08. wurde dieselbe SELL_STOP viermal in 76 s gesetzt und storniert — **bei + identischem Level 74,981**, es wandert also nichts. Die Ursache steht in + `recommendations.block_reason`: der Grund **pendelt zwischen `entry_room` und + `breakout_pending`**. Sobald das Raum-Gate den Kurs kurz stummschaltet, liefert + `_pending_ziel()` `{}` und der Manager storniert; eine Sekunde später ist das Gate + wieder offen und die Order wird neu gelegt. + ⚠ **Das ist wieder Deployment-Drift, nicht nur Log-Lärm:** `backtest_auto_signal_v3.py` + hat den Pending-Vorteil (ØR +0,35/+0,32 gegen −0,09/−0,11 am Markt) unter der + Annahme gemessen, dass die Order **durchgehend am Level liegt**. Flackert sie im + 5-s-Takt, geht genau der Ausbruch verloren, der in eine Storno-Lücke fällt — und + das ist der Fall, für den sie da ist. + **Lösungsrichtung (ungebaut):** harte von weichen Storno-Gründen trennen. HART + (sofort stornieren) = Position offen · Startup-Schonfrist · Guard (Nacht/Circuit/ + News) · Richtungswechsel · Level wandert über die Toleranz · Feature aus. WEICH + (Order liegen lassen) = kurzzeitiges Stummschalten durch `entry_room`/`min_conf`/ + `breakout_pending`. Argument dafür: die Order füllt nur, wenn der Kurs das Level + **bricht** — dann hat er sich vom Gegenlevel wegbewegt, die Raum-Bedingung ist im + Fill-Moment also besser als im Flacker-Moment. Argument dagegen: das ist eine + Aussage über den Einstiegs-Filter und damit **nicht rein infrastrukturell** — + vor dem Bau messen (Variante „Order bleibt liegen" gegen „Order flackert"). - **Auto-Squeeze-Entry = LIVE AUTONOM (User-Vorgabe 2026-07-16, `[trading] auto_squeeze=true`, Default FALSE):** der Bot eröffnet **selbständig eine echte Order**, sobald der **gemessen-validierte** Squeeze-Ausbruch feuert (`wave.squeeze. @@ -3374,6 +3432,8 @@ Bedingungen BETRIEBEN als VALIDIERT. An EINEM Tag wurden drei Fälle gefunden: | 7 | Auto-Squeeze-**Einstiegspreis** | Fill **AM Ausbruchs-Level** (`backtest_breakout_squeeze.py`) | **MARKET**-Order, live median **+0,275×ATR** über dem Level (34 echte Entries) | ØR **+0,456 → −0,158** (H1) bzw. **+0,611 → −0,006** (H2); WR 56 → 37 % | | 6 | `auto_squeeze_skip_night` | Nacht-Kostenfalle am **Wellensignal** (alle Nacht-Bars: 0,333×ATR) | **Squeeze**-Ausbrüche, die nur bei anziehendem ATR feuern (0,183×ATR = Tag-Niveau) | Gate kostet ΣR **−42 (H1) / −243 (H2)**; geblockte Ausbrüche sind in beiden Hälften positiv | | 5 | `backtest_auto_signal.py` | **ohne Winkel** (`_build` ohne `angle=` → Default 90 → `ad=0`) | Winkel steuert einen **Signalzweig** (Reversal) UND **±15 Konfidenzpunkte** | Urteil vom 30.07. beschrieb weder das alte noch das neue System; der Winkelterm allein hebt H1 bei conf 75 von **−0,123 auf −0,047** | +| **8** | **Buchführung der Bot-Trades** (nicht die Strategie, ihre **Beobachtbarkeit**) | Einstieg läuft über `engine._open` → schreibt `setup` + `sl_at_entry` + 16 `ctx_*` | **Pending-Fill** füllt im Broker, `trader._refresh_locked` adoptiert und loggt nur 5 Felder | 4 reale Squeeze-Trades am 06.08. als `setup=NULL` = **von manuellen ununterscheidbar**; B4-Monitor, `squeeze_b5` (Abbruchregel!) und `squeeze_entry_gap` zählten **null** | +| **9** | **Pending-Order liegt am Level** (offen) | `backtest_auto_signal_v3.py`: Order liegt **durchgehend** (ØR +0,35/+0,32) | `entry_room`↔`breakout_pending` flackern → 4× setzen/stornieren in 76 s bei identischem Level | Ausbruch in einer Storno-Lücke = Entry ganz verloren; Ausmaß **ungemessen** | Keiner wäre durch MEHR Backtesting gefunden worden — die Backtests waren korrekt. **Ursachen (strukturell):** (a) Backtests sind NACHBAUTEN, keine Nutzer des Live-Codes diff --git a/core/engine.py b/core/engine.py index fee7a58..0e3cf41 100644 --- a/core/engine.py +++ b/core/engine.py @@ -355,6 +355,11 @@ class TradingEngine: "signal_pending_entry", "true").lower() == "true" self._pending_levels = None self._pending_quelle = "" + # Welche Pending-Order stammt von welchem Pfad? Bei einem FILL ist die + # Positions-Nummer identisch mit der Order-Nummer (MT5) — nur so laesst + # sich die adoptierte Position nachtraeglich dem Bot zuordnen. + self._pending_tickets: dict[int, str] = {} + self._pending_tagged: set = set() self._squeeze_pending_err = None self._squeeze_chase_logged = None self._signal_chase_logged = None @@ -707,6 +712,7 @@ class TradingEngine: # ZUERST die ruhenden Stop-Orders pflegen (setzen/stornieren), # DANN der Market-Fallback: so kann eine bereits liegende Order # den Ausbruch am Level abfangen, bevor nachgejagt würde. + self._check_pending_fill() # gefuellte Pending-Order zuordnen self._manage_squeeze_pending() self._check_auto_squeeze() # autonomer Entry auf Squeeze-Ausbruch self._check_auto_signal() # autonomer Entry auf die Empfehlung @@ -1055,6 +1061,58 @@ class TradingEngine: """Alias — `_check_auto_squeeze` ruft weiterhin diesen Namen.""" return self._auto_guard("squeeze") + def _check_pending_fill(self): + """Hat eine von UNS platzierte Pending-Order gefuellt? Dann nachtragen. + + ⚠ WARUM: eine gefuellte Pending-Order laeuft NICHT durch `_open` — MT5 + oeffnet die Position selbst, und `trader._refresh_locked` adoptiert sie per + magic-match und loggt sie OHNE Setup. Am 06.08. fuellten drei `SQZ-STOP`- + Orders und landeten als `setup=NULL` in der DB, also ununterscheidbar von + manuellen Trades. Damit zaehlten der B4-Monitor und die Reminder + `squeeze_b5` / `squeeze_entry_gap` **null Bot-Trades**, obwohl der Bot + gehandelt hatte — die Messpipeline war blind fuer genau den Umbau, den sie + kontrollieren soll. + Die Zuordnung ist eindeutig: bei einem Pending-Fill ist die POSITIONS-Nummer + identisch mit der ORDER-Nummer (in den Broker-Daten vom 06.08. verifiziert). + """ + try: + if not self._pending_tickets: + return + ps = self.trader.snapshot() + tk = ps.get("ticket") + if not tk or tk in self._pending_tagged: + return + quelle = self._pending_tickets.get(int(tk)) + if not quelle: + return # fremde/manuelle Position + d = "LONG" if ps.get("order_type") == 0 else "SHORT" + setup = f"SQUEEZE_{d}" if quelle == "squeeze" else f"AUTOSIG_{d}" + 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 + # Dieselbe Buchfuehrung wie beim Market-Pfad, damit B4/B5 zaehlen + self._bot_open_ticket = tk + self._bot_open_source = "auto_squeeze" if quelle == "squeeze" else "auto_signal" + if quelle == "squeeze": + self._squeeze_entry_count += 1 + else: + self._signal_entry_count += 1 + log.info(f"🎯 PENDING-FILL ({quelle}): {d} @ {ps.get('entry_price')} " + f"— Position {tk} als {setup} getaggt" + + ("" if ok else " ⚠ DB-Nachtrag fehlgeschlagen")) + tg = self.cfg["telegram"] + if tg.get("enabled", "false").lower() == "true" and tg.get("bot_token"): + try: + send_telegram(f"🎯 Pending-Fill ({quelle})\n" + f"{self.data.symbol or ''} {d} @ " + f"{ps.get('entry_price')} — ruhende Stop-Order " + f"am Level ausgeloest.", + tg["bot_token"], tg["chat_id"]) + except Exception as e: + log.warning(f"Pending-Fill-Telegram: {e}") + except Exception as e: + log.error(f"_check_pending_fill: {e}", exc_info=True) + def _pending_ziel(self) -> tuple[dict, str]: """Welche Stop-Orders SOLLEN liegen? → ({order_type: preis}, Quelle). @@ -1125,6 +1183,7 @@ class TradingEngine: for o in offen: err = self.trader.cancel_pending(o.ticket) if not err: + self._pending_tickets.pop(int(o.ticket), None) log.info(f"⏸ Pending-Entry storniert (T={o.ticket}) — " + ("Position offen" if ps.get("ticket") else "Setup nicht mehr gültig")) @@ -1145,6 +1204,7 @@ class TradingEngine: vorhanden[o.type] = o.ticket else: self.trader.cancel_pending(o.ticket) + self._pending_tickets.pop(int(o.ticket), None) for otype, preis in ziel.items(): if otype in vorhanden: continue @@ -1154,6 +1214,7 @@ class TradingEngine: # gleiche Regel wie beim Market-Pfad (s. `_open`) atr_tf=(mt5.TIMEFRAME_M5 if quelle == "squeeze" else None)) if t: + self._pending_tickets[int(t)] = quelle log.info(f"🎯 Pending-Entry ({quelle}) gesetzt: " f"{'BUY_STOP' if otype == mt5.ORDER_TYPE_BUY_STOP else 'SELL_STOP'}" f" @ {preis:.3f} (T={t})") diff --git a/core/history.py b/core/history.py index ef345e2..690ddee 100644 --- a/core/history.py +++ b/core/history.py @@ -576,6 +576,32 @@ class HistoryLogger: return (int(since.timestamp()), now) return (0, now) + def tag_bot_trade(self, ticket: int, setup: str) -> bool: + """Setup einer bereits geloggten Position NACHTRAGEN. → True bei Erfolg. + + ⚠ WARUM DAS NOETIG IST: eine gefuellte PENDING-Order laeuft nicht durch + `engine._open`, sondern wird vom Positions-Abgleich als „magic-match" + adoptiert und mit `log_trade_open(...)` OHNE Setup geschrieben. Am 06.08. + fuellten drei `SQZ-STOP`-Orders und landeten dadurch als `setup=NULL` in der + DB — ununterscheidbar von manuellen Trades. Folge: B4-Monitor und die + Reminder `squeeze_b5` / `squeeze_entry_gap` zaehlten 0, obwohl der Bot + gehandelt hatte. Genau die Messpipeline, die den Pending-Umbau + kontrollieren soll, war blind fuer ihn. + Nur setzen, wenn noch KEIN Setup steht — ein manuell/regulaer getaggter + Trade darf nicht ueberschrieben werden.""" + try: + with self._connect() as conn: + cur = conn.execute( + "UPDATE trades SET setup=? WHERE ticket=? AND setup IS NULL", + (setup, int(ticket))) + return cur.rowcount > 0 + except Exception: + # ⚠ `core/history.py` fuehrt bewusst keinen Logger (Konvention der + # Datei: stilles Abfangen). Ein fehlgeschlagenes Nachtragen ist + # unkritisch — der Trade steht in der DB, nur ohne Setup-Tag; der + # Aufrufer sieht das am Rueckgabewert. + return False + def stats_overview(self, period: str = "all") -> dict: """Liefert ein Dict mit den Hauptkennzahlen für den gewählten Zeitraum.""" since, until = self._range_to_ts(period)