From 90d77a2305cae900066dfce8516cd918e1e2c78c Mon Sep 17 00:00:00 2001 From: Axel Hocks Date: Wed, 5 Aug 2026 11:43:04 +0200 Subject: [PATCH] Pending-Pfad fuer SIG: ruhende Stop-Order am Bestaetigungs-Level SIG laeuft damit auf der gemessen positiven Variante (OeR +0,35/+0,32, PF 2,2) statt auf der Market-Order (-0,09/-0,11). Der heikelste Punkt war nicht die Order, sondern die Kollision beider Pfade: trader.pending_orders() liefert ALLE magic-gleichen Orders, zwei getrennte Manager haetten sich gegenseitig storniert - sporadisch und im Log kaum erkennbar. Deshalb EIN gemeinsamer Ziel-Zustand (engine._pending_ziel), der Manager gleicht nur noch ab. Vorrang Squeeze > Signal (unabhaengig validiert, selten; das Signal feuert dauernd, es gibt einen Positions-Slot). Zwei additive Ergaenzungen in wave_rec._confirm_breakout: das Bestaetigungs-Level wird auch NACH der Bestaetigung veroeffentlicht (die Nachjagd-Bremse braucht es genau dann; pending bleibt False, Frontend unberuehrt), und conf = die Konfidenz VOR dem Stummschalten - sonst muesste die ruhende Order blind platziert werden. Nachjagd-Bremse jetzt auch fuer SIG (0,20 xATR, Haertetest-gedeckt). 12 Szenarien getestet, live am Snapshot verifiziert. Snapshot-Felder squeeze_pending_levels -> pending_levels + pending_quelle. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 39 ++++++++++-- core/engine.py | 155 ++++++++++++++++++++++++++++++++++++----------- core/wave_rec.py | 16 ++++- 3 files changed, 168 insertions(+), 42 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4c9ef40..9270c5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1401,10 +1401,41 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: also im gemessen negativen Fall. Wie oft das passiert, ist ungemessen. (4) `_pend` wird aktuell **nicht im Snapshot veröffentlicht** — das wäre die erste Bauarbeit. - ⚠⚠ **KONSEQUENZ FÜR DEN LAUFENDEN BETRIEB:** SIG steht seit dem 05.08. 08:13 auf - AN (User-Toggle) und läuft dabei über die **Market-Order — also Variante A, die in - beiden Halbjahren negativ ist.** Solange der Pending-Pfad nicht gebaut ist, ist der - Schalter die gemessen schlechte Variante. + ✅✅ **PENDING-PFAD FÜR SIG GEBAUT & LIVE (2026-08-05, User-Vorgabe), + `[trading] signal_pending_entry=true`.** SIG läuft damit auf Variante B statt A. + ⚠⚠ **Der heikelste Punkt war NICHT die Order, sondern die Kollision der beiden + Pfade.** `trader.pending_orders()` liefert ALLE magic-gleichen Orders — zwei + getrennte Manager (Squeeze/Signal) hätten sich **gegenseitig die Orders + storniert**, sporadisch und im Log kaum erkennbar. Deshalb **EIN gemeinsamer + Ziel-Zustand**: `engine._pending_ziel()` liefert `{order_type: preis}` + Quelle, + `_manage_squeeze_pending` gleicht nur noch ab. **VORRANG: Squeeze > Signal** — der + Squeeze ist unabhängig validiert und selten (~2–3×/Woche), das Signal feuert + dauernd; bei EINEM Positions-Slot ist die Reihenfolge damit eindeutig. + ⚠ **Zwei additive Ergänzungen in `wave_rec._confirm_breakout` waren nötig:** + (1) Das Bestätigungs-Level wird jetzt **auch nach der Bestätigung** veröffentlicht + (`snap["breakout"]["level"]`, `confirmed=True`) — die Nachjagd-Bremse im + Market-Fallback braucht es genau dann. `pending` bleibt False, das Frontend rendert + nur bei `pending` und ist unberührt. (2) **`conf`** = die Konfidenz VOR dem + Stummschalten: der Rückgabewert setzt `conf_pct` auf 0 (das Signal gilt ja noch + nicht), die ruhende Order müsste sonst **blind** platziert werden. Damit wird die + Mindest-Konfidenz jeden Tick geprüft und die Order storniert, sobald sie fällt — + strenger als der Live-Check von vorher (der prüfte nur im Moment des Entries). + ⚠ **Nachjagd-Bremse auch für SIG** (`squeeze_max_chase_atr`, 0,20): greift die + Pending nicht, wird nur noch bis 0,20×ATR über dem Bestätigungs-Level nachgekauft — + der Härtetest zeigt dort +0,148/+0,090, ab ~0,25–0,30 kippt es. + ⚠ **Der wandernde Anker ist die operative Hauptlast:** `_pend` verankert sich bei + jeder k×ATR-Gegenbewegung, nach Timeout und bei Richtungswechsel neu — die Order + wird dann nachgezogen (Toleranz 0,02×ATR gegen Order-Churn). **Mit 12 Szenarien + getestet** (Vorrang · reiner Signal-Fall · SHORT · Konfidenz zu niedrig · Wechsel + Signal→Squeeze mit Storno · wanderndes Level · Toleranz · Gate · Fill · Feature aus + · Bestätigung eingetreten · Schonfrist). Live verifiziert am Snapshot: + `signal_pending=true`, `breakout` liefert `level`/`conf`/`confirmed`; + `pending_levels` leer, weil eine Position offen war = korrektes Verhalten. + Snapshot-Felder umbenannt: `squeeze_pending_levels` → **`pending_levels`** + + **`pending_quelle`**. Zurück: `signal_pending_entry=false`. + ⚠ **B4-Pflicht:** dieselbe Erfolgskontrolle wie beim Squeeze — der Abstand + Einstieg↔Level muss gegen 0 gehen. AUTOSIG-Trades sind über `setup=AUTOSIG_*` + separierbar. ⚠ **Methodik-Fehler beim Bau, gefunden und behoben:** der erste Entwurf liess `pend["level"]` nach der Bestätigung über weitere Bars stehen — bei verzögertem Einstieg (Slot belegt, Konfidenz zu niedrig) wäre er zu einem Preis eingestiegen, diff --git a/core/engine.py b/core/engine.py index 201306b..a84cd76 100644 --- a/core/engine.py +++ b/core/engine.py @@ -348,9 +348,16 @@ class TradingEngine: "squeeze_max_chase_atr", "0.20")) except (TypeError, ValueError): self._squeeze_max_chase = 0.20 - self._squeeze_pending_levels = None + # Signal-Pfad bekommt dieselbe Mechanik (2026-08-05, gemessen + # `backtest_auto_signal_v3.py`: am Bestätigungs-Level ØR +0,35/+0,32 statt + # −0,09/−0,11 am Markt — SIG besteht damit ERSTMALS die 2-Stichproben-Regel). + self._signal_pending = self.cfg["trading"].get( + "signal_pending_entry", "true").lower() == "true" + self._pending_levels = None + self._pending_quelle = "" self._squeeze_pending_err = None self._squeeze_chase_logged = None + self._signal_chase_logged = None self._squeeze_reverse_sig = None # Dedup Reverse-Close (Gegen-Trade) self._squeeze_entry_count = 0 # ── Autonomer Entry auf die EMPFEHLUNG (User-Wunsch 2026-07-30) ─────────── @@ -1073,21 +1080,26 @@ class TradingEngine: tg["bot_token"], tg["chat_id"]) except Exception as e: log.warning(f"S/R-Close-Telegram: {e}") - def _squeeze_guard(self) -> str | None: - """Die NICHT richtungsabhängigen Gates für autonome Squeeze-Entries. - → Grundtext, wenn geblockt, sonst None. + def _auto_guard(self, pfad: str) -> str | None: + """Die NICHT richtungsabhängigen Gates für autonome Entries. + `pfad` = "squeeze" | "signal". → Grundtext, wenn geblockt, sonst None. - ⚠ Bewusst EINE Funktion für beide Einstiegswege (ruhende Stop-Order UND - Market-Fallback). Zwei Kopien derselben Gates wären genau die Divergenz, die - dieses Projekt siebenmal als Deployment-Drift dokumentiert hat — und bei einer - RUHENDEN Order wiegt sie schwerer: eine vergessene Pending-Order feuert im - gesperrten Fenster, ohne dass jemand hinschaut.""" - if not self._auto_squeeze: - return "Auto-Squeeze aus" + ⚠ Bewusst EINE Funktion für ALLE Einstiegswege (ruhende Stop-Order UND + Market-Fallback, Squeeze UND Signal). Kopien derselben Gates wären genau die + Divergenz, die dieses Projekt siebenmal als Deployment-Drift dokumentiert hat + — und bei einer RUHENDEN Order wiegt sie schwerer: eine vergessene + Pending-Order feuert im gesperrten Fenster, ohne dass jemand hinschaut.""" + if pfad == "squeeze": + if not self._auto_squeeze: + return "Auto-Squeeze aus" + skip = self._squeeze_skip_night + else: + if not self._auto_signal: + return "Auto-Signal aus" + skip = self._auto_signal_skip_night if self._circuit_halted(): return "Circuit-Breaker" - if (self._squeeze_skip_night - and datetime.now(_BERLIN).hour in self._squeeze_night_hours): + if skip and datetime.now(_BERLIN).hour in self._squeeze_night_hours: return "Nacht-Fenster" _bl = self.calendar.blackout() if _bl: @@ -1099,6 +1111,48 @@ class TradingEngine: self._news_block_logged = None return None + def _squeeze_guard(self) -> str | None: + """Alias — `_check_auto_squeeze` ruft weiterhin diesen Namen.""" + return self._auto_guard("squeeze") + + def _pending_ziel(self) -> tuple[dict, str]: + """Welche Stop-Orders SOLLEN liegen? → ({order_type: preis}, Quelle). + + ⚠⚠ EIN gemeinsamer Ziel-Zustand für Squeeze UND Signal — nicht zwei Manager. + Zwei getrennte Manager würden über `trader.pending_orders()` jeweils die + Orders des anderen sehen und **gegenseitig stornieren**; der Bug wäre + sporadisch und im Log kaum zu erkennen. + + VORRANG: Squeeze schlägt Signal. Der Squeeze ist unabhängig validiert und + seltener (Kompression ~2–3×/Woche), das Signal feuert dauernd — bei einem + Positions-Slot ist die Reihenfolge damit eindeutig. Ist der Squeeze `armed`, + legt das Signal nichts. + """ + ws = self.wave.snapshot() or {} + atr = (ws.get("pb_feats") or {}).get("atr") or 0.0 + if atr <= 0: + return {}, "" + # ── 1) Squeeze: beide Box-Grenzen ──────────────────────────────── + sq = ws.get("squeeze") or {} + hi, lo = sq.get("box_hi"), sq.get("box_lo") + if (self._squeeze_pending and sq.get("state") == "armed" and hi and lo + and self._auto_guard("squeeze") is None): + return ({mt5.ORDER_TYPE_BUY_STOP: round(float(hi) + _SQ_K_ENG * atr, 3), + mt5.ORDER_TYPE_SELL_STOP: round(float(lo) - _SQ_K_ENG * atr, 3)}, + "squeeze") + # ── 2) Signal: EINE Order am Bestätigungs-Level ────────────────── + # Gemessen `backtest_auto_signal_v3.py`: am Level ØR +0,35/+0,32 (PF 2,2) + # gegen −0,09/−0,11 am Markt — dieselbe Mechanik, die den Squeeze rettet. + bo = ws.get("breakout") or {} + if (self._signal_pending and bo.get("pending") and bo.get("level") + and bo.get("dir") in ("LONG", "SHORT") + and (bo.get("conf") or 0) >= self._auto_signal_min_conf + and self._auto_guard("signal") is None): + ot = (mt5.ORDER_TYPE_BUY_STOP if bo["dir"] == "LONG" + else mt5.ORDER_TYPE_SELL_STOP) + return {ot: round(float(bo["level"]), 3)}, "signal" + return {}, "" + def _manage_squeeze_pending(self): """RUHENDE Stop-Orders an beiden Box-Grenzen, solange der Squeeze `armed` ist. @@ -1119,33 +1173,30 @@ class TradingEngine: return try: ps = self.trader.snapshot() - sq = (self.wave.snapshot() or {}).get("squeeze") or {} atr = ((self.wave.snapshot() or {}).get("pb_feats") or {}).get("atr") or 0.0 - hi, lo = sq.get("box_hi"), sq.get("box_lo") - grund = self._squeeze_guard() - # Orders sollen NUR liegen, wenn: flat · kein Gate · Kompression aktiv · - # Box bekannt · Schonfrist vorbei. Jede dieser Bedingungen kann sich - # jederzeit ändern — deshalb wird das JEDEN Tick neu bewertet und nicht + ziel, quelle = self._pending_ziel() + # Orders sollen NUR liegen, wenn zusätzlich: flat · Schonfrist vorbei. + # Beides kann sich jederzeit ändern → JEDEN Tick neu bewertet, nicht # beim Platzieren einmal geprüft. - soll = (not ps.get("ticket") and grund is None - and sq.get("state") == "armed" - and hi and lo and atr > 0 - and not self._in_startup_grace()) + if ps.get("ticket") or self._in_startup_grace() or atr <= 0: + ziel, quelle = {}, "" offen = self.trader.pending_orders(sym) - if not soll: + if not ziel: for o in offen: err = self.trader.cancel_pending(o.ticket) if not err: - log.info(f"⏸ Squeeze-Pending storniert (T={o.ticket}) — " - f"{grund or ('Position offen' if ps.get('ticket') else 'Squeeze geklärt')}") + log.info(f"⏸ Pending-Entry storniert (T={o.ticket}) — " + + ("Position offen" if ps.get("ticket") + else "Setup nicht mehr gültig")) if offen: - self._squeeze_pending_levels = None + self._pending_levels = None + self._pending_quelle = "" return - ziel = {mt5.ORDER_TYPE_BUY_STOP: round(float(hi) + _SQ_K_ENG * atr, 3), - mt5.ORDER_TYPE_SELL_STOP: round(float(lo) - _SQ_K_ENG * atr, 3)} # Bestehende Orders behalten, wenn ihr Trigger noch passt (Toleranz # 0,02×ATR) — sonst löschen und neu setzen. Ohne die Toleranz würde # jede ATR-Zuckung die Orders löschen/neu setzen (Broker-Last, Log-Flut). + # ⚠ Beim Signal-Pfad ist das wichtiger als beim Squeeze: `_pend` + # verankert sich bei jeder k×ATR-Gegenbewegung neu, das Level wandert. tol = max(0.02 * atr, 0.001) vorhanden = {} for o in offen: @@ -1157,17 +1208,20 @@ class TradingEngine: for otype, preis in ziel.items(): if otype in vorhanden: continue - t, err = self.trader.place_stop(sym, otype, preis, comment="SQZ-STOP") + tag = "SQZ-STOP" if quelle == "squeeze" else "SIG-STOP" + t, err = self.trader.place_stop(sym, otype, preis, comment=tag) if t: - log.info(f"🎯 Squeeze-Pending gesetzt: " + 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})") elif err and self._squeeze_pending_err != err: # ⚠ Nur bei ÄNDERUNG loggen: „Kurs hat das Level bereits passiert" - # tritt im 1-s-Takt auf, sobald der Kurs an einer Box-Grenze klebt. + # tritt im 1-s-Takt auf, sobald der Kurs am Level klebt. self._squeeze_pending_err = err - log.info(f"Squeeze-Pending nicht platzierbar ({preis:.3f}): {err}") - self._squeeze_pending_levels = ziel + log.info(f"Pending-Entry ({quelle}) nicht platzierbar " + f"({preis:.3f}): {err}") + self._pending_levels = ziel + self._pending_quelle = quelle except Exception as e: log.error(f"_manage_squeeze_pending: {e}", exc_info=True) @@ -1354,6 +1408,31 @@ class TradingEngine: return if (d == self._pos_close_dir and now - self._pos_close_ts < _SQUEEZE_REENTRY_COOLDOWN_S): return + # ── NACHJAGD-BREMSE (2026-08-05, gemessen `backtest_auto_signal_v3.py`). + # Der Market-Weg ist nur noch FALLBACK, wenn die ruhende Stop-Order am + # Bestätigungs-Level nicht gegriffen hat. Am Level ØR +0,35/+0,32, am + # Bar-Close −0,09/−0,11 — und der Härtetest zeigt den Kipp-Punkt bei + # ~0,25–0,30×ATR Abstand (bei +0,20 noch +0,148/+0,090). Also nur bis + # `squeeze_max_chase_atr` nachkaufen, sonst diese Episode auslassen. + # ⚠ `_signal_last_sig` wird NICHT gesetzt → kommt der Kurs ans Level + # zurück, darf der Entry noch kommen. + ws = self.wave.snapshot() or {} + bo = ws.get("breakout") or {} + atr_m5 = (ws.get("pb_feats") or {}).get("atr") or 0.0 + lvl = bo.get("level") if bo.get("dir") == d else None + tick = self.data.snapshot() or {} + px = tick.get("ask") if d == "LONG" else tick.get("bid") + if lvl and atr_m5 > 0 and px: + weg = (float(px) - float(lvl)) * (1 if d == "LONG" else -1) / atr_m5 + if weg > self._squeeze_max_chase: + if self._signal_chase_logged != d: + self._signal_chase_logged = d + log.info(f"⏭ Auto-Signal übersprungen: Kurs steht schon " + f"{weg:.2f}×ATR über dem Bestätigungs-Level {lvl} " + f"(Grenze {self._squeeze_max_chase:.2f}) — " + f"gemessen ab hier kein Edge mehr") + return + self._signal_chase_logged = None self._signal_last_sig = d if isinstance(self._last_rec, dict): self._last_rec["setup"] = f"AUTOSIG_{d}" @@ -3516,9 +3595,11 @@ class TradingEngine: # Einstieg über ruhende Stop-Orders (2026-08-05) + die aktuell gesetzten # Trigger — macht im Snapshot prüfbar, ob wirklich Orders liegen. "squeeze_pending": self._squeeze_pending, - "squeeze_pending_levels": ( - sorted(round(v, 3) for v in self._squeeze_pending_levels.values()) - if self._squeeze_pending_levels else []), + "signal_pending": self._signal_pending, + "pending_levels": ( + sorted(round(v, 3) for v in self._pending_levels.values()) + if self._pending_levels else []), + "pending_quelle": self._pending_quelle, "squeeze_max_chase_atr": self._squeeze_max_chase, "squeeze_entry_count": self._squeeze_entry_count, "auto_signal": self._auto_signal, diff --git a/core/wave_rec.py b/core/wave_rec.py index 74a6f90..5764a6c 100644 --- a/core/wave_rec.py +++ b/core/wave_rec.py @@ -571,20 +571,34 @@ class WaveRecommender: p = {"dir": d, "level": cur + d * k * atr, "invalid": cur - d * k * atr, "confirmed": False, "start": time.time()} self._pend = p + # ⚠ Das Bestätigungs-Level wird AUCH nach der Bestätigung veröffentlicht + # (2026-08-05). Zwei Konsumenten brauchen es: die ruhende Stop-Order (sie + # liegt dort) und die Nachjagd-Bremse im Market-Fallback (sie misst den + # Abstand dorthin). `pending` bleibt False — das Frontend rendert nur bei + # `pending`, ist also unberührt. if p["confirmed"]: + snap["breakout"] = {"pending": False, "dir": sig, "need": None, + "level": round(p["level"], 3), "confirmed": True} return rec # schon bestätigt → durchlassen broke = (cur >= p["level"]) if d > 0 else (cur <= p["level"]) against = (cur <= p["invalid"]) if d > 0 else (cur >= p["invalid"]) if broke: p["confirmed"] = True + snap["breakout"] = {"pending": False, "dir": sig, "need": None, + "level": round(p["level"], 3), "confirmed": True} return rec if against or (time.time() - p["start"]) > self._breakout_timeout_s: p = {"dir": d, "level": cur + d * k * atr, "invalid": cur - d * k * atr, "confirmed": False, "start": time.time()} # neu verankern self._pend = p need = abs(p["level"] - cur) + # ⚠ `conf` = die Konfidenz VOR dem Stummschalten. Der Rückgabewert unten + # setzt `conf_pct` auf 0 (das Signal gilt ja noch nicht) — wer die ruhende + # Stop-Order gegen die Mindest-Konfidenz prüfen will, hätte sonst KEINE + # Zahl und müsste die Order blind platzieren. snap["breakout"] = {"pending": True, "dir": sig, - "level": round(p["level"], 3), "need": round(need, 3)} + "level": round(p["level"], 3), "need": round(need, 3), + "conf": rec.get("conf_pct") or 0, "confirmed": False} return {"signal": "WARTEN", "conf_pct": 0, "score": 0.0, "setup": "WAVE", "regime": None, "rsi": None, "block": "breakout_pending", "reasons": [f"warte auf Breakout (+{k:.1f}×ATR {sig}, noch {need:.2f})"]}