Pending-Fill: Bot-Trades wurden nicht als solche gebucht

Auftrag "überprüfe die heutigen autotrades": die DB meldete für den 06.08.
null Bot-Trades, das Log vier gefüllte SQZ-STOP-Orders. Beides stimmte.

Ein Pending-Fill läuft nicht über engine._open — die Position wird von
trader._refresh_locked per magic-match adoptiert, und dieser Pfad loggt nur
Ticket/Symbol/Richtung/Lots/Preis. Es fehlten setup, sl_at_entry und alle
16 ctx_*-Spalten. Damit war genau die Messpipeline, die den Pending-Umbau
vom 05.08. kontrollieren soll, blind für ihn (B4-Monitor, squeeze_b5,
squeeze_entry_gap zählten null) — Deployment-Drift Fall 8, erste Variante,
bei der nicht die Strategie driftet, sondern ihre Beobachtbarkeit.

- history.tag_bot_trade(): UPDATE nur wenn setup IS NULL, stiller Fail-open
- engine._pending_tickets: Ticket -> Quelle, beim Platzieren/Stornieren gepflegt
- engine._check_pending_fill() im _pos_loop vor dem Pending-Manager;
  Zuordnung über Positions-Nr == Order-Nr (an allen 4 Fills verifiziert),
  setzt zusätzlich _bot_open_ticket/_source und die Entry-Zähler

6 Szenarien getestet, darunter die zwei gefährlichen: fremde Position wird
nicht getaggt, derselbe Fill zählt bei Folge-Ticks nur einmal.
Die 4 Trades des 06.08. nachgetragen (Backup .bak-2026-08-06).

ctx_*/sl_at_entry bewusst NICHT nachgetragen: rekonstruiert wären sie später
von gemessenen Werten nicht unterscheidbar.

Dabei gefunden, dokumentiert, NICHT behoben: der Signal-Pfad setzt/storniert
dieselbe Order 4x in 76 s bei identischem Level, weil block_reason zwischen
entry_room und breakout_pending pendelt (Drift Fall 9).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Axel Hocks
2026-08-06 10:55:41 +02:00
co-authored by Claude Opus 5
parent 730219fce8
commit 3c1442109d
3 changed files with 147 additions and 0 deletions
+60
View File
@@ -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** 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 (n=34 unverändert). Gültig sind die zweiten Werte; die Schlussfolgerung ändert
sich nicht. 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-Entry = LIVE AUTONOM (User-Vorgabe 2026-07-16, `[trading]
auto_squeeze=true`, Default FALSE):** der Bot eröffnet **selbständig eine echte auto_squeeze=true`, Default FALSE):** der Bot eröffnet **selbständig eine echte
Order**, sobald der **gemessen-validierte** Squeeze-Ausbruch feuert (`wave.squeeze. 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 % | | 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 | | 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** | | 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. Keiner wäre durch MEHR Backtesting gefunden worden — die Backtests waren korrekt.
**Ursachen (strukturell):** (a) Backtests sind NACHBAUTEN, keine Nutzer des Live-Codes **Ursachen (strukturell):** (a) Backtests sind NACHBAUTEN, keine Nutzer des Live-Codes
+61
View File
@@ -355,6 +355,11 @@ class TradingEngine:
"signal_pending_entry", "true").lower() == "true" "signal_pending_entry", "true").lower() == "true"
self._pending_levels = None self._pending_levels = None
self._pending_quelle = "" 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_pending_err = None
self._squeeze_chase_logged = None self._squeeze_chase_logged = None
self._signal_chase_logged = None self._signal_chase_logged = None
@@ -707,6 +712,7 @@ class TradingEngine:
# ZUERST die ruhenden Stop-Orders pflegen (setzen/stornieren), # ZUERST die ruhenden Stop-Orders pflegen (setzen/stornieren),
# DANN der Market-Fallback: so kann eine bereits liegende Order # DANN der Market-Fallback: so kann eine bereits liegende Order
# den Ausbruch am Level abfangen, bevor nachgejagt würde. # den Ausbruch am Level abfangen, bevor nachgejagt würde.
self._check_pending_fill() # gefuellte Pending-Order zuordnen
self._manage_squeeze_pending() self._manage_squeeze_pending()
self._check_auto_squeeze() # autonomer Entry auf Squeeze-Ausbruch self._check_auto_squeeze() # autonomer Entry auf Squeeze-Ausbruch
self._check_auto_signal() # autonomer Entry auf die Empfehlung self._check_auto_signal() # autonomer Entry auf die Empfehlung
@@ -1055,6 +1061,58 @@ class TradingEngine:
"""Alias — `_check_auto_squeeze` ruft weiterhin diesen Namen.""" """Alias — `_check_auto_squeeze` ruft weiterhin diesen Namen."""
return self._auto_guard("squeeze") 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"🎯 <b>Pending-Fill ({quelle})</b>\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]: def _pending_ziel(self) -> tuple[dict, str]:
"""Welche Stop-Orders SOLLEN liegen? → ({order_type: preis}, Quelle). """Welche Stop-Orders SOLLEN liegen? → ({order_type: preis}, Quelle).
@@ -1125,6 +1183,7 @@ class TradingEngine:
for o in offen: for o in offen:
err = self.trader.cancel_pending(o.ticket) err = self.trader.cancel_pending(o.ticket)
if not err: if not err:
self._pending_tickets.pop(int(o.ticket), None)
log.info(f"⏸ Pending-Entry storniert (T={o.ticket}) — " log.info(f"⏸ Pending-Entry storniert (T={o.ticket}) — "
+ ("Position offen" if ps.get("ticket") + ("Position offen" if ps.get("ticket")
else "Setup nicht mehr gültig")) else "Setup nicht mehr gültig"))
@@ -1145,6 +1204,7 @@ class TradingEngine:
vorhanden[o.type] = o.ticket vorhanden[o.type] = o.ticket
else: else:
self.trader.cancel_pending(o.ticket) self.trader.cancel_pending(o.ticket)
self._pending_tickets.pop(int(o.ticket), None)
for otype, preis in ziel.items(): for otype, preis in ziel.items():
if otype in vorhanden: if otype in vorhanden:
continue continue
@@ -1154,6 +1214,7 @@ class TradingEngine:
# gleiche Regel wie beim Market-Pfad (s. `_open`) # gleiche Regel wie beim Market-Pfad (s. `_open`)
atr_tf=(mt5.TIMEFRAME_M5 if quelle == "squeeze" else None)) atr_tf=(mt5.TIMEFRAME_M5 if quelle == "squeeze" else None))
if t: if t:
self._pending_tickets[int(t)] = quelle
log.info(f"🎯 Pending-Entry ({quelle}) gesetzt: " log.info(f"🎯 Pending-Entry ({quelle}) gesetzt: "
f"{'BUY_STOP' if otype == mt5.ORDER_TYPE_BUY_STOP else 'SELL_STOP'}" f"{'BUY_STOP' if otype == mt5.ORDER_TYPE_BUY_STOP else 'SELL_STOP'}"
f" @ {preis:.3f} (T={t})") f" @ {preis:.3f} (T={t})")
+26
View File
@@ -576,6 +576,32 @@ class HistoryLogger:
return (int(since.timestamp()), now) return (int(since.timestamp()), now)
return (0, 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: def stats_overview(self, period: str = "all") -> dict:
"""Liefert ein Dict mit den Hauptkennzahlen für den gewählten Zeitraum.""" """Liefert ein Dict mit den Hauptkennzahlen für den gewählten Zeitraum."""
since, until = self._range_to_ts(period) since, until = self._range_to_ts(period)