From bd65bdaad08f1f1f602dc2ce4c17db2ba0ce15e0 Mon Sep 17 00:00:00 2001 From: Axel Hocks Date: Wed, 19 Aug 2026 18:01:52 +0200 Subject: [PATCH] M15-Auto-Entry gebaut (Default AUS) -- gegen die Messung, auf User-Wunsch User wollte den Auto-Trade testen, nach vollstaendiger Information ueber die Beleglage. backtest_m15_auto.py (13.08.): die gehandelte Lesart ist in BEIDEN Halbjahren negativ (OeR -0,162/-0,094, PF 0,72/0,84), beide KI schliessen die Null aus, Nachbarn monoton schlechter, und sie ist schlechter als die HTF-Richtung UND als ihr eigenes Gegenteil. Live entkoppelt 46,8 % Treffer. _check_auto_m15 im _pos_loop: Ausloeser gruen + gerichtet, Richtung exakt wie im Backtest (auf->LONG, ab->SHORT), Dedup 1x je Episode ueber (Richtung, Level), nur FLAT, Setup-Tag AUTOM15_*. Erbt die VOLLE Guard-Kette ueber _auto_guard('m15') -- der Parameter war laut Docstring genau dafuer vorgesehen, nachgebaut wurde nichts. Abweichung ausdruecklich benannt: gemessen wurde der Einstieg AM Level mit Fill bei Beruehrung, gebaut ist der Markt-Einstieg. Der ist hier mehrfach als der schlechtere gemessen -- die Live-Zahlen sind eher noch unter -0,16/-0,09 zu erwarten. Abbruchregel VORAB fixiert: nach >=20 AUTOM15_*-Trades Verhaeltnis < 1,0 ODER PF < 1 -> auto_m15 = false. Dieselbe Latte wie B5, damit sie nicht nachtraeglich wandert. Verifiziert: Toggle, Snapshot-Felder, Deploy mit --feld auto_m15, ruff F821 (neuer Code sitzt in einem except -- der dokumentierte blinde Fleck), 85 Tests. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 47 + core/engine.py | 113 +- core/engine.py.bak-2026-08-19-m15auto | 5001 +++++++++++++++++++++++++ server.py | 10 + web/app.js | 9 + web/index.html | 9 +- 6 files changed, 5185 insertions(+), 4 deletions(-) create mode 100644 core/engine.py.bak-2026-08-19-m15auto diff --git a/CLAUDE.md b/CLAUDE.md index a94bde8..777777a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7615,6 +7615,53 @@ vorab fixiert wurde** — also wird sie NICHT gesenkt, sondern als **durch User-Entscheidung überstimmt** vermerkt. Wer sie ersetzen will, fixiert eine NEUE Latte **vor** der Beobachtung, auf einem NEUEN Zählfenster ab heute. +## ⚠⚠ M15-AUTO-ENTRY GEBAUT — GEGEN DIE MESSUNG, auf User-Wunsch (2026-08-19, v=181) + +User: „ich möchte das auto trade testen. Baue den Button.“ — nach vollständiger +Information über die Beleglage. **Default AUS**, Schalter **M15** in der +Aktionsleiste, `[trading] auto_m15 = false`. + +⚠⚠ **DIE MESSUNG SAGT NEIN, und zwar deutlich** (`backtest_m15_auto.py`, 13.08.): +| Variante | H1 ØR / PF | H2 ØR / PF | +|---|---|---| +| **A) die gehandelte Lesart** | **−0,162 / 0,72** | **−0,094 / 0,84** | +| C) Kontrolle HTF-Richtung | −0,085 / 0,85 | −0,030 / 0,95 | +| D) Kontrolle **Gegenrichtung** | −0,070 / 0,87 | **+0,007 / 1,01** | +Beide KI schliessen die Null aus ([−0,222…−0,099] / [−0,152…−0,038]), die +Nachbar-Schwellen werden **monoton schlechter**, und A ist schlechter als die +HTF-Richtung **und als ihr eigenes Gegenteil**. Live-Gegenprobe (`m15_states`, +entkoppelt): **46,8 %** Treffer auf 30 min. Die Karte steht in **46 %** der Zeit +grün UND gerichtet — als Auslöser also alles andere als selektiv. + +**Umsetzung** — `engine._check_auto_m15` im `_pos_loop`: +• Auslöser `status=gruen` + `lesart` in (auf, ab); Richtung **exakt wie im +Backtest**: `auf → LONG`, `ab → SHORT`. • Dedup 1× je Episode über +**(Richtung, Level)** — derselbe Schlüssel `(d_les, nah["level"])`. +• **Nur FLAT.** • Erbt die **volle** Guard-Kette: `_auto_guard("m15")` (Nacht, +Circuit-Breaker, News-Blackout) — der Parameter `pfad` war laut Docstring genau +dafür vorgesehen, **nachgebaut wurde nichts**. • Plus Startup-Schonfrist und +beide Wiedereinstiegs-Sperren. • Setup-Tag **`AUTOM15_*`**. + +⚠⚠ **ABWEICHUNG VON DER GEMESSENEN VARIANTE, ausdrücklich benannt:** gemessen +wurde der Einstieg **AM Level mit Fill bei Berührung**, gebaut ist der +**MARKT**-Einstieg. Der Markt-Einstieg ist hier mehrfach als der schlechtere +gemessen (Squeeze +0,456 → −0,158 **allein durch den Preis**). Die Live-Zahlen +sind also **eher noch unter** −0,16 / −0,09 zu erwarten. Eine ruhende Order +hätte den geteilten `_pending_ziel`-Manager gebraucht (Squeeze hat dort +Vorrang) — eigener Umbau, bewusst nicht Teil dieses Tests. + +✅ **ABBRUCHREGEL, VORAB FIXIERT** (ohne sie wäre es kein Test, sondern nur ein +Schalter): **nach ≥ 20 `AUTOM15_*`-Trades — Verhältnis (ØGewinn/ØVerlust) < 1,0 +ODER PF < 1 → `auto_m15 = false`.** Dieselbe Latte wie B5 beim Squeeze, damit +sie vergleichbar und **nicht nachträglich verschiebbar** ist. +⚠ Erwartung aus der Messung: die Regel **wird** greifen. Das ist kein Argument +gegen den Test — aber es ist der Grund, warum die Latte **jetzt** steht. + +✅ Verifiziert: Toggle an→aus, Snapshot `auto_m15`/`m15_entry_count`, Deploy mit +`--feld auto_m15` (alle 5 Schritte), `ruff F821` sauber (der neue Code sitzt in +einem `except` — der dokumentierte blinde Fleck), 85 Tests, `node --check`. +**Zurück:** Knopf **M15** bzw. `auto_m15 = false`. + ## ⚠ CIRCUIT BREAKER BLEIBT AUS — User-Entscheidung (2026-08-19, abends) Auf den Hinweis, dass BRK **an** und die Notbremse **aus** ist, kam ein diff --git a/core/engine.py b/core/engine.py index bc30c5a..2a3508e 100644 --- a/core/engine.py +++ b/core/engine.py @@ -382,6 +382,19 @@ class TradingEngine: # ⚠ ECHTE Order — Default AUS (Autonomie nie stillschweigend an). `[trading] # auto_squeeze=true` aktiviert. Dedup über (dir, level) des Ausbruchs. self._auto_squeeze = self.cfg["trading"].get("auto_squeeze", "false").lower() == "true" + # ── M15-KARTEN-AUTO-ENTRY (User-Wunsch 2026-08-19, "ich moechte das + # auto trade testen"). ⚠⚠ GEGEN DIE MESSUNG gebaut und deshalb + # Default AUS: `backtest_m15_auto.py` (13.08.) hat genau diese Regel + # gemessen -- die Lesart ist in BEIDEN Halbjahren negativ + # (OeR -0,162/-0,094, PF 0,72/0,84), das 95-%-KI schliesst beide Male + # die Null aus, und sie ist schlechter als die HTF-Richtung UND als + # ihr eigenes Gegenteil. Live bestaetigt (m15_states, entkoppelt): + # 46,8 % Treffer auf 30 min. Der User hat das nach vollstaendiger + # Information ausdruecklich zum TESTEN gewuenscht. + self._auto_m15 = self.cfg["trading"].get("auto_m15", "false").lower() == "true" + self._m15_last_entry_sig = None # Dedup (Richtung, Level) wie im Backtest + self._m15_entry_count = 0 + self._last_m15 = None # letzter berechneter Kartenstand self._squeeze_skip_night = self.cfg["trading"].get("auto_squeeze_skip_night", "true").lower() == "true" # ── WELCHE Stunden ausgesetzt werden (2026-08-05, gemessen `backtest_cost_gate.py`). # War fest 0–7. Die Messung zeigt: KEINE Stunde ist in beiden Halbjahren negativ — @@ -825,6 +838,7 @@ class TradingEngine: self._check_pending_fill() # gefuellte Pending-Order zuordnen self._manage_squeeze_pending() self._check_auto_squeeze() # autonomer Entry auf Squeeze-Ausbruch + self._check_auto_m15() # autonomer Entry auf die gruene M15-Karte except Exception as e: log.error(f"_pos_loop: {e}", exc_info=True) self._sleep(_POS_S) @@ -1158,8 +1172,13 @@ class TradingEngine: # den Squeeze. Der Parameter `pfad` bleibt in der Signatur — die # Guard-Kette ist bewusst EINE Quelle für alle autonomen Wege, und ein # künftiger zweiter Weg soll sie wieder benutzen, nicht nachbauen. - if not self._auto_squeeze: - return "Auto-Squeeze aus" + # ⚠ Pfad-abhaengig seit 2026-08-19: der M15-Auto-Entry nutzt DIESELBE + # Guard-Kette (Nacht, Circuit, News) statt sie nachzubauen -- genau das, + # wofuer der Parameter laut Docstring gedacht ist. + _an = {"squeeze": self._auto_squeeze, + "m15": getattr(self, "_auto_m15", False)}.get(pfad, self._auto_squeeze) + if not _an: + return ("Auto-M15 aus" if pfad == "m15" else "Auto-Squeeze aus") skip = self._squeeze_skip_night if self._circuit_halted(): return "Circuit-Breaker" @@ -1179,6 +1198,90 @@ class TradingEngine: """Alias — `_check_auto_squeeze` ruft weiterhin diesen Namen.""" return self._auto_guard("squeeze") + def set_auto_m15(self, on: bool) -> bool: + """M15-Karten-Auto-Entry an/aus. Rueckgabe = neuer Zustand.""" + self._auto_m15 = bool(on) + self._m15_last_entry_sig = None + log.info(f"Auto-M15-Entry {'AN' if self._auto_m15 else 'AUS'}") + self._save_runtime_state() + return self._auto_m15 + + def _check_auto_m15(self): + """Autonomer Entry auf die GRUENE, gerichtete M15-Karte (Default AUS). + + ⚠⚠ GEGEN DIE MESSUNG. `backtest_m15_auto.py` (13.08.): die Lesart ist in + BEIDEN Halbjahren negativ (OeR -0,162 / -0,094, PF 0,72 / 0,84), das + 95-%-KI schliesst beide Male die Null aus, alle Nachbar-Schwellen werden + monoton schlechter, und sie ist schlechter als die HTF-Richtung UND als + ihr eigenes Gegenteil. Auf User-Wunsch zum TESTEN gebaut. + + ⚠ ABWEICHUNG VON DER GEMESSENEN VARIANTE, ausdruecklich benannt: gemessen + wurde der Einstieg AM Level mit Fill bei Beruehrung; hier wird zum MARKT + eingestiegen. Im Projekt ist der Markt-Einstieg mehrfach als der + schlechtere gemessen (Squeeze +0,456 -> -0,158 allein durch den Preis). + Die Live-Zahlen sind also eher noch unter den -0,16/-0,09 zu erwarten. + Eine ruhende Order haette den geteilten `_pending_ziel`-Manager + gebraucht, in dem der Squeeze Vorrang hat -- das ist ein eigener Umbau. + + Dedup 1x je Episode ueber (Richtung, Level) -- exakt der Backtest- + Schluessel `(d_les, nah["level"])`. Erbt die volle Guard-Kette + (`_auto_guard("m15")`: Nacht, Circuit-Breaker, News-Blackout) sowie + Startup-Schonfrist, Flat-Bedingung und die Wiedereinstiegs-Sperren. + """ + if not self._auto_m15: + return + try: + if self._in_startup_grace(): + return + s = getattr(self, "_last_m15", None) + if not s or s.get("status") != "gruen": + self._m15_last_entry_sig = None # re-armt bei Nicht-Gruen + return + les = s.get("lesart") + if les not in ("auf", "ab"): + self._m15_last_entry_sig = None + return + nah = s.get("nah") or {} + lvl = nah.get("level") + if lvl is None: + return + d = 1 if les == "auf" else -1 # wie im Backtest + sig = (d, round(float(lvl), 3)) + if sig == self._m15_last_entry_sig: + return # dieselbe Episode + if (self.trader.snapshot() or {}).get("lots"): + return # nur FLAT + grund = self._auto_guard("m15") + if grund: + return + # Wiedereinstiegs-Sperren teilen wir mit dem Squeeze (gleiche Logik, + # gleiche Begruendung: Open-Close-Kaskaden nach einem Auto-Close). + now = time.time() + if (self._sr_close_dir == d + and now - self._sr_close_ts < _SQUEEZE_SR_COOLDOWN_S): + return + if (self._pos_close_dir == d + and now - self._pos_close_ts < _SQUEEZE_REENTRY_COOLDOWN_S): + return + self._m15_last_entry_sig = sig + want = "long" if d > 0 else "short" + # ⚠ Setup-TAG ist Pflicht, nicht Kosmetik: ohne ihn sind die Trades + # in der DB von manuellen NICHT unterscheidbar und der Test spaeter + # nicht auswertbar (Deployment-Drift Fall 8, 06.08.: vier reale + # Squeeze-Trades landeten als setup=NULL, damit zaehlten B4-Monitor + # und Abbruchregel NULL Bot-Trades). + self._last_rec["setup"] = f"AUTOM15_{want.upper()}" + fn = self.trader.open_long if d > 0 else self.trader.open_short + err = self._open(fn, want, source="auto_m15") + if err: + log.warning(f"Auto-M15-Entry fehlgeschlagen: {err}") + return + self._m15_entry_count += 1 + log.info(f"AUTO-M15-ENTRY: {want.upper()} auf gruene Karte " + f"(Lesart {les}, Level {lvl}) — selbstaendig eroeffnet") + except Exception as e: + log.warning(f"_check_auto_m15: {e}") + def _check_pending_fill(self): """Hat eine von UNS platzierte Pending-Order gefuellt? Dann nachtragen. @@ -2161,6 +2264,8 @@ class TradingEngine: if "manual_margin" in st: from core.config import set_manual_margin as _set self._manual_margin = _set(st["manual_margin"] or 0) + if "auto_m15" in st: + self._auto_m15 = bool(st["auto_m15"]) if "cb_remember" in st: self._cb_remember = float(st["cb_remember"] or 8.0) if "cb_limit_pct" in st: @@ -2200,6 +2305,7 @@ class TradingEngine: # 0 = aus. So ueberlebt auch die Hoehe den Neustart. "cb_limit_pct": getattr(self, "_cb_limit_pct", 0.0), "cb_remember": getattr(self, "_cb_remember", 8.0), + "auto_m15": getattr(self, "_auto_m15", False), # Kegel-ACI-Skalen (2026-08-08): ohne Persistenz waere # die muehsam erlernte Kalibrierung nach jedem Neustart # weg und liefe von 1,0 neu an. ⚠ JSON-Schluessel sind @@ -4181,6 +4287,7 @@ class TradingEngine: # die Fehlerklasse, fuer die der Linter am 07.08. eingebaut wurde. _m15 = self._m15_setup(market, wave_snap, _cone, self._alignment_cached(), wave_signal) + self._last_m15 = _m15 # fuer `_check_auto_m15` im 1-s-Loop self._log_m15_state(_m15, market) self._check_m15_alert(_m15) # Telegram bei Flanke auf GRUEN self._log_cone_check(_cone) @@ -4352,6 +4459,8 @@ class TradingEngine: # ⚠ Eigenes Fenster (ab 01.08.) — NICHT dasselbe wie # `squeeze_monitor.live` (alle Trades). s. `_squeeze_b5_cached`. "squeeze_b5": self._squeeze_b5_cached(), + "auto_m15": self._auto_m15, + "m15_entry_count": self._m15_entry_count, # Kennzahlen der MANUELLEN Trades für die gleichnamige Karte # (2026-08-04). Rein deskriptiv; ~120 s gecacht, damit der heiße # Snapshot-Pfad nicht je Abruf die DB aggregiert. diff --git a/core/engine.py.bak-2026-08-19-m15auto b/core/engine.py.bak-2026-08-19-m15auto new file mode 100644 index 0000000..bc30c5a --- /dev/null +++ b/core/engine.py.bak-2026-08-19-m15auto @@ -0,0 +1,5001 @@ +""" +core/engine.py — Headless Trading-Engine (ohne UI) +==================================================== +Hält die core/-Objekte, fährt die Refresh-Loops als Threads und liefert einen +aggregierten Snapshot. Einziges Frontend ist die Web-/Mobile-Oberfläche +(`server.py` + `web/`). + MT5Data · TradeManager · TrailingManager · WaveRecommender · + ElliottAnalyzer · TradersUnionProvider · TradingAgent · + DailyLevels · GapAnalyzer + +Wichtig: MT5 ist Windows-gebunden → die Engine läuft auf demselben Rechner wie +das MT5-Terminal. Es sollte immer nur EINE Engine-Instanz gegen das Terminal +laufen. +""" +from __future__ import annotations +import threading +import time +from datetime import datetime +from zoneinfo import ZoneInfo + +import MetaTrader5 as mt5 + +from core.config import ( + HISTORY_DB_FILE, MAGIC, load_config, set_margin_buffer, set_risk_per_trade, + get_margin_buffer as _cfg_margin_buffer, +) +# ⚠ `parse_zones` wurde am 2026-07-31 hier entfernt (letzter Nutzer war `_sr_levels`, +# s. dort): die `[zones]`-Config stammte aus einem ~10 $ tieferen Kursregime und +# lieferte der Wellen-Konfidenz Phantom-Level. Die Funktion bleibt in `core/config.py` +# und die `[zones]`-Sektion in der ini erhalten — reaktivierbar, falls je gewollt. +from core.logger import get_logger +from core.history import HistoryLogger +from core.mt5data import MT5Data +from core.trader import TradeManager +from core.trailing import TrailingManager +from core.tu_rating import TradersUnionProvider +from core.wave_rec import WaveRecommender +from core.elliott import ElliottAnalyzer +from core.agent import TradingAgent +from core.news import NewsFetcher, NewsTranslator +from core.notify import send_telegram as _send_telegram_roh +from core.mailer import send_graph_mail +from core.mt5_utils import mt5_lock +from core.market_hours import session_state +from core.daily_levels import DailyLevels +from core.gaps import GapAnalyzer +from core.structure import MarketStructure +from core.patterns import PatternDetector +from core.candles import CandleAnatomy +from core.cone import build as _cone_build +from core.candle_logger import CandleLogger +from core.wave_rec import (_atr as _wr_atr, _ema_last, + _EMA_FAST, _EMA_SLOW, _TREND_DEADBAND, _STRETCH_MAX, + # Ausbruchs-Aufschlag der Box — DIESELBE Konstante, die + # `_squeeze_one` nutzt. Nachbauen wäre die Sorte Kopie, + # die im Projekt schon 4 divergierende Exit-Modelle + # erzeugt hat. + _SQ_K as _SQ_K_ENG) + +log = get_logger("engine") + +_ATR_TF_MAP = {"M1": mt5.TIMEFRAME_M1, "M5": mt5.TIMEFRAME_M5, + "M15": mt5.TIMEFRAME_M15, "M30": mt5.TIMEFRAME_M30, + "H1": mt5.TIMEFRAME_H1} + +# Loop-Intervalle (Sekunden) — entsprechen den *_REFRESH_MS-Werten des Widgets +# Nach dieser Zeit darf DASSELBE Level erneut melden — sonst verschweigt der +# Alert ein Setup, das den ganzen Tag wiederkommt (s. `_check_m15_alert`). +_ALERT_NEU_S = 4 * 3600 +_PRICE_S = 0.5 +_TREND_S = 5.0 +_POS_S = 1.0 +_TU_S = 300.0 # TU ist nur noch Snapshot-Anzeige (aus Empfehlung/Verdict/ + # Copilot entfernt) — 5 min statt 60 s reicht, spart Scrape+Log +_AGENT_WARMUP = 20.0 # erste Beurteilung erst, wenn Daten geladen sind +_CLOSE_ALERT_COOLDOWN_S = 300.0 # CLOSE-Telegram max. 1×/5 min (kein Spam, wenn + # der Kurs ums Signal-Level pendelt) +_MIN_CONF_DISPLAY = 70 # Konfidenz-Schwelle für die "Order ohne Signal"- + # Warnung im Frontend (nur Anzeige-Konstante — war + # bis 2026-07-23 auf dem entfernten Dry-Run-Auto- + # Trader beheimatet, hier als eigener Wert erhalten) +_BERLIN = ZoneInfo("Europe/Berlin") +_OB_IMB_MIN = 0.30 # Orderbuch-Imbalance darunter = zu schwach fuer eine Stimme +_PAT_W = 0.25 # Verdict-Gewicht der Chartmuster-Stimme (bestaetigt; `forming` + # zaehlt halb). User-Vorgabe 2026-08-01 „5 %" — empirisch aus den + # letzten 4.000 `verdict_votes`: Median der uebrigen Gewichte 4,50 + # → 0,25/(4,50+0,25) = 5,3 % Anteil an der Bias-Nadel (vorher 1,0 + # = 18,2 %). Begruendung + Beleglage bei der Stimme in `_verdict`. +_SQUEEZE_NIGHT = (0, 1, 2, 3, 4, 5, 6, 7) # STATISTISCHE Nacht-Definition +# ⚠ `_SQUEEZE_NIGHT` ist NICHT mehr das Entry-Gate — es bleibt die Definition von +# „Nacht" für die Entry-Checkliste (dort gilt die Kostenfalle: über ALLE Nacht-Bars +# 0,333×ATR) und für den Tag/Nacht-Split im B4-Monitor (damit der Vergleich mit den +# Altdaten stabil bleibt). Welche Stunden der Auto-Squeeze wirklich AUSSETZT, steht +# in `[trading] auto_squeeze_night_hours` (s. `_squeeze_night_hours`). +_SQUEEZE_NIGHT_DEFAULT = "3,4,5,6" # gemessen günstigstes ≥4-h-Fenster, s. u. +_SQUEEZE_SR_COOLDOWN_S = 180.0 # nach S/R-Close kein Auto-Squeeze in GLEICHER Richtung + # (sonst Close-„Level hält" vs. Entry-„Level bricht"- + # Roundtrip in 1 s; 2026-07-17) +_SQUEEZE_REENTRY_COOLDOWN_S = 180.0 # nach JEDEM Close eines Trades (Notfall/SL/Time-Stop/ + # manuell) kein Gleichrichtungs-Squeeze-Wiedereinstieg — + # gegen die Open→Instant-Close-Kaskade bei zu engem Stop + # (real 2026-07-27 15:39: −3-€-Stop killte Entry #1 in 16 s, + # Squeeze noch aktiv → sofortiger Re-Entry; 2026-07-28) +_TICK_STALE_S = 180 # kein Tick seit X s → Markt gilt als geschlossen. + # Absichtlich tick-basiert statt kalenderbasiert: deckt + # Wochenende, Feiertage UND Broker-Ausfall mit einer Regel. +_TRADE_MARKS = 6 # so viele letzte Trades im MQL5-Chart markieren + # (Kerze umranden: Eröffnung hellgrün, Close dunkelgrün) +_STARTUP_GRACE_S = 60.0 # nach Bot-Start schließt der Bot 60 s lang KEINEN + # laufenden Trade server-seitig (S/R-Close/Notfall/ + # Time-Stop/Reverse) — die Daten (Preis/ATR/P(break)/ + # Level) stabilisieren sich erst; verhindert den + # Sofort-Close direkt nach restart_server.bat. + # Broker-SL bleibt unberührt (läuft am Broker). + +# ── Kalibriertes P(Durchbruch)-Modell (logistische Regression, auf H1 trainiert, +# auf H2 KALIBRIERT geprüft: AUC 0,71, predicted≈echt; `analyze_srwaves.py`). +# Merkmale (in Positionsrichtung): [mom6, mom3, mit-Trend(0/1), Level-Distanz/ATR]. +# Standardisierung (mu/sd) + Gewichte (intercept + 4) aus `backtest_srclose_prob.py`. +# P(break)-Modell — auf **M30-Level** trainiert (Umstellung 2026-07-30, gemessen +# `backtest_level_tf.py`): M30-Pivots schlagen die alten M5-Pivots in BEIDEN Hälften +# (AUC out-of-sample 0,721 vs 0,710 · R-Ertrag der Close-Regel ~2× · Level 1,95 statt +# 0,61×ATR entfernt = die „S/R-Linien kleben aneinander"-Beobachtung des Users). +# Ablauf: out-of-sample validiert (H1→H2), danach auf ALLEN 80k Bars nachtrainiert +# (34.771 Touches, Basisrate 39,4 %, Kalibrierung trifft: 23/25 · 38/36 · 58/59 · 86/87). +# ⚠ Die Merkmale bleiben M5-basiert (mom6/mom3 auf M5-Bars) — nur die LEVEL sind M30. +# Alte M5-Werte (falls je zurück): MU (0.7482,0.7036,0.9112,0.7072) · +# SD (1.1597,0.9687,0.2844,0.6403) · W (-0.5853,0.1192,0.7820,0.1204,-0.2994). +# ⚠⚠ NEU TRAINIERT 2026-07-31 auf einer LIVE-SPIEGELNDEN Stichprobe +# (`backtest_pbreak_retrain.py` + `backtest_pbreak_rvalue.py`). GRUND: die alten +# Werte waren auf einer Situation trainiert, die live gar nicht vorkommt. +# Training ALT: Level beim Entry FIXIERT (≥0,3×ATR weg), warten auf den ersten +# Touch nach einem echten ANLAUF → mom3 ≈ 1,45×ATR (ein Schub) +# LIVE: `_draw_levels` wählt das nächste Level JEDE SEKUNDE NEU (Hysterese) +# → der Kurs steht neben einem Level, das gerade erst das nächste +# wurde, ohne Anlauf → mom3 ≈ 0,15×ATR +# Gemessen (aus `candles_m1` rekonstruiert, n=2275): mom3 live 0,149 vs. ALT-µ 1,4523 +# (−1,303) · mom6 0,181 vs 1,6565 (−1,476). Der daraus folgende z-Versatz (−1,05) +# erklärt die gesamte Fehlkalibrierung: das Modell sagte live IMMER ~23 % (real 41 %), +# lag damit stets unter der Schwelle → **das Gate war seit Inbetriebnahme nie aktiv** +# (91 % aller Berührungen geschlossen = der 2× verworfene pauschale S/R-Close). +# KONTROLLE auf der live-spiegelnden Stichprobe: ALT erreicht dort **AUC 0,368** +# (schlechter als Zufall!), NEU **0,654** out-of-sample (Fit H1 → H2). +# R-Ertrag (Echtkosten, Baseline = nur Trailing): NEU schlägt ALT in BEIDEN Hälften +# bei JEDER Schwelle; gegen die Baseline gewinnt es bei 0,35/0,45/0,55 in beiden +# (bei 0,60 kippt H1). Bestwert **0,35** (H1 +357, H2 +1418 gegen Baseline) → +# `sr_close_pbreak` entsprechend auf 0,35 gesetzt. +# ⚠ Das Gewicht von mom3 DREHT das Vorzeichen (+1,4330 → −0,5420): im Anlauf-Kontext +# hieß Schwung „Level bricht", im Klebe-am-Level-Kontext heißt er „Level hält". +# Deshalb war ALT live schlechter als eine Münze und nicht bloß nutzlos. +# Final auf allen 80k Bars gefittet (n=76.542 Events, Basisrate 37,8 %), nachdem +# H1→H2 out-of-sample validiert war — gleiches Vorgehen wie beim ersten Modell. +# Alte M30-Anlauf-Werte (Rückweg): MU (1.6565,1.4523,0.9465,1.9133) · +# SD (2.5291,2.4177,0.2250,2.1257) · W (-0.3264,0.4719,1.4330,0.0943,-0.1885). +_PB_MU = (0.1962, 0.2071, 0.5300, 3.2624) +_PB_SD = (1.3109, 0.9579, 0.4991, 3.5649) +_PB_W = (-0.5216, 0.0712, -0.5420, 0.0047, 0.0386) # [b0, mom6, mom3, wt, dist] + +def _p_break(mom6: float, mom3: float, wt: float, dist: float) -> float: + """P(Level-Durchbruch) ∈ (0,1) aus den 4 standardisierten Merkmalen.""" + import math + feat = (mom6, mom3, wt, dist) + z = _PB_W[0] + for k in range(4): + z += _PB_W[k + 1] * (feat[k] - _PB_MU[k]) / _PB_SD[k] + return 1.0 / (1.0 + math.exp(-max(-30.0, min(30.0, z)))) + + + +# --- Telegram-Gate (User-Vorgabe 2026-08-19) -------------------------------- +# "entferne alle telegram benachrichtigungen. nur noch benachrichtigungen ueber +# geschlossene trades." +# +# BEWUSST EINE STELLE statt 17 Einzeleingriffe: die Sender bleiben stehen +# (Entry-Meldungen, Reports, Alarme), nur der Versand wird gefiltert. Kleiner +# Diff, trivial umkehrbar (Liste erweitern = wieder an) -- und beim +# Zurueckdrehen muessen nicht 17 Stellen einzeln wieder stimmen. +# +# Der TAGES- und WOCHENREPORT wird dadurch nur auf dem TELEGRAM-Weg still -- +# die E-Mail laeuft weiter (er geht ohnehin an beide). Engste Lesart der +# Vorgabe: sie nennt ausdruecklich Telegram. +# +# EINE AUSNAHME mit Bypass: " FEHLGESCHLAGEN" beim Notfall-/ +# Gewinn-Close. Dort ist die Position NICHT zu und es braucht eine +# Handeingabe -- Stille genau dort waere die gefaehrlichste Variante. +_TG_ERLAUBT = ("Trade geschlossen",) + + +def send_telegram(text, *a, **kw): + """Gefilterter Versand -- nur Meldungen aus `_TG_ERLAUBT` gehen raus. + + Rueckgabe False fuer Unterdruecktes, damit Aufrufer, die den Erfolg + auswerten (z. B. der Report-Versand-Marker), nichts faelschlich als + 'zugestellt' verbuchen.""" + try: + if not any(k in (text or "") for k in _TG_ERLAUBT): + return False + except Exception: + return False + return _send_telegram_roh(text, *a, **kw) + + +class TradingEngine: + """Headless-Pendant zu OilWidget: hält die core-Objekte, fährt die + Refresh-Loops als Threads und liefert einen aggregierten Snapshot.""" + + def __init__(self, cfg=None): + self.cfg = cfg or load_config() + try: + set_margin_buffer(float(self.cfg["trading"].get("margin_buffer_pct", "90"))) + except Exception as e: + log.warning(f"Margin-Buffer aus Config: {e}") + try: + set_risk_per_trade(float(self.cfg["trading"].get("risk_pct", "1.5"))) + except Exception as e: + log.warning(f"Risiko/Trade aus Config: {e}") + + # ── History + Telegram ──────────────────────────────────────────── + self.history = HistoryLogger(HISTORY_DB_FILE) + tg = self.cfg["telegram"] + self.history.configure_telegram( + enabled = tg.get("enabled", "false").strip().lower() == "true", + token = tg.get("bot_token", "").strip(), + chat_id = tg.get("chat_id", "").strip(), + ) + # Schwelle für die Close-Benachrichtigung (0 = aus). Siehe __init__. + try: + self._close_notify_min = max( + 0.0, float(tg.get("close_notify_min_eur", "50") or 0)) + except (TypeError, ValueError): + self._close_notify_min = 50.0 + # ── M15-Alert (User-Wunsch 2026-08-14) ──────────────────────────── + # Defaults an den ECHTEN Daten kalibriert, nicht geraten: roh 85 + # Gruen-Flanken/Tag → mit Level-Entprellung 23,7 → +30 min 13,3 → + # +Fenster 8-20 Uhr **7,7/Tag** (Close-Meldung liegt bei 2,4-6,1). + self._m15_alert = (tg.get("m15_alert", "true").strip().lower() == "true") + try: + self._m15_alert_cd = max( + 0.0, float(tg.get("m15_alert_cooldown_min", "30") or 0)) * 60.0 + except (TypeError, ValueError): + self._m15_alert_cd = 1800.0 + try: + _h = str(tg.get("m15_alert_hours", "8-20")).split("-") + self._m15_alert_hours = (int(_h[0]), int(_h[1])) + except (TypeError, ValueError, IndexError): + self._m15_alert_hours = (8, 20) + self._m15_alert_war = False # Flankenerkennung + self._m15_alert_ts = 0.0 + self._m15_alert_key = None + + # ── Markt / Trading / Trailing ──────────────────────────────────── + self.data = MT5Data() + self.trader = TradeManager() + self.trader.history = self.history + self.trail = TrailingManager(self.trader) + self.trail.set_notify(self._on_trail_event) + # (Dry-Run-Auto-Trader entfernt 2026-07-23 — war Verlierer im Backtest, + # UI-Toggle längst weg. `min_conf`-Anzeigekonstante bleibt, s. unten.) + + # ── Analyse-Module ──────────────────────────────────────────────── + self.tu = TradersUnionProvider() + self.wave = WaveRecommender(self.tu, mt5.TIMEFRAME_M5) + try: + self.wave.set_breakout_k(float(self.cfg["trading"].get("breakout_k", "1.0"))) + except (TypeError, ValueError): + pass + try: + self.wave.set_entry_room(float(self.cfg["trading"].get("entry_room_atr", "0.6"))) + except (TypeError, ValueError): + pass + # Reversal/Bounce-Setup — Default hier **false** (User-Entscheidung 2026-08-04 + # nach `backtest_reversal_angle.py`: in JEDER Variante beidhälftig negativ, und + # es hebelt Anti-Überdehnung + M30-Filter aus). Der Modul-Default in `wave_rec` + # bleibt True, damit die Backtests unverändert bleiben — abgeschaltet wird + # ausschließlich hier im Live-Pfad. `reversal_enabled=true` holt es zurück. + self.wave.set_reversal_enabled( + (self.cfg["trading"].get("reversal_enabled", "false") or "false") + .strip().lower() == "true") + # Tageszeit-Gate aus Config (Komma-Liste von Stunden; leer = AUS). Fehlt der + # Key → gemessener Default (im wave_rec-Konstanten-Set). User-Vorgabe 2026-07-22: + # ini auf leer → Gate aus, obwohl 12/16 gemessen negativ sind. + _dh = self.cfg["trading"].get("dead_hours", None) + if _dh is not None: + self.wave.set_dead_hours([h.strip() for h in _dh.split(",") if h.strip()]) + self.elliott = ElliottAnalyzer(mt5.TIMEFRAME_M5) + self.structure = MarketStructure() # Marktstruktur (M30) — reine Anzeige + self.patterns = PatternDetector() # visuelle Chartmuster (M30) — reine Anzeige + # Kerzen-Anatomie (M5) — REINE ANZEIGE. Die Lehrbuch-Deutung ist gemessen + # invertiert (`backtest_candles.py`), das Handelssignal fiel durch + # (`backtest_candle_fade.py`) → kein Verdict-Gewicht, kein Trigger. + self.candle_anat = CandleAnatomy("M5") + # Echte Liquiditäts-Wände vom HL-Orderbuch (Anzeige im MT5-Chart, kein Signal). + # Braucht das HL-Dashboard auf 8001; ist es aus, bleibt der Chart unverändert. + from core.hl_walls import HLWalls + _t2 = self.cfg["trading"] + self.hl_walls = HLWalls( + url=_t2.get("hl_walls_url", "http://localhost:8001/api/walls"), + enabled=_t2.get("export_hl_walls", "true").lower() == "true") + self.candles = CandleLogger(HISTORY_DB_FILE) # M1-Candle-Log (Daten-Sammlung) + # Wirtschaftskalender (Handbuch-Regel „keine Trades vor High-Impact-News"). + # ⚠ NICHT gemessen (Events zu selten für 2-Stichproben) → sperrt nur die + # AUTONOMEN Entries + warnt in der Checkliste, blockt keine manuelle Order. + # Der gemessene EIA-Blackout (Mi 15:30–16:30) bleibt unabhängig davon aktiv. + from core.econ_calendar import EconCalendar + self.calendar = EconCalendar( + before_min=int(_t2.get("news_blackout_before_min", "15")), + after_min =int(_t2.get("news_blackout_after_min", "15")), + enabled =_t2.get("news_blackout", "true").lower() == "true") + # News: eigener Fetch-Loop (versorgt UI /api/news UND Agent-Kontext) + tcfg = self.cfg["translation"] + translator = NewsTranslator( + enabled = tcfg.get("enabled", "true").lower() == "true", + target = tcfg.get("target_language", "de"), + provider = tcfg.get("provider", "google"), + ) + self.news = NewsFetcher(translator=translator) + self._news_refresh_s = max(60, int(self.cfg["news"].get("refresh_min", "10")) * 60) + self.agent = TradingAgent( + self.cfg, data=self.data, trader=self.trader, trail=self.trail, + tu=self.tu, wave=self.wave, history=self.history, + news=self.news, elliott=self.elliott, engine=self) + # Letzte kalibrierte Größen für den Copiloten (2026-08-02). Der Snapshot + # rechnet sie ohnehin je Zyklus — hier nur festhalten, damit `agent. + # _tool_market` sie mitgeben kann, OHNE sie ein zweites Mal zu rechnen + # (derselbe Fehler wie beim Konsens-Pfeil, der ein zweites `_verdict()` zog). + self._last_sr_close_hint: dict | None = None + self._last_stop_approach: dict | None = None + self._last_cone: dict | None = None + # Tägliche WTI-Intraday-Level per Web-Recherche (z.ai/GLM + web_search). + # Nur Kontext → S/R + Anzeige, NICHT Handelsrichtung. + zc = self.cfg["zai"] if self.cfg.has_section("zai") else {} + self.daily = DailyLevels(zc.get("api_key", ""), + zc.get("base_url", "https://api.z.ai/api/paas/v4"), + zc.get("model", "glm-4.5-flash")) + self._daily_last_run: str | None = None # ISO-Datum des letzten Laufs + self._daily_fails = 0 + self._weekly_last_run = None # (ISO-Jahr, KW) des letzten B4-Reports + # Versand-Status überlebt Neustarts — sonst schickt jeder restart_server.bat + # nach 07:30 den Tages-/Wochenreport ERNEUT (war real: 3× am 07.07.) + import os as _os + self._report_state_path = _os.path.join( + _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))), + "report_state.json") + self._load_report_state() + # Kurslücken-Analyse (D1): Fill-Level als S/R-Magnete (Kontext/Konfidenz, + # keine eigene Handelsrichtung). Refresh gedrosselt (~10 min) im Trend-Loop. + self.gaps = GapAnalyzer(refresh_s=600) + + self.require_token = self.cfg["web"].get("require_token", "true").strip().lower() == "true" + self._tf_select = self.cfg["trading"].get("tf_select", "heuristic").strip() + self._tf_min = self.cfg["trading"].get("tf_min", "M5").strip().upper() + if self._tf_min not in _ATR_TF_MAP: + self._tf_min = "M5" + # Obergrenze der Heuristik-TF (Daytrading: nur M1–M15). M30/H1 bleiben als + # Kontext (Filter/Konfluenz), werden aber NICHT mehr als Basis-TF gewählt. + self._tf_max = self.cfg["trading"].get("tf_max", "H1").strip().upper() + if self._tf_max not in _ATR_TF_MAP: + self._tf_max = "H1" + self._wave_tf_label = "M5" + # Mindest-Verweildauer der TF-Heuristik (Fix 2026-07-31, s. `_tf_loop`): + # verhindert das Hin-und-Her, wenn der Score einer TF im Trend auf 0,0 fällt + # (Überdehnung) und nach dem Rücksetzer zurückspringt. 0 = aus. + try: + self._tf_min_dwell_s = float(self.cfg["trading"].get("tf_min_dwell_s", "900")) + except (TypeError, ValueError): + self._tf_min_dwell_s = 900.0 + self._tf_last_switch = 0.0 + self._last_rec: dict = {} + self._agent_fail_cnt = 0 + self._close_alert_ts = 0.0 # letzter CLOSE-Telegram-Push (5-min-Cooldown) + self._close_alert_ticket = None # Ticket, auf das sich der Cooldown bezieht + self._close_alert_count = 0 # wie oft der Flip-Close-Alarm gefeuert hat + self._auto_sr_close = self.cfg["trading"].get("auto_sr_close", "true").lower() == "true" + try: + self._sr_close_pbreak = float(self.cfg["trading"].get("sr_close_pbreak", "0.60")) + except (TypeError, ValueError): + self._sr_close_pbreak = 0.60 + self._sr_close_pbreak = max(0.30, min(0.90, self._sr_close_pbreak)) # sinnvolles Band + self._sr_close_count = 0 # wie oft der Bot am S/R automatisch schloss + self._sr_close_last_ticket = None # verhindert Doppel-Auslösung je Ticket + # Autonomer Entry auf den gemessen-validierten Squeeze-Ausbruch (nur FLAT). + # ⚠ ECHTE Order — Default AUS (Autonomie nie stillschweigend an). `[trading] + # auto_squeeze=true` aktiviert. Dedup über (dir, level) des Ausbruchs. + self._auto_squeeze = self.cfg["trading"].get("auto_squeeze", "false").lower() == "true" + self._squeeze_skip_night = self.cfg["trading"].get("auto_squeeze_skip_night", "true").lower() == "true" + # ── WELCHE Stunden ausgesetzt werden (2026-08-05, gemessen `backtest_cost_gate.py`). + # War fest 0–7. Die Messung zeigt: KEINE Stunde ist in beiden Halbjahren negativ — + # es gibt keine „toten Stunden". Jedes Fenster kostet also Ertrag; die Sperre kauft + # ausschließlich Beaufsichtigungs-Ruhe. 3–6 Uhr ist das günstigste Fenster mit noch + # ≥4 h Schutz: Preis ΣR −12,5/−62,9 R gegen −42,4/−242,6 bei 0–7 (≈ 27 %). + # Leere Liste = wirkt wie `auto_squeeze_skip_night=false`. + self._squeeze_night_hours = tuple( + int(x) for x in str(self.cfg["trading"].get( + "auto_squeeze_night_hours", _SQUEEZE_NIGHT_DEFAULT)).split(",") + if x.strip().isdigit() and 0 <= int(x) <= 23) + # Stop-&-Reverse nur, wenn explizit an (Default aus) — der Reverse ist unbelegt; + # der flat-Entry bleibt davon unberührt (der validierte Teil). + self._squeeze_reverse = self.cfg["trading"].get("auto_squeeze_reverse", "false").lower() == "true" + self._squeeze_last_sig = None # Dedup Entry (flat) + # ── Einstieg über RUHENDE Stop-Orders statt Market (2026-08-05, gemessen + # `backtest_squeeze_entry.py`: der Einstiegspreis IST der Edge). Die Orders + # liegen, solange der Squeeze `armed` ist, an beiden Box-Grenzen. + self._squeeze_pending = self.cfg["trading"].get( + "squeeze_pending_entry", "true").lower() == "true" + # Market bleibt der Fallback — aber nur, solange der Kurs noch im gemessen + # positiven Bereich über dem Level steht (0,20×ATR → ØR +0,244/+0,426). + try: + self._squeeze_max_chase = float(self.cfg["trading"].get( + "squeeze_max_chase_atr", "0.20")) + except (TypeError, ValueError): + self._squeeze_max_chase = 0.20 + 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._squeeze_reverse_sig = None # Dedup Reverse-Close (Gegen-Trade) + self._squeeze_entry_count = 0 + # ⚠⚠ DER AUTONOME ENTRY AUF DIE EMPFEHLUNG (Auto-Signal / „SIG") IST AM + # 2026-08-12 KOMPLETT ENTFERNT (User-Entscheidung). Er war seit dem + # 01.08. aus und auf BEIDEN Ausfuehrungs-Achsen gemessen negativ — + # Markt-Einstieg in allen 8 Feldern, ruhende Order mit Touch-Fill in + # allen 6 und dabei sogar SCHLECHTER als Markt. Ursache strukturell: + # das Bestaetigungs-Level `_pend` WANDERT. + # Weg sind: `_check_auto_signal`, `set_auto_signal`, der Signal-Zweig in + # `_pending_ziel`, `/api/autosignal`, der Dashboard-Button und die + # Snapshot-Felder. Die ini-Schluessel (`auto_signal*`, + # `signal_pending_entry`) werden nicht mehr gelesen. + # GEBLIEBEN: die AUTOSIG_*-Trades in der DB (Historie) und die + # Backtests (sie dokumentieren das Warum). + # Sicherung: `.removed_backup/auto_signal_2026-08-12.py` + Git. + # 15-Minuten-Regel für BOT-eröffnete Trades (gemessen kalibriert: 0,5×ATR) + # ⚠ `_t` wurde bis 2026-08-12 im entfernten Auto-Signal-Block gesetzt; der + # Linter (`ruff F821`) hat den Bruch beim Entfernen sofort gemeldet. + _t = self.cfg["trading"] + try: + self._adverse15_atr = max(0.0, float(_t.get("adverse_15min_atr", "0.5"))) + except (TypeError, ValueError): + self._adverse15_atr = 0.5 + self._bot_open_ticket = None # Ticket, das der Bot selbst eröffnet hat + self._bot_open_source = None # ... und WOMIT (auto_squeeze | auto_signal). + # Nötig, weil die 15-Min-Regel nur für den + # Auto-Signal-Pfad gemessen ist (s. unten). + self._adverse15_done_ticket = None # 15-Min-Prüfung je Ticket nur 1× + self._adverse15_count = 0 + self._sr_close_ts = 0.0 # Zeit + Richtung des letzten S/R-Closes + self._sr_close_dir = None # → Cooldown gegen Gleichrichtungs-Roundtrip + 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 + # ── Close-Benachrichtigung (2026-08-11, User: „ich bekomme keine Telegram- + # Nachrichten mehr"). Diagnose damals: der Kanal war intakt (Testnachricht + # zugestellt), aber ALLE NEUN Auslöser waren aus — jeder einzeln begründet, + # zusammen aber ein Nebeneffekt, den niemand beschlossen hatte. Diese + # Meldung hängt bewusst an KEINER Automatik: sie feuert bei jedem Close, + # auch bei manuellen und bei Broker-SL. Genau der −87-€-SL vom 10.08. kam + # sonst wortlos. + # ⚠ Schwelle aus den Daten gewählt, nicht geraten: bei ~17 Trades/Tag liefert + # 50 € rund 2,4–6,1 Meldungen/Tag und erfasst 54–80 % des bewegten Geldes. + # ⚠ Eine ABSOLUTE Euro-Schwelle altert mit der Positionsgrösse (Median |P&L| + # über 30 Tage 14,65 €, über die letzten 7 Tage 35,80 €) — bei wachsendem + # Konto kommen mehr Meldungen. Bewusst so: ein Mensch denkt in Euro. + # ⚠ `_close_notify_min` wird WEITER OBEN aus der ini gelesen (bei der + # Telegram-Konfiguration) — hier NICHT erneut zuweisen, das ueberschriebe + # den Wert mit 0 und die Meldung waere still tot. + self._close_notify_pending = None # [ticket, Versuche] bis `exit_time` steht + # Markt-zu-Erkennung über die letzte KURSBEWEGUNG (s. `snapshot`) — die + # Tick-Zeitstempel taugen dafür nicht (Abholzeit bzw. zirkulärer Offset). + self._last_bid = None + self._last_bid_move_ts = 0.0 + # 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 + # Nachrang gegenüber dem S/R-Close (gemessen besser, s. `_check_flip_close`) + self._flip_close_subordinate = \ + _tf_cfg.get("auto_flip_close_subordinate", "true").lower() == "true" + 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 + # aus echter Margin berechnet wurde (Retry-Schutz, + # s. `_check_auto_close`) + try: + self._sr_close_min_gain = max(0.0, float( + self.cfg["trading"].get("sr_close_min_gain", "0"))) + except (TypeError, ValueError): + self._sr_close_min_gain = 0.0 + # Gewinn-Close automatisch auf X% des Einsatzes (Margin) DIESER Position setzen, + # sobald sie eröffnet wird (User-Vorgabe 2026-07-23) — überschreibt den manuell + # gemerkten/persistierten Wert bei jeder ECHT NEUEN Position (nicht beim + # Wiedererkennen nach Neustart, s. `_check_auto_close`). 0 = aus (dann bleibt der + # manuell gesetzte/gemerkte Wert wie bisher maßgeblich). + try: + self._sr_close_min_gain_pct = max(0.0, float( + self.cfg["trading"].get("sr_close_min_gain_pct", "1.0"))) + except (TypeError, ValueError): + self._sr_close_min_gain_pct = 1.0 + self._pb_state = None # geglättetes P(break) + Hysterese-Zone je Level + self._stop_pb_state = None # dito für die Gegen-/Stop-Seiten-Anzeige + # (gemessen `analyze_pbreak_flicker.py`: mom3 der + # laufenden Kerze ließ P ~7–15 Pp je M5-Bar springen) + # UI-Einstellungen neustart-fest (Fix 2026-07-19): Toggles/Werte, die über + # die App gesetzt werden (Auto-Squeeze, S/R-Auto-Close, Mindestgewinn), + # überschreiben nach dem Laden die ini-Defaults — vorher war z. B. ein am + # Handy gesetzter Mindestgewinn nach jedem restart_server.bat wieder weg. + import os as _os2 + self._runtime_state_path = _os2.path.join( + _os2.path.dirname(_os2.path.dirname(_os2.path.abspath(__file__))), + "runtime_state.json") + self._load_runtime_state() + self._mql5_files_dir = None # \MQL5\Files (lazy, für den Indikator) + self._export_trade_marks = self.cfg["trading"].get("export_trade_marks", "false").lower() == "true" + self._export_mql5 = self.cfg["trading"].get("export_mql5_levels", "true").lower() == "true" + # Kegel im MT5-Chart: per Default AUS, seit die Pfeile ihn ersetzen (2026-07-31). + self._export_cone = self.cfg["trading"].get("export_cone", "false").lower() == "true" + # Meldungen oben links im MT5-Chart (User-Wunsch 2026-08-17) — dieselben + # Zeilen wie die Dashboard-Karte, nur zusammengesetzt, nicht neu gerechnet. + self._export_meldungen = ( + self.cfg["trading"].get("export_meldungen", "true").lower() == "true") + # Konsens-Pfeil (AR;K) — Default AUS, spiegelt `InpShowConsensus=false` im + # Indikator. Bei `true` wird je Export ein zweites `_verdict()` gerechnet. + self._export_consensus_arrow = self.cfg["trading"].get( + "export_consensus_arrow", "false").lower() == "true" + self._draw_last = {"r": None, "s": None} # letzte gezeichnete S/R (Hysterese) + try: # WHT-Quellensteuer (Broker behält % je GEWINN ein) → Netto-Anzeige + self._wht_pct = max(0.0, float(self.cfg["trading"].get("wht_pct", "0") or 0)) + except (TypeError, ValueError): + self._wht_pct = 0.0 + self._draw_cache: tuple[float, dict] | None = None # (ts, result) TTL ~1 s — + # 4 Aufrufer (Snapshot 2×, pos_loop, CSV, Charts): EIN Ergebnis je Sekunde + # garantiert identische Linien in Kachel/Chart/Close-Meldung + spart Lock-Last + self._emergency_loss: float | None = None # aktiver Notfall-Stop (Verlust) + self._last_pos_ticket = None # für Auto-Arm beim Öffnen + # Mindest-Notfall-Stop (Kontowährung): ein von Hand gesetzter Wert UNTER + # diesem Minimum wird als „aus" behandelt statt scharfgestellt — sonst killt + # ein Vertipper (z. B. „1") jeden Trade sofort, weil die Schwelle unter dem + # Spread liegt UND per _emergency_remember auf ALLE Folge-Trades vererbt wird + # (real 2026-07-27: −1-€-Stop schloss Trades in derselben Sekunde). 0 = aus. + try: + self._emergency_min = max(0.0, float(self.cfg["trading"].get("emergency_min_eur", "3") or 0)) + except (TypeError, ValueError): + self._emergency_min = 3.0 + # Beim Öffnen einer neuen Position wird der Notfall-Stop automatisch auf + # den ZULETZT genutzten Wert gesetzt (gemerkt über Trades hinweg). Start- + # wert = [trading] auto_emergency_loss (0 = Auto-Arm aus). + try: + self._emergency_remember = float(self.cfg["trading"].get("auto_emergency_loss", "10") or 0) + except (TypeError, ValueError): + self._emergency_remember = 10.0 + # Auto-Notfall-Stop als % des Kontos (User-Vorgabe 2026-07-15): ist + # `[trading] auto_emergency_pct` > 0, wird beim Öffnen der Stop auf + # pct% der Balance gesetzt (skaliert mit dem Konto) STATT auf den + # gemerkten Fixwert. 0 = aus (Fixwert-Verhalten wie bisher). + try: + self._emergency_pct = max(0.0, float(self.cfg["trading"].get("auto_emergency_pct", "0") or 0)) + except (TypeError, ValueError): + self._emergency_pct = 0.0 + # Notfall-Stop als % der EINSATZ-Margin dieser Position (User-Vorgabe + # 2026-07-23, `[trading] auto_emergency_margin_pct=3.0`) — dritter, margin- + # basierter Modus, hat Vorrang vor dem Balance-%-Modus (`_emergency_pct`) + # und dem Fixwert, wenn >0. Retry-sicher wie `_sr_close_min_gain_pct` + # armiert (s. `_check_auto_close`): `order_calc_margin` kann im ersten Tick + # nach dem Öffnen noch 0 liefern. ⚠ Bekannte Kopplungs-Regel (CLAUDE.md): + # ein %-Notfall-Stop ist unter Margin-Sizing enger als der 2×ATR-SL — bewusste + # User-Vorgabe trotzdem, kein Gegen-Argument wiederholen. + try: + self._emergency_margin_pct = max(0.0, float( + self.cfg["trading"].get("auto_emergency_margin_pct", "0") or 0)) + except (TypeError, ValueError): + self._emergency_margin_pct = 0.0 + self._emergency_margin_armed_ticket = None # Retry-Schutz wie bei sr_close_min_gain + # ── Circuit Breaker (Tagesverlust-Stopp, User-Vorgabe 2026-07-27) ────────── + # `[trading] daily_loss_limit_pct` > 0: erreicht der TAGES-P&L (realisiert heute + # + offene Position) −pct% der Balance, schließt der Bot die Position und + # eröffnet KEINE neuen Auto-Trades mehr bis zum nächsten Tag. Überlebens-Netz + # gegen Verlust-Kaskaden (früher abgelehnt; mit dem Autonomie-Ziel reaktiviert + # — unter 80 %-Margin schützt es v. a. gegen die Serie, nicht den Einzeltrade, + # dessen 2×ATR-SL allein schon >pct% verlieren kann). 0 = aus. + try: + self._cb_limit_pct = max(0.0, float(self.cfg["trading"].get("daily_loss_limit_pct", "0") or 0)) + # Toggle-Gedaechtnis: ohne das waere die Prozentzahl nach + # einmal Ausschalten verloren (s. `set_circuit_breaker`). + self._cb_remember = self._cb_limit_pct or 8.0 + except (TypeError, ValueError): + self._cb_limit_pct = 0.0 + self._circuit_tripped_date = None # Berlin-Datum, an dem der Breaker griff + self._cb_realized = (0.0, 0.0) # (ts, realisierter Tages-P&L) gecacht ~30 s + # Gewinn-mitnehmen (Take-Profit) — symmetrisch zum Notfall-Stop: schließt + # bei P&L ≥ +Wert. Gemerkter Wert über Trades hinweg (0 = Auto-Arm aus). + self._takeprofit: float | None = None + try: + self._takeprofit_remember = float(self.cfg["trading"].get("auto_takeprofit", "0") or 0) + except (TypeError, ValueError): + self._takeprofit_remember = 0.0 + # Notfall-Stop/TP überleben Neustarts: sonst re-armiert ein restart_server.bat + # eine OFFENE Position auf die 2%-Automatik und verwirft eine manuelle Über- + # steuerung (real 2026-07-17 14:30: −25 € → −19,42 €, dann Notfall-Close bei + # −19,64). Persistiert {ticket, loss, tp}; in _check_auto_close wiederhergestellt, + # solange dasselbe Ticket noch offen ist. + self._emergency_state_path = _os.path.join( + _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))), + "emergency_state.json") + self._emergency_state_ticket = None + self._emergency_state_loss = None + self._emergency_state_tp = None + self._emergency_state_trail = None # Trailing-Zustand per-Ticket (neustart-fest) + self._load_emergency_state() + # Startup-Schonfrist: bis zu diesem Zeitpunkt kein server-seitiger Auto-Close + # (in start() gesetzt). Konfigurierbar via [trading] startup_close_grace_s. + try: + self._startup_grace_s = max(0.0, float( + self.cfg["trading"].get("startup_close_grace_s", str(_STARTUP_GRACE_S)))) + except (TypeError, ValueError): + self._startup_grace_s = _STARTUP_GRACE_S + self._no_autoclose_until = 0.0 + self._running = False + self._connected = False + self._error: str | None = None + self._threads: list[threading.Thread] = [] + # Snapshot-Cache: ein berechneter Snapshot wird über alle WS-Clients + + # HTTP-Aufrufe geteilt (TTL) → SQLite/Verdict nur 1×/TTL statt N×/s. + self._snap_cache: dict | None = None + self._snap_cache_ts: float = 0.0 + self._snap_lock = threading.Lock() + self._sqmon_cache: dict | None = None # Auto-Squeeze-Monitor (B4), TTL 60 s + self._sqmon_cache_ts: float = 0.0 + + # ══════════════════════════════════════════════════════════════════════ + # LIFECYCLE + # ══════════════════════════════════════════════════════════════════════ + def start(self) -> bool: + """Verbindet MT5, übernimmt offene Positionen und startet die Loops.""" + preferred = self.cfg["trading"].get("last_symbol", "").strip() or None + if not self.data.connect(preferred_symbol=preferred): + self._error = self.data.error + log.error(f"MT5-Connect fehlgeschlagen: {self.data.error}") + return False + self._connected = True + self._adopt_positions() + + # Start-Timeframe: fester tf_select hat Vorrang, sonst letzte Wahl aus + # der Config; Heuristik/Agent justieren danach laufend. + if self._tf_select.upper() in _ATR_TF_MAP: + saved_tf = self._tf_select.upper() + else: + saved_tf = self.cfg["trading"].get("atr_tf", "M5").strip().upper() + if saved_tf not in _ATR_TF_MAP: + saved_tf = "M5" + # Start-TF in das erlaubte Band [tf_min … tf_max] klemmen (Heuristik-Modus) + if self._tf_select == "heuristic": + order = list(_ATR_TF_MAP.keys()) + lo = order.index(self._tf_min); hi = order.index(self._tf_max) + r = order.index(saved_tf) + saved_tf = order[min(max(r, lo), hi)] + self._wave_tf_label = saved_tf + const = _ATR_TF_MAP[saved_tf] + self.wave.set_timeframe(const) + self.elliott.set_timeframe(const) + self.trail.set_atr_tf_override(const) + + threading.Thread(target=self.trader.reconcile_open_trades, + daemon=True, name="reconcile").start() + # RSS-Feeds im Hintergrund validieren (ausgefallene werden ersetzt) + threading.Thread(target=self.news.validate_feeds, + daemon=True, name="feed-validator").start() + + # Startup-Schonfrist scharf: die nächsten `_startup_grace_s` s schließt der + # Bot keinen laufenden Trade selbst (S/R-Close/Notfall/Time-Stop/Reverse) — + # verhindert Sofort-Close eines adoptierten Trades auf noch-instabilen Daten. + self._no_autoclose_until = time.time() + self._startup_grace_s + self.trail.set_no_close_until(self._no_autoclose_until) + if self._startup_grace_s > 0: + log.info(f"Startup-Schonfrist: {self._startup_grace_s:.0f} s kein Auto-Close") + + self._running = True + # _tu_loop stillgelegt (2026-07-19): TU ist aus Empfehlung/Verdict/Copilot + # raus UND die Dashboard-Kachel ist ausgeblendet → der 5-min-Scrape lief + # für niemanden. Reaktivieren = self._tu_loop wieder in die Liste. + for loop in (self._price_loop, self._trend_loop, self._pos_loop, + self._news_loop, self._tf_loop, self._agent_loop, + self._daily_loop): + t = threading.Thread(target=loop, daemon=True, name=loop.__name__) + t.start() + self._threads.append(t) + + log.info(f"Engine gestartet · Symbol {self.data.symbol} · TF {saved_tf}") + log.info("Auto-Squeeze-Nachtsperre: " + ( + ", ".join(f"{h}:00" for h in self._squeeze_night_hours) + if (self._squeeze_skip_night and self._squeeze_night_hours) else "AUS")) + return True + + def stop(self): + self._running = False + try: + self.data.disconnect() + except Exception: + pass + + def _adopt_positions(self): + """Beim Start vorhandene MT5-Positionen übernehmen (wie main() im Widget).""" + try: + all_pos = mt5.positions_get() or [] + log.info(f"Sofort-Adoption: {len(all_pos)} offene Position(en)") + if all_pos: + own = [p for p in all_pos if getattr(p, "magic", 0) == MAGIC] + pick = own[0] if own else all_pos[0] + if pick.symbol != self.data.symbol: + log.info(f"Symbol-Switch beim Start: {self.data.symbol} → " + f"{pick.symbol} (offene Position T={pick.ticket})") + ok, msg = self.data.switch_symbol(pick.symbol) + if not ok: + log.warning(f"Symbol-Switch fehlgeschlagen: {msg}") + self.trader.refresh(self.data.symbol) + except Exception as e: + log.warning(f"Sofort-Adoption: {e}", exc_info=True) + + # ══════════════════════════════════════════════════════════════════════ + # LOOPS (jeder MT5-Call sichert sich intern den globalen mt5_lock) + # ══════════════════════════════════════════════════════════════════════ + def _price_loop(self): + cnt = 0 + while self._running: + cnt += 1 + try: + self.data.fetch_price() + s = self.data.snapshot() + if s.get("bid") and s.get("ask") and self.data.symbol: + try: + self.trail.on_price(self.data.symbol, s["bid"], s["ask"]) + except Exception as te: + log.warning(f"trail.on_price: {te}") + except Exception as e: + log.error(f"_price_loop#{cnt}: {e}", exc_info=True) + time.sleep(_PRICE_S) + + def _trend_loop(self): + while self._running: + try: + self.data.fetch_trend() + if self.data.symbol: + # S/R-Kontext an das Wellenmodul geben (für die Konfidenz) + try: + srl = self._sr_levels(self.data.snapshot()) + self.wave.set_sr_context(srl.get("resistance"), + srl.get("support")) + except Exception as se: + log.warning(f"set_sr_context: {se}") + try: self.gaps.maybe_refresh(self.data.symbol) + except Exception as ge: log.warning(f"gaps.maybe_refresh: {ge}") + try: self.wave.refresh_market(self.data.symbol) + except Exception as we: log.warning(f"wave.refresh_market: {we}") + try: self.elliott.refresh_market(self.data.symbol) + except Exception as ee: log.warning(f"elliott.refresh_market: {ee}") + try: self.structure.refresh_market(self.data.symbol) + except Exception as se2: log.warning(f"structure.refresh_market: {se2}") + try: self.patterns.refresh_market(self.data.symbol) + except Exception as pe2: log.warning(f"patterns.refresh_market: {pe2}") + try: self.candle_anat.refresh_market(self.data.symbol) + except Exception as ka: log.warning(f"candles.refresh_market: {ka}") + try: self.candles.log(self.data.symbol) # M1-Candle-Log (~55 s) + except Exception as ce: log.warning(f"candles.log: {ce}") + try: self._evaluate_pbreak_predictions() # ~alle 5 min, self-throttled + except Exception as pe: log.warning(f"pbreak_predictions: {pe}") + try: self._evaluate_rec_outcomes() # ~alle 5 min, self-throttled + except Exception as re_: log.warning(f"rec_outcomes: {re_}") + # Selbstmessung der M15-Karte (2026-08-08): ohne Rueckkopplung + # koennte sie monatelang falsch stehen, ohne dass es auffaellt — + # genau die Luecke, die `pbreak_predictions` beim P(break)-Modell + # geschlossen hat. + try: self._evaluate_m15_states() # ~alle 5 min, self-throttled + except Exception as me: log.warning(f"m15_states: {me}") + # Kegel-Abdeckung + ACI-Nachjustierung (2026-08-08) + try: self._evaluate_cone_checks() + except Exception as ce: log.warning(f"cone_checks: {ce}") + # Wirtschaftskalender (self-throttled ~1 h, außerhalb des mt5_lock + # unkritisch — reiner HTTP-Fetch mit Timeout) + # HL-Kontext (Funding/OI/Premium) — reine Anzeige. + # ⚠⚠ IM DAEMON-THREAD, nicht inline: der Aufruf fragt die + # HL-API live nach allen Öl-Märkten und braucht real ~7 s. + # Inline würde er den Trend-Loop bei jedem Fälligwerden + # (~5 min) so lange anhalten — dieselbe Begründung, aus der + # der Wirtschaftskalender im Thread läuft. `market()` ist + # selbst gedrosselt und thread-sicher, ein zweiter Aufruf + # während der erste läuft liefert einfach den Cache. + try: + if not getattr(self, "_hl_ctx_busy", False): + self._hl_ctx_busy = True + threading.Thread(target=self._hl_ctx_refresh, + daemon=True).start() + except Exception as he: log.debug(f"hl market: {he}") + try: self.calendar.maybe_refresh() + except Exception as ke: log.warning(f"calendar.refresh: {ke}") + self._run_analysis() + except Exception as e: + log.error(f"_trend_loop: {e}", exc_info=True) + self._sleep(_TREND_S) + + def _pos_loop(self): + while self._running: + try: + if self.data.symbol: + self.trader.refresh(self.data.symbol) + self._check_circuit_breaker() # Tagesverlust-Stopp (Überleben zuerst) + self._check_auto_close() + # ⚠ NACH `_check_auto_close`: erst dort wird der Flat-Übergang + # erkannt und die Benachrichtigung vorgemerkt. + self._check_close_notify() # Telegram bei jedem Close ab Schwelle + self._check_adverse15() # 15-Min-Regel (nur Bot-Trades) + self._check_flip_close() # Empfehlung dreht → schließen + self._check_sr_close() + # 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 + except Exception as e: + log.error(f"_pos_loop: {e}", exc_info=True) + self._sleep(_POS_S) + + def set_emergency(self, value) -> float | None: + """Notfall-Verlust-Schwelle setzen/löschen (Kontowährung, als positiver + Betrag gespeichert). None/leer/0 → aus. Rückgabe = aktiver Wert.""" + try: + v = abs(float(value)) if value not in (None, "", "0", 0) else None + if v is not None and v <= 0: + v = None + except (TypeError, ValueError): + v = None + # Vertipper-Schutz: ein Wert unter dem Minimum (Spread-Region) würde den + # Trade sofort schließen UND sich auf alle Folge-Trades vererben → als „aus" + # behandeln (Fix 2026-07-27, real: −1-€-Stop killte jeden Trade sofort). + if v is not None and self._emergency_min > 0 and v < self._emergency_min: + log.warning(f"Notfall-Stop −{v:.2f} < Minimum {self._emergency_min:.2f} € " + f"(zu eng, würde sofort schließen) → als AUS behandelt") + v = None + self._emergency_loss = v + if v is not None: + self._emergency_remember = v # für die nächste Position merken + else: + self._emergency_remember = 0.0 # „aus" schaltet auch den Auto-Arm ab + log.info(f"Notfall-Stop {('gesetzt: -' + format(v, '.2f')) if v else 'aus'}") + self._save_emergency_state() # Handeingabe neustart-fest (Fix 2026-07-17) + return self._emergency_loss + + def set_takeprofit(self, value) -> float | None: + """Gewinn-Ziel setzen/löschen (Kontowährung, positiver Betrag). None/leer/0 + → aus. Gemerkt für die nächste Position. Rückgabe = aktiver Wert.""" + try: + v = abs(float(value)) if value not in (None, "", "0", 0) else None + if v is not None and v <= 0: + v = None + except (TypeError, ValueError): + v = None + self._takeprofit = v + if v is not None: + self._takeprofit_remember = v + else: + self._takeprofit_remember = 0.0 # „aus" schaltet auch den Auto-Arm ab + log.info(f"Gewinn-Ziel {('gesetzt: +' + format(v, '.2f')) if v else 'aus'}") + self._save_emergency_state() # Handeingabe neustart-fest (Fix 2026-07-17) + return self._takeprofit + + def _in_startup_grace(self) -> bool: + """True in den ersten `_startup_grace_s` s nach Bot-Start — dann schließt der + Bot keinen laufenden Trade selbst (Daten noch nicht stabil nach Neustart).""" + return time.time() < self._no_autoclose_until + + # ── Circuit Breaker (Tagesverlust-Stopp) ───────────────────────────────────── + def _day_realized_cached(self) -> float: + """Realisierter Tages-P&L (heute geschlossene Trades), gecacht ~30 s — + damit die SQLite-Abfrage nicht jeden 1-s-Tick läuft.""" + now = time.time() + if now - self._cb_realized[0] < 30: + return self._cb_realized[1] + try: + v = float((self.history.stats_overview("today") or {}).get("total_pnl") or 0.0) + except Exception: + v = self._cb_realized[1] + self._cb_realized = (now, v) + return v + + def _circuit_halted(self) -> bool: + """True, wenn der Circuit Breaker HEUTE (Berlin) bereits ausgelöst hat → + keine neuen Auto-Trades bis zum nächsten Tag.""" + if self._cb_limit_pct <= 0 or not self._circuit_tripped_date: + return False + return self._circuit_tripped_date == datetime.now(_BERLIN).date() + + def _check_circuit_breaker(self): + """Tagesverlust-Stopp (User-Vorgabe 2026-07-27): erreicht der Tages-P&L + (realisiert heute + offene Position) −`daily_loss_limit_pct`% der Balance, + schließt der Bot die Position und sperrt neue Auto-Trades bis zum nächsten + Tag. Wirkt auch bei gesperrtem Handy. Fail-safe.""" + if self._cb_limit_pct <= 0: + return + if self._in_startup_grace(): # nach Neustart nicht auf halb-init Daten feuern + return + today = datetime.now(_BERLIN).date() + if self._circuit_tripped_date and self._circuit_tripped_date != today: + self._circuit_tripped_date = None # neuer Tag → Reset + log.info("Circuit Breaker zurückgesetzt (neuer Handelstag)") + if self._circuit_tripped_date == today: + return # heute schon ausgelöst + bal = float(self.data.balance or 0.0) + if bal <= 0: + return + ps = self.trader.snapshot() + open_pnl = float(ps.get("pnl") or 0.0) + day_total = self._day_realized_cached() + open_pnl + limit_eur = self._cb_limit_pct / 100.0 * bal + if day_total > -limit_eur: + return + self._circuit_tripped_date = today + log.warning(f"🛑 CIRCUIT BREAKER: Tages-P&L {day_total:+.2f} ≤ -{limit_eur:.2f} € " + f"({self._cb_limit_pct:.0f}% von {bal:.0f}) → Position schließen, " + f"keine Auto-Trades mehr heute") + sym = self.data.symbol or "" + tg = self.cfg["telegram"] + tg_on = (tg.get("enabled", "false").lower() == "true" and tg.get("bot_token")) + if ps.get("ticket"): + err = self.trader.close(reason="circuit_breaker") + if err: + log.error(f"Circuit-Breaker-Close fehlgeschlagen: {err}") + if tg_on: + try: + send_telegram(f"🛑 CIRCUIT BREAKER\n{sym} Tagesverlust {day_total:+.2f} € " + f"≤ -{limit_eur:.2f} € ({self._cb_limit_pct:.0f}% der Balance). " + f"Position geschlossen, keine Auto-Trades mehr heute.", + tg["bot_token"], tg["chat_id"]) + except Exception as e: + log.warning(f"Circuit-Breaker-Telegram: {e}") + + def _check_auto_close(self): + """Server-seitiger Auto-Close (wirkt auch bei gesperrtem Handy): Notfall-Stop + (P&L ≤ −Schwelle) UND Gewinn-mitnehmen (P&L ≥ +Ziel). Beide werden bei einer + NEUEN Position automatisch auf den zuletzt genutzten Wert gesetzt; manuelles + Übersteuern im laufenden Trade bleibt erhalten.""" + ps = self.trader.snapshot() + ticket = ps.get("ticket") + if not ticket: + # Flat-Übergang: hatten wir gerade noch eine Position, merke Zeit+Richtung + # → Squeeze-Wiedereinstiegs-Sperre (gegen Open→Instant-Close-Kaskade). Gilt + # für JEDE Close-Art (Notfall/SL/Time-Stop/manuell); der S/R-Close hat schon + # seinen eigenen Marker, dieser deckt den Rest ab. + if self._last_pos_ticket is not None and self._open_pos_dir: + self._pos_close_ts = time.time() + self._pos_close_dir = self._open_pos_dir + # ⚠ Close-Benachrichtigung nur VORMERKEN, nicht hier senden: der + # Flat-Zustand kommt aus `trader.snapshot()` und kann dem + # DB-Schreiber um einen Tick voraus sein. `_check_close_notify` + # wartet, bis `exit_time` steht — sonst meldete es einen Close + # ohne P&L. + if self._close_notify_min > 0: + self._close_notify_pending = [self._last_pos_ticket, 0] + self._open_pos_dir = None + self._last_pos_ticket = None + self._emergency_loss = None + self._takeprofit = None + return + self._open_pos_dir = "LONG" if ps.get("order_type") == 0 else "SHORT" + if ticket != self._last_pos_ticket: # neue/wiedererkannte Position + self._last_pos_ticket = ticket + if ticket == self._emergency_state_ticket: + # Dieselbe (noch offene) Position nach Neustart/Wiedererkennung → + # persistierten Wert WIEDERHERSTELLEN (inkl. manueller Übersteuerung), + # NICHT auf die pct-Automatik zurücksetzen (Fix 2026-07-17: Neustart + # bei offenem Trade kappte sonst die von Hand gesetzten −25 € auf −19,42). + self._emergency_loss = self._emergency_state_loss + self._takeprofit = self._emergency_state_tp + self._sr_close_min_gain_armed_ticket = ticket # wiederhergestellter Wert + # gilt als final — NICHT auf 1% zurücksetzen + self._emergency_margin_armed_ticket = ticket # dito für den margin-%-Stop + log.info(f"Auto-Close wiederhergestellt (Pos {ticket}): Stop " + f"{('-'+format(self._emergency_loss,'.2f')) if self._emergency_loss else 'aus'} · TP " + f"{('+'+format(self._takeprofit,'.2f')) if self._takeprofit else 'aus'}") + # Trailing wiederherstellen: war es vor dem Neustart AN, wieder aktivieren + # (Fix 2026-07-29: Trailing lebte nur im Speicher → nach jedem Neustart + # aus, die Position lief nur mit Broker-SL, ohne Breakeven/Time-Stop; der + # User musste es manuell einschalten). War es AUS (z. B. manuelles SL/TP + # → deaktiviert), bleibt es aus — der persistierte Zustand entscheidet. + if self._emergency_state_trail and not self.trail.enabled and self.data.symbol: + self.trail.toggle(self.data.symbol) + log.info(f"Trailing wiederhergestellt (Pos {ticket}) nach Neustart") + else: # echt neue Position → armieren + if self._emergency_margin_pct > 0: + pass # wird unten margin-basiert (retry-sicher) gesetzt + elif self._emergency_pct > 0 and self.data.balance: + # % des Kontos (skaliert dynamisch, User-Vorgabe) + self._emergency_loss = round(self._emergency_pct / 100.0 * float(self.data.balance), 2) + elif self._emergency_remember and self._emergency_remember > 0: + self._emergency_loss = float(self._emergency_remember) + if self._takeprofit_remember and self._takeprofit_remember > 0: + self._takeprofit = float(self._takeprofit_remember) + log.info(f"Auto-Close armiert (Pos {ticket}): Stop " + f"{('-'+format(self._emergency_loss,'.2f')) if self._emergency_loss else 'aus'} · TP " + f"{('+'+format(self._takeprofit,'.2f')) if self._takeprofit else 'aus'}") + self._save_emergency_state() + # Gewinn-Close (Mindestgewinn für den S/R-Auto-Close) auf X% des EINSATZES + # dieser Position setzen (Margin kommt frisch aus dem trader.refresh() + # unmittelbar vor diesem Aufruf in _pos_loop). Läuft NICHT nur einmalig beim + # Ticket-Wechsel, sondern jeden Tick erneut, bis es geklappt hat (Fix + # 2026-07-23): war `order_calc_margin` genau im ersten Tick nach dem Öffnen + # noch 0/nicht verfügbar, blieb die Schwelle sonst dauerhaft auf dem STALEN + # Wert der vorherigen Position hängen (`_last_pos_ticket` war da schon + # umgesetzt, der Ticket-Wechsel-Zweig feuert nur 1×) — konnte den Trade zu + # früh (falscher, zu niedriger Alt-Wert) ODER dauerhaft mit falscher Schwelle + # laufen lassen. + if (self._sr_close_min_gain_pct > 0 + and self._sr_close_min_gain_armed_ticket != ticket): + margin = ps.get("margin") or 0.0 + if margin > 0: + self._sr_close_min_gain = round( + self._sr_close_min_gain_pct / 100.0 * margin, 2) + self._sr_close_min_gain_armed_ticket = ticket + self._save_runtime_state() + log.info(f"Gewinn-Close armiert (Pos {ticket}): " + f"+{self._sr_close_min_gain:.2f} ({self._sr_close_min_gain_pct:.0f}% Margin {margin:.2f})") + # Notfall-Stop als % der Margin — gleiches Retry-Muster (margin-Wert kann im + # ersten Tick nach dem Öffnen noch 0 sein). Hat Vorrang vor Balance-%/Fixwert + # (die im "echt neue Position"-Zweig oben bewusst übersprungen wurden, wenn + # `_emergency_margin_pct>0`). + if (self._emergency_margin_pct > 0 + and self._emergency_margin_armed_ticket != ticket): + margin = ps.get("margin") or 0.0 + if margin > 0: + self._emergency_loss = round( + self._emergency_margin_pct / 100.0 * margin, 2) + self._emergency_margin_armed_ticket = ticket + self._save_emergency_state() + log.info(f"Notfall-Stop armiert (Pos {ticket}): " + f"-{self._emergency_loss:.2f} ({self._emergency_margin_pct:.0f}% Margin {margin:.2f})") + # Trailing-Zustand per-Ticket neustart-fest halten: nur bei ÄNDERUNG schreiben + # (kein Datei-I/O je Tick). Fängt auch die Selbst-Deaktivierung des Trailings + # (manuelles SL/TP im Terminal, `_do_modify`) — sonst würde der Neustart ein vom + # User abgeschaltetes Trailing wieder anwerfen. + if bool(self.trail.enabled) != bool(self._emergency_state_trail): + self._save_emergency_state() + if self._in_startup_grace(): # Startup-Schonfrist: armieren ja, schließen nein + return + pnl = ps.get("pnl") + if pnl is None: + return + tg = self.cfg["telegram"] + tg_on = (tg.get("enabled", "false").lower() == "true" and tg.get("bot_token")) + sym = self.data.symbol or "" + + def _do(reason, ok_icon, ok_word, level_txt): + err = self.trader.close(reason=reason) + if err: + log.error(f"{ok_word} fehlgeschlagen: {err}") + if tg_on: + # BYPASS am Gate vorbei: die Position ist hier NICHT geschlossen und + # es braucht eine Handeingabe. Stille waere hier der + # gefaehrlichste Fall. + try: _send_telegram_roh(f"⚠ {ok_word} FEHLGESCHLAGEN\n{sym} P&L {pnl:+.2f} " + f"({level_txt}) — bitte MANUELL schließen! ({err})", + tg["bot_token"], tg["chat_id"]) + except Exception as e: log.warning(f"Auto-Close-Telegram (Fehler): {e}") + return False + if tg_on: + try: send_telegram(f"{ok_icon} {ok_word}\n{sym} bei P&L {pnl:+.2f} " + f"({level_txt}) geschlossen.", tg["bot_token"], tg["chat_id"]) + except Exception as e: log.warning(f"Auto-Close-Telegram: {e}") + return True + + thr = self._emergency_loss + if thr and pnl <= -abs(thr): + log.warning(f"🚨 NOTFALL-CLOSE: P&L {pnl:+.2f} ≤ -{abs(thr):.2f}") + if _do("emergency", "🚨", "Notfall-Close", f"Schwelle -{abs(thr):.2f}"): + self._emergency_loss = None + return + tp = self._takeprofit + if tp and pnl >= abs(tp): + log.info(f"💰 GEWINN-CLOSE: P&L {pnl:+.2f} ≥ +{abs(tp):.2f}") + if _do("takeprofit", "💰", "Gewinn mitgenommen", f"Ziel +{abs(tp):.2f}"): + self._takeprofit = None + return + + def _check_sr_close(self): + """Automatischer S/R-Close (User-Vorgabe): schließt eine Position **im Plus** + selbstständig, wenn die gemessene Bedingung greift (Kurs klebt am gegenüber- + liegenden Level + schwacher Anlauf → Durchbruch ~26 %, Close schlägt Laufen- + lassen; `backtest_srbreak.py`). Nur bei `auto_sr_close=true`, genau 1×/Ticket, + wirkt auch bei gesperrtem Handy. Fail-safe.""" + if not self._auto_sr_close: + return + if self._in_startup_grace(): # Startup-Schonfrist: kein Sofort-Close nach Neustart + return + ps = self.trader.snapshot() + ticket = ps.get("ticket") + if not ticket: + self._sr_close_last_ticket = None + return + if ticket == self._sr_close_last_ticket: # bereits ausgelöst → nicht erneut + return + pnl = ps.get("pnl") + if pnl is None or pnl <= 0: # NUR bei Trade im Plus (User-Vorgabe) + return + # Optionaler Mindestgewinn (User-Vorgabe; Default 0=aus — gemessen ist ohne + # besser): unter der Schwelle wird am Level NICHT geschlossen, Trade läuft. + if self._sr_close_min_gain > 0 and pnl < self._sr_close_min_gain: + return + hint = self._sr_close_hint(self.data.snapshot(), ps, self.wave.snapshot()) + if not hint or not hint.get("close"): # am Level, aber P(break) ≥ Schwelle → laufen lassen + return + self._sr_close_last_ticket = ticket # vor dem Close markieren (kein Doppel) + sym = self.data.symbol or "" + _confirm = hint.get("confirm") + log.info(f"🎯 S/R-AUTO-CLOSE: {sym} P&L {pnl:+.2f} — am Level {hint['level']}, " + f"Durchbruch nur ~{hint['p_break']}% (< {self._sr_close_pbreak:.0%})" + + (f" — Bestätigung erst ab {_confirm}" if _confirm else "") + + " → Gewinn sichern") + err = self.trader.close(reason="sr_close") + tg = self.cfg["telegram"] + tg_on = (tg.get("enabled", "false").lower() == "true" and tg.get("bot_token")) + if err: + log.error(f"S/R-Auto-Close fehlgeschlagen: {err}") + self._sr_close_last_ticket = None # Fehlschlag → nächster Tick erneut + if tg_on: + try: send_telegram(f"⚠ S/R-Close FEHLGESCHLAGEN\n{sym} P&L {pnl:+.2f} " + f"— bitte MANUELL schließen! ({err})", + tg["bot_token"], tg["chat_id"]) + except Exception as e: log.warning(f"S/R-Close-Telegram (Fehler): {e}") + return + # Richtung + Zeit merken → Auto-Squeeze-Cooldown gegen Gleichrichtungs-Roundtrip + self._sr_close_dir = "LONG" if ps.get("order_type") == 0 else "SHORT" + self._sr_close_ts = time.time() + self._sr_close_count += 1 + if tg_on: + try: send_telegram(f"🎯 S/R-Auto-Close\n{sym} bei P&L {pnl:+.2f} geschlossen " + f"— Kurs klebte am Level {hint['level']} mit schwachem Anlauf " + f"(Durchbruch ~{hint['p_break']}% unwahrscheinlich).", + tg["bot_token"], tg["chat_id"]) + except Exception as e: log.warning(f"S/R-Close-Telegram: {e}") + + 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 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.""" + # ⚠ Seit dem Entfernen des Auto-Signal-Pfads (2026-08-12) gibt es nur noch + # den Squeeze. Der Parameter `pfad` bleibt in der Signatur — die + # Guard-Kette ist bewusst EINE Quelle für alle autonomen Wege, und ein + # künftiger zweiter Weg soll sie wieder benutzen, nicht nachbauen. + if not self._auto_squeeze: + return "Auto-Squeeze aus" + skip = self._squeeze_skip_night + if self._circuit_halted(): + return "Circuit-Breaker" + if skip and datetime.now(_BERLIN).hour in self._squeeze_night_hours: + return "Nacht-Fenster" + _bl = self.calendar.blackout() + if _bl: + if self._news_block_logged != _bl["title"]: + self._news_block_logged = _bl["title"] + log.info(f"📅 News-Blackout — kein Auto-Entry: {_bl['title']} " + f"({_bl['impact']}, in {_bl['minutes']:.0f} min)") + return "News-Blackout" + 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 _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" + # ⚠ Seit 2026-08-12 legt nur noch der Squeeze Pending-Orders (der + # Auto-Signal-Pfad ist entfernt). Die `quelle` bleibt trotzdem im + # Marker — ein künftiger zweiter Weg würde sie wieder brauchen, und + # ein Fill ohne bekannte Quelle darf NICHT stillschweigend als + # Squeeze gebucht werden (Deployment-Drift Fall 8). + if quelle != "squeeze": + log.warning(f"Pending-Fill mit unbekannter Quelle {quelle!r} " + f"(Ticket {tk}) — NICHT getaggt") + return + setup = f"SQUEEZE_{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" + self._squeeze_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). + + ⚠⚠ 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") + # ⚠⚠ DER SIGNAL-ZWEIG IST AM 2026-08-12 ENTFERNT (User-Entscheidung). + # Hier lag eine Order am Bestätigungs-Level von `_confirm_breakout`. + # Gemessen negativ auf BEIDEN Ausführungs-Achsen: + # · Markt-Einstieg — negativ in allen 8 Feldern + # (`backtest_auto_signal.py` 30.07., `_norev` 04.08.) + # · ruhende Order, Fill bei BERÜHRUNG — **−0,392/−0,338**, negativ in + # allen 6 Feldern und damit sogar SCHLECHTER als der Markt-Einstieg + # (`backtest_signal_touchfill.py` 06.08.) + # ⚠ Die +0,35/+0,32 aus `backtest_auto_signal_v3.py` galten in Variante + # B (Fill beim Close-Bruch) und waren Look-ahead — der Aufschlag + # betrug 0,77–0,85 R. + # Der Grund ist STRUKTURELL und nicht reparierbar: `_pend` **wandert** + # (neu verankert bei jeder k×ATR-Gegenbewegung, nach Timeout und bei + # Richtungswechsel). Eine Order an einem wandernden Level fängt + # systematisch die Fehlausbrüche ein — „ein Level ist nur dann ein + # Ausführungs-Vorteil, wenn es STILLSTEHT". + # Damit entfällt auch der offene Churn-Defekt (4× setzen/stornieren in + # 76 s bei identischem Level, gefunden 06.08.). + # Sicherung: `.removed_backup/auto_signal_2026-08-12.py` + Git-Historie. + return {}, "" + + def _manage_squeeze_pending(self): + """RUHENDE Stop-Orders an beiden Box-Grenzen, solange der Squeeze `armed` ist. + + WARUM (gemessen `backtest_squeeze_entry.py`, 2026-08-05): der Squeeze hat + seinen gesamten Edge im EINSTIEGSPREIS. Die Market-Order auf dem 5-s-Poll + füllt live im Median 0,275×ATR hinter dem Level (Mittel 0,686, Max 4,3) → + ØR kippt von +0,456 auf −0,158. Eine ruhende Stop-Order deckelt den Preis + konstruktiv: selbst mit 0,20×ATR Fill-Slippage bleibt ØR +0,244/+0,426. + + Beide Seiten werden bestückt (die Box kann nach oben ODER unten brechen); + füllt eine, existiert eine Position → `soll` wird False und die Gegenseite + wird im selben Tick storniert. Das ersetzt ein echtes OCO, das MT5 nicht hat. + """ + if not self._squeeze_pending: + return + sym = self.data.symbol or "" + if not sym: + return + try: + ps = self.trader.snapshot() + atr = ((self.wave.snapshot() or {}).get("pb_feats") or {}).get("atr") or 0.0 + 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. + if ps.get("ticket") or self._in_startup_grace() or atr <= 0: + ziel, quelle = {}, "" + offen = self.trader.pending_orders(sym) + if not ziel: + 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")) + if offen: + self._pending_levels = None + self._pending_quelle = "" + return + # 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: + z = ziel.get(o.type) + if z is not None and abs(o.price_open - z) <= tol and o.type not in vorhanden: + 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 + tag = "SQZ-STOP" if quelle == "squeeze" else "SIG-STOP" + t, err = self.trader.place_stop( + sym, otype, preis, comment=tag, + # 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})") + 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 am Level klebt. + self._squeeze_pending_err = err + 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) + + def _check_auto_squeeze(self): + """Autonomer Entry auf den gemessen-validierten Volatilitäts-Squeeze-Ausbruch + (`wave.squeeze.state == 'active'`), NUR wenn flat. Feuert **1× je Ausbruch** + (Dedup über dir+level; re-armt, sobald der Squeeze klärt). Emergency-Stop + armiert beim Öffnen automatisch (2 % Konto, via `_check_auto_close`). Fail-safe.""" + try: + if self._squeeze_guard() is not None: # aus · Circuit · Nacht · News + return + sq = (self.wave.snapshot() or {}).get("squeeze") or {} + if sq.get("state") != "active": + self._squeeze_last_sig = None # Ausbruch geklärt → re-armen + return + d = sq.get("dir"); lvl = sq.get("level") + if d not in ("LONG", "SHORT") or lvl is None: + return + sig = (d, round(float(lvl), 2)) + # Nacht-Fenster, News-Blackout, Circuit-Breaker und der An/Aus-Schalter + # sitzen jetzt in `_squeeze_guard()` (oben geprüft) — EINE Quelle für + # Pending- und Market-Weg, s. dort. + ps = self.trader.snapshot() + ticket = ps.get("ticket") + if ticket: + pos_dir = "LONG" if ps.get("order_type") == 0 else "SHORT" + if pos_dir == d: + self._squeeze_last_sig = sig # gleiche Richtung → schon dabei + return + # ── Stop-&-Reverse (Default AUS, `auto_squeeze_reverse`): unbelegt + + # dreht am Ausbruch-Extrem (real 2026-07-17 18:55). Ist er aus, bleibt + # der Gegen-Trade unangetastet — Drehen ist dann MANUELLE Entscheidung. + # `_squeeze_last_sig` NICHT setzen → schließt der Gegen-Trade (SL/manuell), + # kann der flat-Entry auf denselben Ausbruch noch feuern. + if not self._squeeze_reverse: + return + if self._in_startup_grace(): # Startup-Schonfrist: kein Reverse-Close nach Neustart + return + # Trade läuft GEGEN den Ausbruch → sofort schließen. Der nächste (flat) + # Tick eröffnet dann in Ausbruchsrichtung über den Entry-Pfad. Dedup über + # reverse_sig (kein Doppel-Close, während die Position noch schließt). + if sig == self._squeeze_reverse_sig: + return + self._squeeze_reverse_sig = sig + sym = self.data.symbol or "" + pnl = ps.get("pnl") + log.info(f"🔄 AUTO-SQUEEZE-REVERSE: {pos_dir}-Position gegen {d}-Ausbruch " + f"@ {lvl} (P&L {pnl:+.2f} EUR) → sofort schließen, dann drehen") + err = self.trader.close(reason="squeeze_reverse") + tg = self.cfg["telegram"] + tg_on = (tg.get("enabled", "false").lower() == "true" and tg.get("bot_token")) + if err: + log.error(f"Auto-Squeeze-Reverse-Close fehlgeschlagen: {err}") + self._squeeze_reverse_sig = None # Fehlschlag → nächster Tick erneut + elif tg_on: + try: send_telegram(f"🔄 Auto-Squeeze-Reverse\n{sym} {pos_dir}-" + f"Position (P&L {pnl:+.2f}) gegen {d}-Ausbruch @ {lvl} " + f"geschlossen — dreht auf {d}.", + tg["bot_token"], tg["chat_id"]) + except Exception as e: log.warning(f"Squeeze-Reverse-Telegram: {e}") + return + + # FLAT → Entry-Pfad + if sig == self._squeeze_last_sig: + return # diesen Ausbruch schon gehandelt + # Cooldown: nicht in GLEICHER Richtung direkt nach einem S/R-Close wieder + # eröffnen — der Close ging von „Level hält" aus, ein sofortiger Gleich- + # richtungs-Entry wäre der widersprüchliche 1-s-Roundtrip (2026-07-17). + # `_squeeze_last_sig` NICHT setzen → nach Ablauf feuert der Ausbruch, falls + # noch aktiv. Gegenrichtung (Reverse) bleibt unberührt (läuft oben). + if (d == self._sr_close_dir + and time.time() - self._sr_close_ts < _SQUEEZE_SR_COOLDOWN_S): + return + # Wiedereinstiegs-Sperre nach JEDEM Close (Notfall/SL/Time-Stop/manuell): + # nicht sofort in GLEICHER Richtung neu eröffnen — verhindert die + # Open→Instant-Close-Kaskade bei zu engem Stop (2026-07-27). `_squeeze_last_sig` + # NICHT setzen → nach Ablauf feuert der Ausbruch, falls noch aktiv. + if (d == self._pos_close_dir + and time.time() - self._pos_close_ts < _SQUEEZE_REENTRY_COOLDOWN_S): + return + # ── NACHJAGD-BREMSE (2026-08-05, gemessen `backtest_squeeze_entry.py`). + # Der Market-Weg ist nur noch der FALLBACK, wenn keine Pending-Order + # gegriffen hat (Broker-Mindestabstand, Squeeze sprang direkt auf + # `active`, Order-Fehler). Gemessen ist ein Einstieg bis rund + # **0,20×ATR** über dem Level in BEIDEN Hälften positiv (ØR +0,244/ + # +0,426); darüber kippt es Richtung −0,158/−0,006. Also: nur bis dahin + # nachkaufen, sonst diesen Ausbruch auslassen. + # ⚠ `_squeeze_last_sig` wird dabei NICHT gesetzt — läuft der Kurs zum + # Level zurück (Ausbruch bleibt `active`), darf der Entry noch kommen. + atr_m5 = ((self.wave.snapshot() or {}).get("pb_feats") or {}).get("atr") or 0.0 + tick = self.data.snapshot() or {} + px = tick.get("ask") if d == "LONG" else tick.get("bid") + if 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._squeeze_chase_logged != sig: + self._squeeze_chase_logged = sig + log.info(f"⏭ Auto-Squeeze übersprungen: Kurs steht schon " + f"{weg:.2f}×ATR über dem Level {lvl} " + f"(Grenze {self._squeeze_max_chase:.2f}) — " + f"gemessen ab hier kein Edge mehr") + return + self._squeeze_last_sig = sig + if isinstance(self._last_rec, dict): + self._last_rec["setup"] = f"SQUEEZE_{d}" + log.info(f"🚀 AUTO-SQUEEZE-ENTRY: {d} @ Ausbruch {lvl} — eröffne selbständig") + err = (self.open_long(source="auto_squeeze") if d == "LONG" + else self.open_short(source="auto_squeeze")) + sym = self.data.symbol or "" + tg = self.cfg["telegram"] + tg_on = (tg.get("enabled", "false").lower() == "true" and tg.get("bot_token")) + if err: + log.error(f"Auto-Squeeze-Entry fehlgeschlagen: {err}") + if tg_on: + try: send_telegram(f"⚠ Auto-Squeeze-Entry FEHLGESCHLAGEN\n" + f"{sym} {d} @ {lvl} — {err}", tg["bot_token"], tg["chat_id"]) + except Exception as e: log.warning(f"Squeeze-Telegram (Fehler): {e}") + return + self._squeeze_entry_count += 1 + if tg_on: + try: send_telegram(f"🚀 Auto-Squeeze-Entry\n{sym} {d} selbständig " + f"eröffnet (Ausbruch aus Kompression @ {lvl}).", + tg["bot_token"], tg["chat_id"]) + except Exception as e: log.warning(f"Squeeze-Telegram: {e}") + except Exception as e: + log.error(f"_check_auto_squeeze: {e}", exc_info=True) + + def set_auto_squeeze(self, on: bool) -> bool: + """Autonomen Squeeze-Entry an/aus (UI/Config). Rückgabe = neuer Zustand.""" + self._auto_squeeze = bool(on) + self._squeeze_last_sig = None + log.info(f"Auto-Squeeze-Entry {'AN' if self._auto_squeeze else 'AUS'}") + self._save_runtime_state() + return self._auto_squeeze + + def set_circuit_breaker(self, on: bool) -> bool: + """Tagesverlust-Stopp an/aus (User-Wunsch 2026-08-19, Dashboard-Schalter). + + ⚠ Der Breaker hat KEINEN eigenen An/Aus-Schalter, sondern lebt in + `_cb_limit_pct` (0 = aus). Ein Toggle muss die Prozentzahl also merken, + sonst waere sie nach einmal Ausschalten fuer immer weg. `_cb_remember` + haelt den letzten Wert > 0; Startwert ist die ini (`daily_loss_limit_pct`, + aktuell 8), Rueckfall 8, falls die ini je auf 0 stand. + + ⚠⚠ WAS ER TUT UND WAS NICHT: er SCHLIESST die offene Position bei + Erreichen des Tageslimits (das wirkt auf ALLE Trades) und sperrt neue + AUTONOME Entries bis zum naechsten Tag (`_squeeze_guard`). Manuelle + Orders sperrt er NICHT — gemessen sind nur 6,1 % der Trades autonom, + die schliessende Haelfte ist also die wirksame. Real ausgeloest am + 17.08. 18:35 (-82,42 <= -81,45 EUR). + """ + if on: + self._cb_limit_pct = float(getattr(self, "_cb_remember", 0.0) or 8.0) + else: + if self._cb_limit_pct > 0: + self._cb_remember = self._cb_limit_pct + self._cb_limit_pct = 0.0 + log.info(f"Circuit-Breaker {'AN' if self._cb_limit_pct > 0 else 'AUS'}" + f" ({self._cb_limit_pct:.0f} %)") + self._save_runtime_state() + return self._cb_limit_pct > 0 + + def _check_adverse15(self): + """15-Minuten-Regel (User-Wunsch 2026-07-30): steht ein **vom Bot eröffneter** + Trade 15 min nach Entry mehr als `adverse_15min_atr`×ATR im Minus → schließen + (der Einstieg war falsch; gemessen: Gewinner nehmen Ø 0,19 $ Hitze, Verlierer + 0,41 $). ⚠ Schwelle GEMESSEN kalibriert = **0,5×ATR**; „sobald im Minus" wäre + die schlechteste Variante (WR 39→31 %). Netto bringt die Regel über beide + Halbjahre ~nichts (Regime-Schutz: hilft im schlechten, kostet im guten) — + bewusst als Sicherheit eingebaut. Greift NICHT bei manuellen Trades (dort + bleibt die Diskretion beim User) und nicht in der Startup-Schonfrist.""" + if self._adverse15_atr <= 0: + return + if self._in_startup_grace(): + return + ps = self.trader.snapshot() + ticket = ps.get("ticket") + if not ticket: + return + if ticket != self._bot_open_ticket: # nur Bot-eröffnete Trades + return + # ⚠ SQUEEZE-TRADES AUSGENOMMEN (gemessen `backtest_adverse15_squeeze.py`, + # 2026-07-31). Die Schwelle 0,5×ATR wurde in `backtest_auto_signal.py` auf + # WAVE-SIGNAL-Entries kalibriert und dann auf ALLE Bot-Trades angewandt — + # wieder ein Kalibrierungs-Bruch. Auf SQUEEZE-Entries schadet die Regel bei + # JEDER Schwelle in BEIDEN Hälften (Δ gegen „ohne Regel": 0,3 → −25/−38 · + # **0,5 → −18/−34** · 0,8 → −12/−39 · 1,2 → −5/−18) — monoton, d. h. die + # Regel selbst ist das Problem, nicht ihre Einstellung. Grund: der Squeeze ist + # runner-abhängig (43 % Treffer, Ertrag im Tail), und ein Schnitt nach 15 min + # trifft genau den Tail. Ohne Regel H1 −54/H2 **+220**, mit 0,5 H1 −72/H2 +186. + # Anlass war der 31.07.: zwei Squeeze-Trades wurden per adverse15 gekappt + # (−13,77 / −28,09 €), danach wären +19,28 bzw. +17,60 € möglich gewesen — + # während dieselbe Regel beim Auto-Signal-Trade desselben Tages 32 € rettete. + if self._bot_open_source == "auto_squeeze": + return + if ticket == self._adverse15_done_ticket: # je Ticket nur 1× prüfen + return + ot = ps.get("open_time") or 0 + if not ot: + return + age = time.time() - float(ot) + if age < 15 * 60: + return # noch keine 15 min alt + self._adverse15_done_ticket = ticket # Prüfzeitpunkt erreicht → 1× bewerten + # Verlust in ATR messen (M5-ATR wie im Backtest, Floor wie live) + try: + atr = float(((self.wave.snapshot() or {}).get("pb_feats") or {}).get("atr") or 0.0) + except Exception: + atr = 0.0 + atr = max(atr, 0.06) + entry = float(ps.get("entry_price") or 0.0) + cur = float(ps.get("cur_price") or 0.0) + if not entry or not cur: + return + d = 1 if ps.get("order_type") == 0 else -1 + adverse_atr = (entry - cur) * d / atr # >0 = läuft gegen die Position + if adverse_atr < self._adverse15_atr: + log.info(f"15-Min-Check (Pos {ticket}): {adverse_atr:+.2f}×ATR gegen die " + f"Position — unter Schwelle {self._adverse15_atr:.2f}, läuft weiter") + return + pnl = ps.get("pnl") + log.warning(f"⏱ 15-MIN-ADVERSE-CLOSE: Pos {ticket} läuft {adverse_atr:.2f}×ATR " + f"gegen die Richtung (≥ {self._adverse15_atr:.2f}) — P&L " + f"{(pnl if pnl is not None else 0):+.2f} → Einstieg war falsch, schließen") + err = self.trader.close(reason="adverse15") + tg = self.cfg["telegram"] + tg_on = (tg.get("enabled", "false").lower() == "true" and tg.get("bot_token")) + if err: + log.error(f"15-Min-Adverse-Close fehlgeschlagen: {err}") + self._adverse15_done_ticket = None # Fehlschlag → nächster Tick erneut + return + self._adverse15_count += 1 + if tg_on: + try: send_telegram(f"⏱ 15-Min-Regel\n{self.data.symbol or ''} nach 15 min " + f"{adverse_atr:.2f}×ATR gegen die Richtung (P&L " + f"{(pnl if pnl is not None else 0):+.2f}) → geschlossen.", + tg["bot_token"], tg["chat_id"]) + except Exception as e: log.warning(f"Adverse15-Telegram: {e}") + + def set_sr_autoclose(self, on: bool) -> bool: + """Automatischen S/R-Close an/aus (UI/Config). Rückgabe = neuer Zustand.""" + self._auto_sr_close = bool(on) + log.info(f"Auto-S/R-Close {'AN' if self._auto_sr_close else 'AUS'}") + self._save_runtime_state() + return self._auto_sr_close + + def set_sr_close_min_gain(self, value) -> float: + """Mindestgewinn (EUR) für den S/R-Auto-Close. 0/leer = aus.""" + try: + v = max(0.0, float(value)) if value not in (None, "") else 0.0 + except (TypeError, ValueError): + v = 0.0 + self._sr_close_min_gain = v + log.info(f"S/R-Close-Mindestgewinn {('+' + format(v, '.2f')) if v > 0 else 'aus'}") + self._save_runtime_state() # neustart-fest (Fix 2026-07-22 — war vergessen) + return self._sr_close_min_gain + + def set_sltp(self, sl=None, tp=None) -> dict: + """Manuelles SL/TP der offenen Position setzen. **Deaktiviert das Trailing** + (User-Vorgabe) — sonst zöge es SL/TP beim nächsten Tick wieder um. Rückgabe + {ok, sl, tp} oder {ok:false, error}.""" + err = self.trader.modify_sltp(sl, tp) + if err: + return {"ok": False, "error": err} + was = self.trail.deactivate() + if self.data.symbol: + self.trader.refresh(self.data.symbol) # Snapshot mit neuen Werten + ps = self.trader.snapshot() + log.info(f"Manuelles SL/TP übernommen — Trailing " + f"{'AUS (war an)' if was else 'war bereits aus'}") + return {"ok": True, "sl": ps.get("sl"), "tp": ps.get("tp"), "trail_was_on": was} + + def _tu_loop(self): + while self._running: + try: + self.tu.fetch() + except Exception as e: + log.warning(f"_tu_loop: {e}") + self._sleep(_TU_S) + + def _news_loop(self): + if self._sleep(6): # kurz warten, bis MT5/Feeds bereit sind + return + while self._running: + try: + self.news.fetch() + except Exception as e: + log.warning(f"_news_loop: {e}") + if self._sleep(self._news_refresh_s): + return + + def _agent_loop(self): + if self._sleep(_AGENT_WARMUP): + return + while self._running: + try: + if self.agent.auto_enabled and self.agent.is_configured(): + ok = self.agent.analyze() + if ok: + adv = self.agent.snapshot().get("advisory") or {} + tf = adv.get("timeframe") + if tf and self._tf_select == "agent": + self._apply_wave_tf(tf) + if self.agent.tg_push: + self._agent_telegram() + snaps = self.agent.snapshot() + self._agent_fail_cnt = (0 if not snaps.get("error") + else min(self._agent_fail_cnt + 1, 5)) + except Exception as e: + log.error(f"_agent_loop: {e}", exc_info=True) + # Backoff bei wiederholten Fehlern (Quota schonen) + interval = min(self.agent.interval_min * (2 ** self._agent_fail_cnt), 120) + if self._sleep(interval * 60): + return + + def _sleep(self, secs: float) -> bool: + """Schläft `secs`, aber in 0.5-s-Schritten unterbrechbar. + Gibt True zurück, wenn die Engine in der Zwischenzeit gestoppt wurde.""" + end = time.time() + secs + while time.time() < end: + if not self._running: + return True + time.sleep(min(0.5, max(0.0, end - time.time()))) + return not self._running + + # ── Analyse (entspricht _do_refresh_analysis im Widget) ──────────────── + def _run_analysis(self): + s = self.data.snapshot() + if self._export_mql5: + self._write_levels_file() + self.trail.set_sr(s.get("sr")) + self.trail.set_trend_angle(s.get("angles", {}).get("M15", 90.0)) + + rec = self.wave.signal() + self._check_close_alert(rec) + + # Empfehlung in die DB loggen (für Tages-Reviews; eigenes Throttling in + # history.log_recommendation → flutet nicht). + try: + adv = (self.agent.snapshot() or {}).get("advisory") or {} + # News-Sentiment mitloggen (2026-07-24, Idee 6): war seit einer Weile + # None — wieder befüllt, damit die Prädiktivität in ein paar Monaten + # messbar ist (analyze_verdict_calibration.py fand H2 nur n=4/6). + try: + _news = float((self.news.sentiment or {}).get("score") or 0.0) + except Exception: + _news = None + self.history.log_recommendation( + signal=rec.get("signal"), score=float(rec.get("score") or 0.0), + conf_pct=int(rec.get("conf_pct") or 0), + angles=s.get("angles") or {}, + reversal=("rev" if "REV" in (rec.get("setup") or "") else None), + ai_sentiment=adv.get("bias"), ai_confidence=adv.get("confidence"), + setup=rec.get("setup"), regime=None, rsi=s.get("rsi_m15"), + news_score=_news, + # Stufe-2-Telemetrie (2026-07-31): WELCHES Gate blockt gerade? + # Ohne das war live nicht feststellbar, warum der Bot zu 93 % + # WARTEN sagt (Backtest-Erwartung ~43 %). + block=rec.get("block")) + except Exception as e: + log.warning(f"log_recommendation: {e}") + + # ── Selbst-Kalibrierung: eine Zeile je EPISODE (2026-08-06) ────────── + # WARUM: die Qualitaet der Empfehlung war bis heute unsichtbar — sie musste + # auf Nachfrage rueckwirkend gemessen werden (`analyze_signal_live.py`: + # 48–51 % Treffer ueber 1.071 Episoden). Dasselbe Muster wie + # `pbreak_predictions` hat den Ausfall des P(break)-Modells LIVE aufgedeckt, + # was kein Backtest konnte. Reine Messung, kein Eingriff in die Logik. + # ⚠ Nur bei RICHTUNGSWECHSEL, nicht je Minute — sonst zaehlt man dieselbe + # Marktlage dutzendfach (der Fehler, den die Episoden-Bildung behebt). + try: + _sig = rec.get("signal") + if _sig in ("LONG", "SHORT") and _sig != getattr(self, "_rec_epi_sig", None): + # ⚠⚠ BUG BEHOBEN 2026-08-11 (Review-Durchgang 4): hier stand + # `s.get("pb_feats")`. `s` ist aber der **MT5Data**-Snapshot + # (Preis/Trend/Konto) — `pb_feats` lebt im **Wave**-Snapshot. + # `s.get(...)` lieferte damit IMMER None → `_atr = 0.0` → die + # Bedingung `_atr > 0` war NIE erfüllt → `rec_outcomes` blieb + # seit dem Einbau (06.08.) bei **0 Zeilen**, also 5 Tage lang + # still tot. Die Selbst-Kalibrierung der Empfehlung hat in + # dieser Zeit nichts gemessen. + # ⚠ Warum es keine Prüfung fing: es ist KEIN NameError (ruff F821 + # sieht nichts), sondern ein falsches dict — syntaktisch + # einwandfrei. Und das umgebende `except` loggt nur auf DEBUG, + # der Logger steht auf INFO. Genau die Fehlerklasse, vor der + # der `wave_snap`-Fallstrick vom 04.08. schon einmal warnte. + # ✅ Aufgefallen ist es NUR, weil die Tabelle in Durchgang 4 mit + # 0 Zeilen neben ihren Schwestern stand (m15_states 3.143, + # cone_checks 263, pbreak_predictions 3.775). + _ws = self.wave.snapshot() or {} + _atr = float((_ws.get("pb_feats") or {}).get("atr") or 0.0) + _px = float(s.get("bid") or 0.0) + # ⚠⚠ DIAGNOSE (2026-08-12): der Fix vom 11.08. hat NICHT gewirkt — + # nach 10 Episoden standen weiter 0 Zeilen. Warum, war von + # aussen nicht feststellbar, weil der Fehlschlag NUR auf DEBUG + # ging und der Logger auf INFO steht. Genau die Blindheit, die + # den Ausfall fuenf Tage getragen hat. Beide Zweige melden + # sich jetzt auf INFO — es feuert nur bei einem echten + # Richtungswechsel, also selten. + if not (_atr > 0 and _px > 0): + log.info(f"rec_outcome NICHT geschrieben: atr={_atr} px={_px} " + f"sig={_sig} (pb_feats={'da' if _ws.get('pb_feats') else 'FEHLT'})") + if _atr > 0 and _px > 0 and self.history: + # ⚠⚠ ZWEITER BUG, gefunden 2026-08-12 im Log: hier stand + # `self.wave.timeframe_name` — das Attribut gibt es NICHT + # (`AttributeError`, im `except` auf DEBUG verschluckt). + # `rec["tf"]` ist leer, also lief es IMMER in den Fallback. + # Der `pb_feats`-Fix vom 11.08. war also richtig — er kam + # an der ersten Sperre vorbei und legte diese zweite frei. + # ⚠ Lehre: nach einem Fix nicht nur „schreibt es jetzt?" + # pruefen, sondern das LOG lesen. Die Zeilen standen seit + # Stunden da; ich hatte nur auf INFO gefiltert. + _rid = self.history.log_rec_outcome( + signal=_sig, conf_pct=int(rec.get("conf_pct") or 0), + tf=str(rec.get("tf") or self._wave_tf_label or ""), + price=_px, atr=_atr) + log.info(f"rec_outcome #{_rid}: {_sig} @ {_px} atr={_atr}") + if _sig in ("LONG", "SHORT", "WARTEN"): + # WARTEN re-armt: die naechste Richtung ist eine NEUE Episode. + self._rec_epi_sig = _sig if _sig != "WARTEN" else None + except Exception as e: + # ⚠ war `log.debug` — damit blieb ein Fehlschlag unsichtbar, obwohl + # der Logger auf INFO steht. Die Ausnahme feuert nur bei einem + # Richtungswechsel, also selten; WARNING ist hier kein Lärm. + log.warning(f"log_rec_outcome: {e!r}") + + # Verdict-Stimmen-Logging (2026-07-24, Idee 2b): Modul-Votes 1×/min in die + # DB (`verdict_votes`), damit die UNBELEGTEN Stimmen (KI-Copilot, Elliott) + # nach ein paar Wochen datenbasiert bewertet werden können (behalten / + # Gewicht senken / raus wie TU). Erste Messung (analyze_verdict_calibration. + # py, 2026-07-24): Copilot nicht robust prädiktiv, n aber klein → sammeln. + try: + vd = self._verdict(s, rec, self.wave.snapshot(), self.tu.snapshot(), + self.agent.snapshot(), self.elliott.snapshot(), + self.patterns.snapshot()) + vv = {v["name"]: v for v in vd.get("votes") or []} + def _v(name, key="vote"): + return (vv.get(name) or {}).get(key, 0) + self.history.log_verdict_votes( + headline=vd.get("headline") or "WARTEN", + conf=int(vd.get("conf") or 0), bias=float(vd.get("bias") or 0.0), + tf=vd.get("tf") or "", wave=_v("Welle"), m30=_v("M30"), h1=_v("H1"), + ki=_v("KI-Copilot"), ki_w=float(_v("KI-Copilot", "weight") or 0.0), + elliott=_v("Elliott"), elliott_w=float(_v("Elliott", "weight") or 0.0), + squeeze=_v("Squeeze"), news_score=_news, + # Die drei ab 2026-07-30 ergänzten Stimmen MITLOGGEN — nur so ist in + # ein paar Wochen datenbasiert entscheidbar, ob sie bleiben (Muster: + # Kontrolltest zeigt kleinen Zusatz) bzw. ob die UNGEMESSENEN + # Orderbuch-/Liq-Trend-Stimmen überhaupt Prädiktivität haben. + pattern=_v("Muster"), pattern_w=float(_v("Muster", "weight") or 0.0), + orderbook=_v("Orderbuch"), + orderbook_w=float(_v("Orderbuch", "weight") or 0.0), + liqtrend=_v("Liq-Trend"), + liqtrend_w=float(_v("Liq-Trend", "weight") or 0.0)) + except Exception as e: + log.debug(f"log_verdict_votes: {e}") + + # Kontext für Trade-Logging (set_open_context beim Öffnen) + # ── Voller Entscheidungszustand beim Einstieg (Stufe 1 „Manuelle Trades", + # 2026-08-04). Bis dahin wurden 5 Felder gespeichert, davon zwei fast nie + # befüllt — die Marktlage eines manuellen Einstiegs war damit NICHT + # rekonstruierbar. Alles hier ist bereits berechnet (Snapshot-Teile), es + # wird nur eingesammelt. Reine Telemetrie, kein Verhalten. + _ctx = {} + try: + # ⚠ `wave_snap` existiert in dieser Methode NICHT (anders als in + # `snapshot()`) — hier den Snapshot selbst holen. Ein NameError wäre vom + # `except` unten still geschluckt worden und hätte den Kontext dauerhaft + # leer gelassen, ohne dass es jemand merkt. + _ws = self.wave.snapshot() or {} + _bid = s.get("bid") + _atr5 = (_ws.get("pb_feats") or {}).get("atr") + _sh = self._last_sr_close_hint or {} + _sa = self._last_stop_approach or {} + _dl = self._draw_levels() or {} + _res = (_dl.get("res") or [None])[0] + _sup = (_dl.get("sup") or [None])[0] + _st = (self.structure.snapshot() or {}).get("trend") + _sq = (_ws.get("squeeze") or {}).get("state") + _ses = (session_state() or {}).get("phase") + _cone = self._last_cone or {} + _c120 = next((l for l in (_cone.get("levels") or []) + if l.get("band") == 80 and l.get("minutes") == 120), None) + _ctx = { + "p_break_target": _sh.get("p_break"), + "p_break_stop": _sa.get("p_break"), + "dist_res_atr": (round((_res - _bid) / _atr5, 2) + if (_res and _bid and _atr5) else None), + "dist_sup_atr": (round((_bid - _sup) / _atr5, 2) + if (_sup and _bid and _atr5) else None), + "structure": _st, + "squeeze": _sq, + "htf_trend": _ws.get("htf_trend"), + "h1_trend": _ws.get("h1_trend"), + "spread_atr": (round(float(s.get("spread") or 0) / _atr5, 3) + if _atr5 else None), + "session": _ses, + "block_reason": rec.get("block"), + "conf_pct": rec.get("conf_pct"), + "atr_m5": (round(_atr5, 4) if _atr5 else None), + # `vd` entsteht oben in einem try — bei einem Fehler dort wäre es + # gar nicht definiert. `locals()`-Guard statt NameError. + "bias": (locals().get("vd") or {}).get("bias"), + # Position im 120-min-Kegel: 0 = unteres Band, 1 = oberes + "cone_pos": (round((_bid - _c120["lo"]) + / max(_c120["hi"] - _c120["lo"], 1e-9), 3) + if (_c120 and _bid) else None), + "hour": datetime.now(_BERLIN).hour, + } + except Exception as e: + log.debug(f"open-context: {e}") + self._last_rec = { + "rec_signal": rec.get("signal"), + "rec_score": rec.get("score"), + "ai_sentiment": None, + "ai_confidence": None, + "setup": rec.get("setup"), + "regime": None, + "rsi": s.get("rsi_m15"), + "news_score": _news, + "ctx": _ctx, + } + + 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 + # ── NACHRANG gegenüber dem S/R-Close (gemessen `backtest_exit_combo.py`, + # 2026-07-31): liegt ein S/R-ZIEL in Reichweite (≤0,6×ATR, dieselbe + # Schwelle wie der Live-Hinweis), hat der **validierte** Exit Vorrang und + # der Flip hält sich zurück. Grund: der Flip nahm dem S/R-Close messbar + # Trades weg (535→507 in H1, 284→252 in H2 = 5–11 %) — und zwar genau + # die, für die dieser validiert ist. Mit Nachrang ist es in BEIDEN + # Hälften besser als ohne (H1 +62 statt +56, H2 −49 statt −50). + # `auto_flip_close_subordinate=false` schaltet den Nachrang ab. + if self._flip_close_subordinate: + try: + dl = self._draw_levels() + tgt = (dl.get("res") or [None])[0] if is_long \ + else (dl.get("sup") or [None])[0] + if tgt is not None and abs(tgt - bid) <= 0.6 * max(atr, 0.06): + return # S/R-Ziel in Reichweite → Vorrang dort + except Exception: + pass + 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. + Auch bei gesperrtem Handy. (S/R-Ziel-Alarm entfernt — stupste zum verfrühten + Gewinner-Kappen; den Gewinner führt das Trailing.)""" + ps = self.trader.snapshot() + ticket = ps.get("ticket") + if not ticket: + return # keine Position → kein Alarm; Cooldown NICHT zurücksetzen + # (ein kurz flackerndes Ticket darf den Cooldown nicht killen) + if ticket != self._close_alert_ticket: + self._close_alert_ticket = ticket + self._close_alert_ts = 0.0 # NUR bei echter neuer Position sofort melden + is_long = ps.get("order_type") == mt5.ORDER_TYPE_BUY + sig = rec.get("signal") + pnl = ps.get("pnl") or 0.0 + + # Close-Alarm NUR bei Signal-FLIP gegen die Position (echter Wende-Grund). + # Der frühere S/R-Ziel-Alarm wurde entfernt — er stupste zum verfrühten + # Kappen der Gewinner (Verlust-Schiefe); den Gewinner führt das Trailing. + reason = None + if pnl > 0: # Close-Alarm NUR bei Trade im Plus + if (is_long and sig == "SHORT") or (not is_long and sig == "LONG"): + reason = f"Empfehlung dreht auf {sig} ({rec.get('conf_pct')}%) — Gewinn sichern" + + if not reason: + return + if time.time() - self._close_alert_ts < _CLOSE_ALERT_COOLDOWN_S: + return # innerhalb des 5-min-Cooldowns — kein Spam + self._close_alert_ts = time.time() + self._close_alert_count += 1 + log.info(f"🔔 Flip-Close-Alarm #{self._close_alert_count}: " + f"{self.data.symbol or ''} {'LONG' if is_long else 'SHORT'} " + f"P&L {pnl:+.2f} — {reason}") + tg = self.cfg["telegram"] + if tg.get("enabled", "false").lower() == "true" and tg.get("bot_token"): + try: + send_telegram( + f"🔔 CLOSE-Signal\n" + f"{self.data.symbol or ''} — offene " + f"{'LONG' if is_long else 'SHORT'}-Position (P&L {pnl:+.2f})\n" + f"{reason} — Schließen erwägen.", + tg["bot_token"], tg["chat_id"]) + except Exception as e: + log.warning(f"Close-Telegram: {e}") + + def _apply_wave_tf(self, tf_label: str, source: str = ""): + """Wellen-Timeframe übernehmen (Wave/Elliott/Trailing koppeln). + Ein laufender Trade behält seinen eingefrorenen TF.""" + tf_label = (tf_label or "").upper().strip() + const = _ATR_TF_MAP.get(tf_label) + if const is None or tf_label == self._wave_tf_label: + return + self._wave_tf_label = tf_label + self.wave.set_timeframe(const) + self.elliott.set_timeframe(const) + self.trail.set_atr_tf_override(const) + if self.data.symbol: + threading.Thread( + target=lambda: self.wave.refresh_market(self.data.symbol), + daemon=True).start() + log.info(f"Wellen-Timeframe → {tf_label}{(' ('+source+')') if source else ''}") + # Nur im Speicher merken — NICHT in die oil_widget_config.ini schreiben: + # save_config() schreibt die ganze (Secrets-)Datei neu und verliert dabei + # alle Kommentare; bei jedem Heuristik-Switch wäre das ständiger Churn. + # Beim Neustart wählt die Heuristik den TF binnen einer Minute ohnehin neu. + try: + self.cfg["trading"]["atr_tf"] = tf_label + except Exception: + pass + + # ── TF-Heuristik: TF mit dem klarsten handelbaren EMA-Trend wählen ──────── + _TF_BIAS = 0.10 # höhere TF leicht bevorzugen (Stabilität): weight = 1+rank*bias + + def _choose_tf(self, sym: str): + """Bewertet die erlaubten TF nach Trendklarheit (|EMA12-EMA50|/ATR, nicht + überdehnt), bevorzugt höhere TF leicht und respektiert tf_min. + Gibt (beste_TF, scores) zurück.""" + order = list(_ATR_TF_MAP.keys()) # M1..H1 + min_rank = order.index(self._tf_min) if self._tf_min in order else order.index("M5") + max_rank = order.index(self._tf_max) if self._tf_max in order else len(order) - 1 + scores: dict = {} + best_lbl, best_score = None, 0.0 + with mt5_lock(timeout=2) as got: + if not got: + return None, scores + for rank, (lbl, tf) in enumerate(_ATR_TF_MAP.items()): + if rank < min_rank or rank > max_rank: # außerhalb [tf_min … tf_max] + scores[lbl] = 0.0; continue + r = mt5.copy_rates_from_pos(sym, tf, 0, 80) + if r is None or len(r) < _EMA_SLOW + 5: + scores[lbl] = 0.0; continue + h = [float(b["high"]) for b in r] + l = [float(b["low"]) for b in r] + c = [float(b["close"]) for b in r] + atr = _wr_atr(h, l, c) + if not atr or atr <= 0: + scores[lbl] = 0.0; continue + ef = _ema_last(c, _EMA_FAST); es = _ema_last(c, _EMA_SLOW) + sep = abs(ef - es) / atr + stretch = abs(c[-1] - es) / atr + raw = sep if (sep >= _TREND_DEADBAND and stretch <= _STRETCH_MAX) else 0.0 + score = raw * (1.0 + rank * self._TF_BIAS) # höhere TF leicht bevorzugt + scores[lbl] = round(score, 2) + if score > best_score: + best_score, best_lbl = score, lbl + return best_lbl, scores + + def _tf_loop(self): + if self._tf_select != "heuristic": + return # feste TF oder Agent steuert → keine Heuristik + if self._sleep(8): + return + while self._running: + try: + if self.data.symbol: + best, scores = self._choose_tf(self.data.symbol) + cur = self._wave_tf_label + # ── Mindest-Verweildauer (Fix 2026-07-31) ──────────────────── + # Die 1,2×-Hysterese unten ist WIRKUNGSLOS, sobald der Score der + # aktuellen TF auf exakt 0,0 fällt — und genau das tut er, sobald + # sie überdehnt ist (`_choose_tf`). Im Trend kippt M5 dadurch + # regelmäßig raus (Score 0,00) und nach dem nächsten Rücksetzer + # wieder rein (1,93): real 33 Wechsel/Tag, Median-Abstand 5 min. + # Zusammen mit dem `_pend`-Reset (dort gefixt) hielt das die + # Breakout-Bestätigung dauerhaft offen → 93 % WARTEN live gegen + # ~43 % Backtest-Erwartung. Die Verweildauer gibt einer TF Zeit, + # ihr Signal überhaupt fertig zu bestätigen. 0 = aus. + now = time.time() + dwell_ok = (self._tf_min_dwell_s <= 0 or + (now - self._tf_last_switch) >= self._tf_min_dwell_s) + # Hysterese: nur wechseln, wenn klar besser (kein Flattern) + if dwell_ok and best and best != cur and \ + scores.get(best, 0) > scores.get(cur, 0) * 1.2: + self._tf_last_switch = now + self._apply_wave_tf(best, source=f"Heuristik {scores}") + except Exception as e: + log.warning(f"_tf_loop: {e}") + if self._sleep(60): + return + + def _load_report_state(self): + """Letzte Report-Versanddaten laden (überlebt Neustarts, s. __init__).""" + try: + import json + with open(self._report_state_path, encoding="utf-8") as f: + st = json.load(f) + self._daily_last_run = st.get("daily") or None + wk = st.get("weekly") + self._weekly_last_run = tuple(wk) if wk else None + except FileNotFoundError: + pass + except Exception as e: + log.warning(f"report_state laden: {e}") + + def _save_report_state(self): + try: + import json + with open(self._report_state_path, "w", encoding="utf-8") as f: + json.dump({"daily": self._daily_last_run, + "weekly": list(self._weekly_last_run) + if self._weekly_last_run else None}, f) + except Exception as e: + log.warning(f"report_state speichern: {e}") + + def _load_runtime_state(self): + """UI-gesetzte Einstellungen laden (überleben Neustarts). Nur Keys, die + auch in der Datei stehen, überschreiben die ini-Defaults.""" + try: + import json + with open(self._runtime_state_path, encoding="utf-8") as f: + st = json.load(f) + if "auto_squeeze" in st: + self._auto_squeeze = bool(st["auto_squeeze"]) + # ⚠ `auto_signal` wird seit 2026-08-12 NICHT mehr gelesen (Pfad + # entfernt). Ein Altbestand in der Datei schadet nicht — er wird + # beim naechsten Schreiben ohnehin nicht mehr mitgeschrieben. + if "auto_sr_close" in st: + self._auto_sr_close = bool(st["auto_sr_close"]) + if "sr_close_min_gain" in st: + self._sr_close_min_gain = max(0.0, float(st["sr_close_min_gain"] or 0)) + if "manual_margin" in st: + from core.config import set_manual_margin as _set + self._manual_margin = _set(st["manual_margin"] or 0) + if "cb_remember" in st: + self._cb_remember = float(st["cb_remember"] or 8.0) + if "cb_limit_pct" in st: + self._cb_limit_pct = max(0.0, float(st["cb_limit_pct"] or 0)) + if "margin_pct" in st: + from core.config import set_margin_buffer as _setp, get_margin_buffer + _setp(float(st["margin_pct"] or 0) or 95.0) + self._margin_pct = round(get_margin_buffer() * 100.0, 1) + if "cone_skalen" in st: + # ⚠ JSON liefert String-Schluessel → auf int zurueckholen, sonst + # findet `cone.build` sie nicht (es sucht nach `bars` als int). + # ⚠ Der Block MUSS im `try` stehen: der erste Entwurf landete + # hinter den except-Klauseln, wo `st` gar nicht mehr existiert. + self._cone_skalen = {int(k): float(v) + for k, v in (st["cone_skalen"] or {}).items()} + log.info(f"Runtime-State geladen: BRK={'AN' if self._auto_squeeze else 'aus'} · " + f"S/R-Close={'AN' if self._auto_sr_close else 'aus'} · " + f"MinGewinn={self._sr_close_min_gain:+.2f} · " + f"Einsatz={'auto' if not getattr(self, '_manual_margin', 0) else f'{self._manual_margin:.2f}'}" + f" · Einsatz-%={getattr(self, '_margin_pct', 0):.0f}") + except FileNotFoundError: + pass + except Exception as e: + log.warning(f"runtime_state laden: {e}") + + def _save_runtime_state(self): + try: + import json, os + tmp = self._runtime_state_path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump({"auto_squeeze": self._auto_squeeze, + "auto_sr_close": self._auto_sr_close, + "sr_close_min_gain": self._sr_close_min_gain, + "manual_margin": getattr(self, "_manual_margin", 0.0), + "margin_pct": getattr(self, "_margin_pct", 0.0), + # Circuit-Breaker als PROZENTZAHL (nicht bool): + # 0 = aus. So ueberlebt auch die Hoehe den Neustart. + "cb_limit_pct": getattr(self, "_cb_limit_pct", 0.0), + "cb_remember": getattr(self, "_cb_remember", 8.0), + # Kegel-ACI-Skalen (2026-08-08): ohne Persistenz waere + # die muehsam erlernte Kalibrierung nach jedem Neustart + # weg und liefe von 1,0 neu an. ⚠ JSON-Schluessel sind + # Strings — `cone.build` und der ACI-Schritt lesen + # deshalb beide Formen (int UND str). + "cone_skalen": {str(k): v for k, v in + (getattr(self, "_cone_skalen", None) + or {}).items()}}, f) + os.replace(tmp, self._runtime_state_path) + except Exception as e: + log.warning(f"runtime_state speichern: {e}") + + def _load_emergency_state(self): + """Aktiven Notfall-Stop/Take-Profit + zugehöriges Ticket laden (überlebt + Neustarts). Wird in `_check_auto_close` nur angewandt, solange dasselbe + Ticket noch offen ist — sonst armiert die neue Position normal.""" + try: + import json + with open(self._emergency_state_path, encoding="utf-8") as f: + st = json.load(f) + self._emergency_state_ticket = st.get("ticket") + self._emergency_state_loss = st.get("loss") + self._emergency_state_tp = st.get("tp") + self._emergency_state_trail = st.get("trail") + except FileNotFoundError: + pass + except Exception as e: + log.warning(f"emergency_state laden: {e}") + + def _save_emergency_state(self): + """Notfall-Stop/TP + Ticket atomar persistieren (bei Auto-Arm UND manuellem + Set) — damit ein Neustart/Wiedererkennen die Schwelle nicht auf die Automatik + zurücksetzt (Fix 2026-07-17).""" + try: + import json, os + tmp = self._emergency_state_path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump({"ticket": self._last_pos_ticket, + "loss": self._emergency_loss, + "tp": self._takeprofit, + "trail": bool(self.trail.enabled)}, f) + os.replace(tmp, self._emergency_state_path) + self._emergency_state_ticket = self._last_pos_ticket + self._emergency_state_loss = self._emergency_loss + self._emergency_state_tp = self._takeprofit + self._emergency_state_trail = bool(self.trail.enabled) + except Exception as e: + log.warning(f"emergency_state speichern: {e}") + + def _daily_loop(self): + """Holt 1×/Tag ab 07:30 Berlin die Tages-Level (Web-Recherche). Fail-safe: + bei Fehlschlag bis zu 3 Versuche, dann Tag abhaken (alte Level bleiben).""" + from datetime import datetime + from zoneinfo import ZoneInfo + berlin = ZoneInfo("Europe/Berlin") + if self._sleep(10): + return + while self._running: + try: + now = datetime.now(berlin) + today = now.date().isoformat() + due = (self._daily_last_run != today + and (now.hour, now.minute) >= (7, 30)) + if due and self.data.symbol: + price = (self.data.snapshot() or {}).get("bid") + if self.daily.is_configured() and price: + self.daily.run(price) # best-effort, fail-safe + ok = self._send_daily_report() # Telegram + E-Mail + if ok or self._daily_fails >= 2: + self._daily_last_run = today; self._daily_fails = 0 + self._save_report_state() + else: + self._daily_fails += 1 + # B4-Wochenreport: montags ab 07:30, 1×/Woche (Track B) + wk = now.isocalendar()[:2] + if (now.weekday() == 0 and (now.hour, now.minute) >= (7, 30) + and self._weekly_last_run != wk): + if self._send_weekly_report(): + self._weekly_last_run = wk + self._save_report_state() + except Exception as e: + log.warning(f"_daily_loop: {e}") + if self._sleep(120): + return + + def _send_daily_report(self) -> bool: + """Tagesreport = heutige Web-Prognose + Vortags-Bilanz → Telegram + + E-Mail (trading@hocks.eu). True, wenn min. ein Kanal sendete. Fail-safe.""" + from datetime import datetime, timedelta + try: + st = self.history.stats_overview("yesterday") or {} + except Exception as e: + log.warning(f"daily stats: {e}"); st = {} + n = st.get("n_trades", 0); pnl = st.get("total_pnl", 0.0) or 0.0 + wr = st.get("winrate", 0.0) or 0.0; pf = st.get("profit_factor") + cur = (self.data.snapshot() or {}).get("currency") or "EUR" + yday = (datetime.now() - timedelta(days=1)).strftime("%d.%m.%Y") + pf_s = f"{pf:.2f}" if pf else "—" + + dl = self.daily.snapshot() + if dl.get("support") is not None: + fc = (f"Bias {dl['bias']} · Support {dl['support']:.2f} · " + f"Widerstand {dl['res_lo']:.2f}–{dl['res_hi']:.2f}\n{dl.get('summary','')}") + else: + fc = f"nicht verfügbar ({dl.get('error') or '—'})" + + # ── Systemzustand (2026-08-11) ────────────────────────────────────── + # ⚠⚠ Der Tagesreport ist der einzige Kanal, der nachweislich JEDEN Tag + # ankommt (Telegram-Log lückenlos `ok`). Die Wächter kannten den + # Zustand längst — er erreichte nur niemanden. Details und Beleg in + # `_system_status()`. + try: + zust = self._system_status() + except Exception as e: + zust = [f"⚠ Systemzustand nicht ermittelbar ({e})"] + zust_txt = ("\n\n⚙ Systemzustand:\n" + "\n".join(zust)) if zust else "" + zust_html = ("

⚙ Systemzustand

" + + "
".join(zust) + "

") if zust else "" + + txt = (f"📊 Oil Tagesreport\n\n" + f"Heute — Prognose (Web):\n{fc}\n\n" + f"Gestern ({yday}):\n" + f"{n} Trades · WR {wr:.0f}% · PnL {pnl:+.2f} {cur} · PF {pf_s}" + f"{zust_txt}") + html = (f"

Oil Tagesreport

" + f"

Heute — Prognose (Web)

{fc.replace(chr(10), '
')}

" + f"

Gestern ({yday})

{n} Trades · Trefferquote {wr:.0f} % · " + f"PnL {pnl:+.2f} {cur} · Profit-Faktor {pf_s}

" + f"{zust_html}" + f"

Prognose ist Dritt-Kontext " + f"(Web-Recherche), kein Handelssignal.

") + + sent = False + tg = self.cfg["telegram"] + if tg.get("enabled", "false").lower() == "true" and tg.get("bot_token"): + try: + send_telegram(txt, tg["bot_token"], tg["chat_id"]); sent = True + except Exception as e: + log.warning(f"daily telegram: {e}") + if self.cfg.has_section("graph"): + report_to = (self.cfg["graph"].get("report_to") + or "trading@hocks.eu, axel@hocks.eu") + if send_graph_mail(self.cfg["graph"], f"Oil Trading Report {yday}", + html, report_to): + sent = True + log.info(f"Tagesreport gesendet: {sent}") + return sent + + def _send_weekly_report(self) -> bool: + """B4-Wochenreport (Track B) → E-Mail + Telegram. Hält Live-Stats gegen die + Backtest-Erwartung, flaggt Leckagen. Fail-safe: kein Kanal/Fehler → False.""" + from datetime import datetime + from html import escape + try: + from weekly_review import build_report + # M5 für den Squeeze-Monitor unter mt5_lock holen (kein shutdown!) und + # durchreichen — so ruft build_report selbst keine MT5-Funktion auf. + m5 = None + try: + with mt5_lock(timeout=5) as got: + if got and self.data.symbol: + rr = mt5.copy_rates_from_pos(self.data.symbol, mt5.TIMEFRAME_M5, 0, 30*288+30) + if rr is not None and len(rr): + pt = mt5.symbol_info(self.data.symbol).point + m5 = {"H": [float(b["high"]) for b in rr], "L": [float(b["low"]) for b in rr], + "C": [float(b["close"]) for b in rr], + "SP": [float(b["spread"]) * pt for b in rr]} + except Exception as e: + log.debug(f"weekly m5: {e}") + text = build_report(m5=m5) + except Exception as e: + log.warning(f"weekly build: {e}"); return False + kw = datetime.now().isocalendar()[1] + subject = f"Oil Wochenreport (B4) — KW{kw}" + html = ("

Oil Wochenreport (B4)

" + f"
"
+                f"{escape(text)}
" + "

Live-Stats vs. Backtest-Erwartung " + "(Track B/B4). Bei [!]: Ursache trennen (Regime/Disziplin/Sizing), nicht " + "am Signal drehen.

") + sent = False + tg = self.cfg["telegram"] + if tg.get("enabled", "false").lower() == "true" and tg.get("bot_token"): + try: + send_telegram(f"📈 Wochenreport (B4) KW{kw}\n
{escape(text)}
", + tg["bot_token"], tg["chat_id"]); sent = True + except Exception as e: + log.warning(f"weekly telegram: {e}") + if self.cfg.has_section("graph"): + report_to = (self.cfg["graph"].get("report_to") + or "trading@hocks.eu, axel@hocks.eu") + try: + if send_graph_mail(self.cfg["graph"], subject, html, report_to): + sent = True + except Exception as e: + log.warning(f"weekly mail: {e}") + log.info(f"Wochenreport gesendet: {sent}") + return sent + + # ── Telegram-Callbacks (UI-frei) ─────────────────────────────────────── + def _on_trail_event(self, event: str, volume: float, price: float, profit: float): + tg = self.cfg["telegram"] + if not (tg.get("enabled", "false").lower() == "true" and tg.get("bot_token")): + return + try: + if event == "partial": + send_telegram( + f"💰 Teil-Exit (Partial TP)\n" + f"{self.data.symbol or ''} · {volume:.2f} L @ {price:.3f}\n" + f"Gewinn-Distanz {profit:+.3f} — Rest läuft mit Trailing weiter", + tg["bot_token"], tg["chat_id"]) + elif event == "adverse": + send_telegram( + f"🛑 Früh-Ausstieg\n" + f"{self.data.symbol or ''} · {volume:.2f} L @ {price:.3f}\n" + f"Trade lief {abs(profit):.3f} gegen den Einstieg — geschlossen", + tg["bot_token"], tg["chat_id"]) + except Exception as e: + log.warning(f"Trail-Event-Telegram: {e}") + + def _agent_telegram(self): + snap = self.agent.snapshot() + adv = snap.get("advisory") + if not adv: + return + tg = self.cfg["telegram"] + if tg.get("enabled", "false").lower() == "true" and tg.get("bot_token"): + try: + risks = "\n".join(f"• {r}" for r in adv.get("risks", [])) + send_telegram( + f"🧠 KI-Copilot: {adv['bias']} ({adv['confidence']}%)\n" + f"{adv['headline']}\n\n{adv['reasoning']}" + + (f"\n\nRisiken\n{risks}" if risks else ""), + tg["bot_token"], tg["chat_id"]) + except Exception as e: + log.warning(f"Agent-Telegram: {e}") + + # ══════════════════════════════════════════════════════════════════════ + # TRADE-AKTIONEN (für die Trade-Endpoints in server.py — Schritt 3) + # ══════════════════════════════════════════════════════════════════════ + def open_long(self, source: str = "manuell") -> str | None: + return self._open(self.trader.open_long, "LONG", source) + + def open_short(self, source: str = "manuell") -> str | None: + return self._open(self.trader.open_short, "SHORT", source) + + def _open(self, fn, want: str, source: str = "manuell") -> str | None: + sym = self.data.symbol + if not sym: + return "Kein Symbol verbunden" + # KEIN harter Block mehr: Handel ohne passende Empfehlung ist erlaubt — + # das Frontend warnt + lässt bestätigen (User-Entscheidung). Hier nur + # protokollieren, WER handelt und ob gegen die aktuelle Empfehlung. + # ⚠ `source` korrekt attribuieren: autonome Auto-Squeeze-Orders laufen + # durch denselben Pfad — vorher schrieb er fälschlich „vom User bestätigt". + sig = (self.wave.signal() or {}).get("signal", "WARTEN") + if source == "auto_squeeze": + log.info(f"[ORDER] {want} — autonom vom Auto-Squeeze eröffnet " + f"(Signal={sig})") + elif source == "auto_signal": + log.info(f"[ORDER] {want} — autonom auf die Empfehlung eröffnet " + f"(Signal={sig})") + elif sig != want: + log.info(f"[ORDER] {want} ohne passende Empfehlung (Signal={sig}) " + f"— vom User bestätigt, wird eröffnet") + self.trader.set_open_context(**self._last_rec) + # ⚠ SL-ATR-Zeitebene quellen-abhaengig (2026-08-05, User-Entscheidung nach + # `backtest_sl_basis.py`): SQUEEZE bekommt **M5**, alles andere bleibt bei + # M15. Begruendung: auf Squeeze-Entries ist der Ertrag zwischen beiden + # Basen ein Wash (innerhalb eines Standardfehlers), der Worst-Case sinkt + # aber von −8,83 auf −2,44xATR_M5 = Faktor 3. Auf der WELLEN-Population + # ist der weite M15-SL dagegen in BEIDEN Halbjahren besser — deshalb + # bewusst KEINE globale Umstellung. Strukturell begruendet: Breakout- und + # Pullback-Einstiege haben verschiedene Nachlauf-Dynamik. + _atr_tf = mt5.TIMEFRAME_M5 if source == "auto_squeeze" else None + err = fn(sym, _atr_tf) + if err: + return err + # Vom BOT eröffnete Trades markieren → nur für die greift die 15-Min-Regel + # (manuelle Trades bleiben in der Diskretion des Users, User-Vorgabe). + if source in ("auto_squeeze", "auto_signal"): + try: + self._bot_open_ticket = self.trader.snapshot().get("ticket") + self._bot_open_source = source + self._adverse15_done_ticket = None + except Exception: + pass + # Trailing automatisch aktivieren (wie _after_trade im Widget) + if not self.trail.enabled: + self.trail.set_setup(self._last_rec.get("setup")) + self.trail.toggle(sym) + return None + + def close(self) -> str | None: + return self.trader.close(reason="manual") + + def toggle_trail(self) -> bool: + sym = self.data.symbol + if sym: + return self.trail.toggle(sym) + return self.trail.enabled + + def _sr_levels(self, market: dict) -> dict: + """Nächste Widerstandslinie ÜBER Preis (Short) und Unterstützung UNTER + Preis (Long) — kombiniert dynamische S/R-Levels + die [zones]-Config.""" + bid = market.get("bid") + if not bid: + return {"resistance": None, "support": None, + "resistances": [], "supports": []} + sr = market.get("sr") or {} + above, below = [], [] + for r in (sr.get("resistances") or []): + p = r.get("price") + if p: (above if p > bid else below).append(p) + for s in (sr.get("supports") or []): + p = s.get("price") + if p: (above if p > bid else below).append(p) + # ⚠ [zones]-CONFIG ENTFERNT (2026-07-31, Modul-Audit). Vorher flossen hier + # beide Kanten jeder `[zones]`-Zone als S/R-Linien ein — 16 Kanten aus einem + # ~10 $ TIEFEREN Kursregime (Zonen bei 72,90–77,00 bei einem Kurs von 85,3). + # Aus dem CHART wurden sie schon am 2026-07-14 entfernt, WEIL sie stale sind; + # in die Wellen-Konfidenz liefen sie aber weiter. + # Realer Fall beim Audit (Kurs 85,342): `market.sr` hatte GAR KEINE + # Widerstände (M15-Cluster sind in frischen Trends oft leer) → der nächste + # „Widerstand" kam aus der stale `fvg`-Zone bei **88,00** (2,66 $ weg), + # während der echte bei **85,315** lag (0,03 $ weg). Der Konfidenz-Abzug + # „dicht unter Widerstand" (−12) greift nur innerhalb 0,5×ATR und feuerte + # deshalb NIE, obwohl er hätte feuern müssen. + # Entfernen ist KEINE Strategieänderung, sondern das Streichen von Daten aus + # einem anderen Preisregime: fehlt ein Level, gibt es weder Bonus noch Abzug — + # das ist ehrlicher als ein falsches Level. + # ⚠ OFFEN (messpflichtig, NICHT hier gemacht): die Wellen-Konfidenz stattdessen + # aus `_draw_levels` (M30, dieselbe Quelle wie Chart + S/R-Close) speisen. Das + # würde den −12-Abzug tatsächlich auslösen → Konfidenz sinkt → das 55%-Gate + # verschiebt sich. Strategieänderung, gehört gemessen. + # Tages-Level aus der Morgen-Recherche (Kontext) + try: + for lv in self.daily.zone_lines(): + (above if lv > bid else below).append(lv) + except Exception: + pass + # Offene Kurslücken (D1-Gaps) als Fill-Magnete (Kontext/Konfidenz) + try: + for lv in self.gaps.zone_lines(): + (above if lv > bid else below).append(lv) + except Exception: + pass + above = sorted(set(round(p, 3) for p in above)) + below = sorted(set(round(p, 3) for p in below), reverse=True) + return { + "resistance": above[0] if above else None, # nächster Widerstand (Short) + "support": below[0] if below else None, # nächste Unterstützung (Long) + "resistances": above[:3], + "supports": below[:3], + } + + def _draw_levels(self) -> dict: + """S/R-Linien fürs ZEICHNEN (PWA-Charts + MQL5-CSV) UND für den + P(break)-Auto-Close: echte Kursstruktur = rohe **M30**-Pivots + (`wave.pb_levels30`), geclustert (~0,5×ATR). + + ⚠ **TF-Umstellung M5→M30 am 2026-07-30** (User: „die S/R-Linien liegen oft + sehr nahe beieinander"), gemessen `backtest_level_tf.py` über 80k M5-Bars in + 2 Halbjahren — M30 gewinnt in BEIDEN: Level-Abstand **1,95 statt 0,61×ATR** + (nur 28 % statt 71 % näher als 1×ATR), AUC out-of-sample 0,721 vs 0,710, + R-Ertrag der Close-Regel **rund doppelt** (+12.424/+13.047 vs +6.698/+7.749). + Das P(break)-Modell (`_PB_MU/_SD/_W`) wurde dafür auf M30-Leveln NACHTRAINIERT. + Fallback auf die alten M5-Level (`pb_levels`), falls M30 noch nicht da ist. + ⚠ NICHT betroffen: das **Entry-Raum-Gate** nutzt weiter `pb_levels` (M5) — + `entry_room_atr=0.6` ist darauf kalibriert. + + Zeichnet **nur die NÄCHSTE je Seite** (2 Linien total, + deckungsgleich mit den Dashboard-Werten `sr-res`/`sr-sup`) — sonst tauchten + beim Kurslauf die weiter entfernten Linien laufend auf/ab (User-Feedback + 2026-07-14). Bugfix davor: Linien kamen aus `_sr_levels` (stale [zones]-Config + + Tageslevel + Gaps) — Uralt-Level statt aktueller Struktur. Ergebnis wird + ~1 s gecacht (Konsistenz über alle Anzeigen + weniger Lock-Last).""" + now = time.time() + dc = self._draw_cache + if dc and (now - dc[0]) < 1.0: + return dc[1] + try: + ws = self.wave.snapshot() or {} + # M30-Level (gemessen besser); M5 nur als Fallback beim Kaltstart, bis + # der erste M30-Turn-Fetch durch ist. + lv = ws.get("pb_levels30") or ws.get("pb_levels") or {} + atr = (ws.get("pb_feats") or {}).get("atr") or 0.2 + bid = (self.data.snapshot() or {}).get("bid") + if not bid: + return {"res": [], "sup": []} + # ⚠ Clusterung/Auswahl/Hysterese liegen seit 2026-08-07 in + # `core/sr_levels.py` — die Backtests bauten sie vorher NACH und + # wichen dabei ab (gemischter Rohpool statt ph/pl getrennt; 4,5 % + # der Zeitpunkte hatten einen anderen Widerstand). Verhalten hier + # unverändert, bitgenau geprüft in `tests/test_sr_levels.py`. + from core.sr_levels import cluster as _cluster_lv + from core.sr_levels import halten as _halten_lv + from core.sr_levels import naechste as _naechste_lv + + def cluster(vals): + return _cluster_lv(vals, atr) + # Fix 2026-07-23 (User-Frage „Widerstand/Support-Retest von der falschen + # Seite"): Widerstand/Support werden NACH AKTUELLER KURSPOSITION gewählt + # (über/unter bid), NICHT mehr strikt nach Entstehungstyp (Swing-Hoch `ph` + # vs. Swing-Tief `pl`). Vorher blieb ein gebrochenes Level für IMMER im + # falschen Topf hängen: ein Swing-Tief, das der Kurs unterschritt und von + # UNTEN erneut ansteuerte, tauchte nie als Widerstand auf (kam nur aus `ph`); + # ein Swing-Hoch, das der Kurs überschritt und von OBEN erneut ansteuerte + # (Retest), tauchte nie als Support auf (kam nur aus `pl`) — 2 von 4 Retest- + # Varianten hatten dadurch nie eine Durchbruchwahrscheinlichkeit. + # ⚠ ph/pl bleiben GETRENNT geclustert (nicht vorher zusammenlegen!) — ein + # erster Versuch clusterte den kombinierten Rohpool direkt, was in dichten + # Zonen zu Ketten-Mega-Clustern führte (doppelte Punktdichte → die einfache + # Nachbar-Kette lief über >1×ATR durch). Erst NACH dem separaten Clustern + # (unveränderte Granularität wie vorher) werden die beiden fertigen + # Linien-Listen zusammengeführt und rein nach Position zu bid gewählt. + ph_c = cluster(lv.get("ph") or []) + pl_c = cluster(lv.get("pl") or []) + both = ph_c + pl_c + # NUR die nächste je Seite (2 Linien total = wie im Dashboard) + r, s = _naechste_lv(both, bid) + # Hysterese „klebrig" (Fix 2026-07-21): die gezeichnete Linie bleibt + # stehen, solange der Kurs sie nicht WIRKLICH durchbricht (bid > R+0,1×ATR + # bzw. bid < S−0,1×ATR). Ein neuer, näherer Pivot löst KEINEN Wechsel aus + # — genau das war die Zitter-Quelle (auf M5 bilden sich laufend neue Pivots). + # Ausnahme: ist die alte Linie weit weg (>1,5×ATR) UND ein deutlich näheres + # Level existiert, wird sie übernommen (sonst bleibt sie irrelevant hängen). + r = _halten_lv(r, self._draw_last.get("r"), bid, atr, True) + s = _halten_lv(s, self._draw_last.get("s"), bid, atr, False) + self._draw_last = {"r": r, "s": s} + out = {"res": [r] if r is not None else [], + "sup": [s] if s is not None else []} + self._draw_cache = (now, out) + return out + except Exception: + return {"res": [], "sup": []} + + def _dash_sr(self, market: dict) -> dict: + """S/R fürs Dashboard — GLEICHE Pivot-Quelle wie die Chart-Linien + (`_draw_levels`), damit Position-Kachel und Chart übereinstimmen.""" + dl = self._draw_levels() + res = dl.get("res") or []; sup = dl.get("sup") or [] + return {"resistance": (res[0] if res else None), + "support": (sup[0] if sup else None), + "resistances": res, "supports": sup} + + def _write_levels_file(self): + """Schreibt S/R als CSV nach `\\MQL5\\Files\\sr_levels.csv`, + damit ein MQL5-Indikator (mql5/SR_Levels.mq5) sie im Terminal zeichnet — das + Python-MT5-Paket kann keine Chart-Objekte erzeugen. Atomarer Write (.tmp→replace), + damit der Indikator nie eine halbe Datei liest.""" + import os + if self._mql5_files_dir is None: + try: + with mt5_lock(timeout=2) as got: + if got: + ti = mt5.terminal_info() + self._mql5_files_dir = os.path.join(ti.data_path, "MQL5", "Files") + except Exception: + self._mql5_files_dir = "" # einmal fehlgeschlagen → nicht endlos retryen + if not self._mql5_files_dir: + return + try: + dl = self._draw_levels() # echte Pivot-Struktur, nicht [zones]-Config + lines = [f"SYM;{self.data.symbol or ''}"] + # P(Durchbruch) je Linie mitgeben (User-Wunsch 2026-07-30) → der Indikator + # schreibt sie ins Label: „Widerstand 84.536 · Durchbruch 34%". + # Richtung: Widerstand = Bruch nach OBEN (+1), Support = nach UNTEN (−1); + # Merkmale identisch zu `_sr_close_hint`/`_stop_approach_hint` (M5-Momentum, + # `wt` = EMA-Richtung stimmt mit der Bruchrichtung, `dist` = Abstand/ATR). + # ⚠ Das Modell ist auf GENAU diese M30-Level trainiert (Nachtraining + # 2026-07-30) — Kalibrierung out-of-sample geprüft (AUC 0,72). + def _pb_for(level, dd): + try: + pf = (self.wave.snapshot() or {}).get("pb_feats") or {} + atr = pf.get("atr"); m6 = pf.get("mom6"); m3 = pf.get("mom3") + ed = pf.get("ema_diff") + b = (self.data.snapshot() or {}).get("bid") + if not (atr and b) or None in (m6, m3, ed): + return None + wt = 1.0 if ed * dd > 0 else 0.0 + return round(_p_break(m6 * dd, m3 * dd, wt, abs(level - b) / atr) * 100) + except Exception: + return None + for p in dl.get("res", []): + _pb = _pb_for(p, 1) + lines.append(f"R;{p}" + (f";{_pb}" if _pb is not None else "")) + for p in dl.get("sup", []): + _pb = _pb_for(p, -1) + lines.append(f"S;{p}" + (f";{_pb}" if _pb is not None else "")) + # Regressionskanal (M30) als Trendlinien-Anker → MQL5 zieht OBJ_TREND + # (Format CH;;t1;p1;t2;p2 mit Broker-Zeiten). + try: + cl = (self.structure.snapshot() or {}).get("channel_line") + if cl and cl.get("t1") and cl.get("t2"): + for kind, key in (("U", "upper"), ("M", "mid"), ("L", "lower")): + pts = cl.get(key) + if pts and len(pts) == 2: + lines.append(f"CH;{kind};{cl['t1']};{pts[0]};{cl['t2']};{pts[1]}") + except Exception: + pass + # Chartmuster-Linien (PT;…) 2026-07-30 auf User-Wunsch ENTFERNT — die + # Kachel/PWA-Anzeige bleibt, im MT5-Chart wurde es zu voll. Der Indikator + # (v1.12) hat den PT-Handler ebenfalls nicht mehr. + # ECHTE Liquiditäts-Wände von Hyperliquid (größtes L2-Level je Seite, + # basis-korrigiert auf CFD-Niveau) — Format LQ;;preis;size. + # Anders als R/S NICHT aus Pivots geschätzt, sondern tatsächlich ruhende + # Orders. REINE ANZEIGE (Übertragbarkeit auf den CFD wird gerade gemessen). + # Format: LQ;;preis;size;;<1|0> (Pfeil-Richtung = Wirkrichtung + # der Wand nach der Lesart „sie hält"; letztes Feld 1 = die GRÖSSERE der + # beiden Wände). Zusätzlich LT;t1;p1;t2;p2; = Liquiditäts-Trendlinie + # (Regression über den volumengewichteten Wand-Schwerpunkt) — Zeiten + # werden hier in BROKER-Zeit umgerechnet, weil MT5-Charts die brauchen. + try: + w = self.hl_walls.walls() + if w: + dom = w.get("dominant") + ab = "U" if (w.get("arrow_bid") == "up") else "D" + aa = "D" if (w.get("arrow_ask") == "down") else "U" + lines.append(f"LQ;B;{w['bid']};{w['bid_sz']:.0f};{ab};{1 if dom=='B' else 0}") + lines.append(f"LQ;A;{w['ask']};{w['ask_sz']:.0f};{aa};{1 if dom=='A' else 0}") + tr = w.get("trend") + if tr and tr.get("t1") and tr.get("t2"): + boff = self.trader._broker_offset_s() + lines.append(f"LT;{int(tr['t1'])+boff};{tr['p1']};" + f"{int(tr['t2'])+boff};{tr['p2']};" + f"{'U' if tr.get('dir')=='up' else 'D'}") + except Exception: + pass + # Liquiditätszonen (Equal Highs/Lows, M30): EQ;;lo;hi; + # → goldenes Band ab dem ersten Pivot, nach rechts verlängert. + # ⚠ REINE ANZEIGE (als Signal gemessen verworfen, s. backtest_liquidity_sweep). + try: + zs = (self.wave.snapshot() or {}).get("liq_zones") or {} + boff_z = self.trader._broker_offset_s() + for key, tag in (("eqh", "H"), ("eql", "L")): + z = zs.get(key) + if z and z.get("lo") and z.get("hi"): + # `t` ist ROHE Broker-Zeit aus copy_rates → NICHT nochmal + # umrechnen (anders als die HL-Trendlinie, die lokal tickt). + lines.append(f"EQ;{tag};{z['lo']};{z['hi']};{int(z['t'])}") + except Exception: + pass + # KONSOLIDIERUNGS-BOX (M5, dieselbe wie der validierte Squeeze): + # CO;lo;hi;<0|1> — letztes Feld 1 = komprimiert (≤2,5×ATR, Ausbruch steht + # bevor), 0 = nur die aktuelle 10-Bar-Spanne. ⚠ REINE ANZEIGE: die + # Konsolidierungs-SETUPS (False Breakout, Distribution, Accumulation, + # Retest) sind gemessen ALLE verworfen (`backtest_consolidation.py`); + # validiert ist nur der Squeeze-AUSBRUCH. Die Box zeigt also, WO gerade + # gestaut wird — nicht, wohin es geht. + try: + _sq = (self.wave.snapshot() or {}).get("squeeze") or {} + _lo, _hi = _sq.get("box_lo"), _sq.get("box_hi") + if _lo and _hi and _hi > _lo: + _tight = 1 if _sq.get("state") in ("armed", "active") else 0 + lines.append(f"CO;{_lo};{_hi};{_tight}") + except Exception: + pass + # Aktueller Kurs für die fette Anzeige oben rechts (User-Wunsch): PX;bid + try: + _bid = (self.data.snapshot() or {}).get("bid") + if _bid: + lines.append(f"PX;{_bid}") + except Exception: + pass + _atr5 = 0.0 + try: + _atr5 = float(((self.wave.snapshot() or {}).get("pb_feats") or {}) + .get("atr") or 0.0) + except Exception: + pass + # ── WAHRSCHEINLICHKEITS-KEGEL (CN) — im Chart per Default AUS + # (User 2026-07-31: „anstatt des Trichters hätte ich gerne einen Pfeil"). + # Die Dashboard-Kachel „Erwartete Spanne" bleibt; `[trading] export_cone=true` + # holt den Kegel in den Chart zurück. + if self._export_cone: + try: + _cn = _cone_build(_bid, _atr5, bands=(80,)) + for lv in (_cn or {}).get("levels", []): + lines.append(f"CN;{lv['minutes']};{lv['lo']};{lv['hi']}") + except Exception: + pass + # ── PFEILE: AR;;;; (art L | S | K) ────────────── + # ⚠ EINORDNUNG — die freie Frage „wohin läuft der Kurs?" ist im Projekt + # GEMESSEN ein Münzwurf (`analyze_reversal.py`: AUC 0,499–0,509 out-of- + # sample, in-sample nur 0,52). Deshalb zeigt KEINER dieser Pfeile eine + # freie Kursprognose, sondern jeweils eine Aussage mit eigener Beleglage: + # L = am nächsten LEVEL, aus dem kalibrierten P(break) (AUC 0,65 oos, + # live kalibriert 27 % vorhergesagt vs 28 % real) — BEDINGTE Aussage + # an einem realen Objekt, die einzige belegte Richtungsangabe. + # S = SQUEEZE-Ausbruch — das einzige 2-Stichproben-validierte + # Richtungssignal (ØR +0,14…+0,23). Erscheint nur bei `active`. + # K = KONSENS der Verdict-Module — ⚠ NICHT kalibriert + # (`analyze_verdict_calibration.py`: in H2 sogar invertiert), heißt + # deshalb „Konsens" und nicht „Prognose". + try: + _dl = dl + _r = (_dl.get("res") or [None])[0] + _s = (_dl.get("sup") or [None])[0] + if _bid and _atr5 > 0: + # (L) nächstes Level = das nähere von beiden + cand = [(abs(x - _bid), x, 1 if x > _bid else -1) + for x in (_r, _s) if x is not None] + if cand: + _d0, _lv0, _dd = min(cand) + _p = _pb_for(_lv0, _dd) # in Prozent (0–100) oder None + # ⚠ TOTBAND 45–55 %: dort ist P(break) ein Münzwurf. Einen + # Pfeil zu zeichnen würde daraus eine Richtungsaussage machen — + # genau der Fehler, den das Projekt heute mehrfach vermieden hat + # (freie Richtung = AUC 0,5). Stattdessen ein FLACHER Pfeil mit + # „unentschieden". + if _p is not None: + if 45 <= _p <= 55: + to = _bid # waagerecht = keine Aussage + txt = f"{_p}% — unentschieden" + elif _p > 55: + to = _lv0 + _dd * 0.5 * _atr5 + txt = f"{_p}% Durchbruch" + else: + to = _bid - _dd * 1.0 * _atr5 + txt = f"{100 - _p}% Abprall" + lines.append(f"AR;L;{round(_bid, 3)};{round(to, 3)};{txt}") + # (S) Squeeze — nur wenn aktiv + _sq = (self.wave.snapshot() or {}).get("squeeze") or {} + if _sq.get("state") == "active" and _sq.get("dir") and _sq.get("level"): + _sd = 1 if _sq["dir"] == "LONG" else -1 + lines.append(f"AR;S;{round(float(_sq['level']), 3)};" + f"{round(float(_sq['level']) + _sd * 2.0 * _atr5, 3)};" + f"Squeeze {_sq['dir']}") + # (K) Konsens — Länge proportional zum Bias-Betrag (max 2×ATR) + # ⚠ NUR berechnen, wenn der Pfeil auch gezeichnet wird + # (Review 2026-08-02): Der Indikator verwirft `AR;K` seit v1.33 + # per Default (`InpShowConsensus=false` → `continue`), der Bot + # rechnete dafür aber im ~5-s-Export-Takt ein KOMPLETTES zweites + # `_verdict()` — Arbeit für eine Linie, die niemand zeichnet, plus + # das Risiko, dass dieses zweite Verdict vom Verdict im Snapshot + # abweicht. Der Schalter spiegelt jetzt den Indikator-Default; + # wer `InpShowConsensus=true` setzt, setzt hier `export_consensus + # _arrow=true` dazu. + if self._export_consensus_arrow: + _vd = self._verdict(self.data.snapshot(), self.wave.signal(), + self.wave.snapshot(), None, + self.agent.snapshot(), self.elliott.snapshot(), + self.patterns.snapshot()) + _b = float(_vd.get("bias") or 0.0) + if abs(_b) >= 0.2: + lines.append(f"AR;K;{round(_bid, 3)};" + f"{round(_bid + (1 if _b > 0 else -1) * min(abs(_b), 1.0) * 2.0 * _atr5, 3)};" + f"Konsens {_b:+.2f}") + except Exception: + pass + # ── HYPERLIQUID-ERSATZKURS bei geschlossenem Markt (User 2026-08-01): + # HL;;;;; — der Indikator + # zeigt ihn UNTER dem (eingefrorenen) MT5-Kurs, sobald kein Tick mehr + # kommt. HL (`xyz:CL`) handelt 24/7, Pepperstone nicht; ohne das sieht man + # am Wochenende einen Freitags-Kurs und merkt es nicht. + try: + _w = self.hl_walls.walls() + if _w and _w.get("mid_mt5") is not None: + _tr = (_w.get("trend") or {}).get("dir") + lines.append(f"HL;{_w['mid_mt5']:.3f};{float(_w.get('age_s') or 0):.0f};" + f"{_w.get('bid')};{_w.get('ask')};" + f"{'U' if _tr == 'up' else 'D' if _tr == 'down' else '-'}") + except Exception: + pass + # G/V der offenen Position, direkt unter dem Kurs (User-Wunsch 2026-07-30): + # PL;wert;waehrung. **NETTO** wie im Dashboard — bei Gewinn abzüglich der + # WHT-Quellensteuer, die der Broker einbehält (`[trading] wht_pct`); bei + # Verlust unverändert (da fällt keine Steuer an). Ohne Position keine Zeile + # → der Indikator löscht das Label. + try: + _ps = self.trader.snapshot() + if _ps.get("ticket") and _ps.get("pnl") is not None: + _p = float(_ps["pnl"]) + if _p > 0 and self._wht_pct > 0: + _p *= (1.0 - self._wht_pct / 100.0) + _cur = (self.data.snapshot() or {}).get("currency") or "" + lines.append(f"PL;{_p:.2f};{_cur}") + except Exception: + pass + # ── ZONEN statt nackter Linien (User-Wunsch 2026-08-17, nach einem + # TradingView-Bild): RZ;lo;hi = Widerstands-Zone · SZ;lo;hi = + # Support-Zone. Der Indikator zeichnet sie als farbiges Band. + # ⚠⚠ DIE BREITE IST ECHT, NICHT ERFUNDEN: sie ist die Spanne der + # M30-Pivots, die den Cluster gebildet haben (`sr_levels.zonen`). + # Das Vorbild-Bild leitet seine Zone aus **Fibonacci** ab (0,618/ + # 0,658) — Fib ist hier gemessen und VERWORFEN: die Level halten + # nicht besser als eine Zufallslinie gleicher Distanz (04.08., + # Haltequote 54,3/54,5 % gegen 55,7/53,8 % Zufall). Eine + # Fib-Zone waere also eine huebsche Linie ohne Beleg. + # ⚠ Ein Cluster aus EINEM Pivot hat Breite 0 — dann bekommt er eine + # Mindestbreite von 0,10xATR, damit das Band sichtbar bleibt. + # ⚠ Die untere Zone heisst SUPPORT, nicht „Long Liquidation": echte + # Liquidations-Daten hat das Projekt nicht. Bricht sie, liegen dort + # die Long-Stops — das ist dieselbe Stelle, aber ehrlich benannt. + try: + _zl = self._draw_levels() or {} + _r0 = (_zl.get("res") or [None])[0] + _s0 = (_zl.get("sup") or [None])[0] + _ws3 = self.wave.snapshot() or {} + _a3 = (_ws3.get("pb_feats") or {}).get("atr") or 0.2 + _lv3 = _ws3.get("pb_levels30") or _ws3.get("pb_levels") or {} + from core.sr_levels import zonen as _zonen_lv + _mind = 0.10 * _a3 + for _px, _seite, _tag in ((_r0, "ph", "RZ"), (_s0, "pl", "SZ")): + if _px is None: + continue + _tr = None + for _m, _lo2, _hi2 in _zonen_lv(_lv3.get(_seite) or [], _a3): + if abs(_m - _px) < 0.0015: + _tr = (_lo2, _hi2); break + if _tr is None: + _tr = (_px, _px) + _lo2, _hi2 = _tr + if _hi2 - _lo2 < _mind: # Einzel-Pivot sichtbar machen + _mit = (_lo2 + _hi2) / 2.0 + _lo2, _hi2 = _mit - _mind / 2, _mit + _mind / 2 + lines.append(f"{_tag};{round(_lo2, 3)};{round(_hi2, 3)}") + except Exception: + pass + # ── MELDUNGEN oben links (MS;) — User-Wunsch 2026-08-17 ──── + # Dieselben Zeilen wie die Dashboard-Karte „Meldungen" (`#card-pos`). + # ⚠⚠ NUR ZUSAMMENSETZEN, NICHT NEU RECHNEN. Der Konsens-Pfeil hat im + # ~5-s-Export-Takt ein komplettes zweites `_verdict()` gezogen — + # doppelte Last UND die Gefahr, dass Chart und Dashboard + # auseinanderlaufen. Alles hier kommt aus bereits berechneten + # Snapshot-Teilen bzw. den `_last_*`-Feldern. + # ⚠ Abschaltbar: `[trading] export_meldungen=false`. + if self._export_meldungen: + try: + _ms: list[str] = [] + _ws2 = self.wave.snapshot() or {} + _sq2 = _ws2.get("squeeze") or {} + if _sq2.get("state") == "active" and _sq2.get("dir"): + _ms.append(f"🚀 Squeeze-Ausbruch {_sq2['dir']} " + f"@ {_sq2.get('level')}") + elif _sq2.get("state") == "armed": + _ms.append("🎯 Kompression — Ausbruch steht bevor") + _bo = _ws2.get("bounce") or {} + if _bo.get("state") in ("expected", "active") and _bo.get("dir"): + _pf = "aktiv" if _bo["state"] == "active" else "erwartet" + _ms.append(f"⚠ Ueberdehnung ({_pf}) — Reaktion " + f"{'nach oben' if _bo['dir'] == 'LONG' else 'nach unten'}" + f" moeglich [{_bo.get('tf') or ''}]") + _sh = self._last_sr_close_hint + if _sh and _sh.get("level"): + _pb2 = _sh.get("p_break") + _txt = ("unentschieden" if _pb2 is None or 45 <= _pb2 <= 55 + else f"{_pb2}% Bruch" if _pb2 > 55 + else f"{100 - _pb2}% Abprall") + _ms.append(f"S/R-Ziel {_sh['level']} · {_txt}" + + (" · Bot wuerde schliessen" if _sh.get("close") else "")) + _st2 = self._last_stop_approach + if _st2 and _st2.get("level"): + _pb3 = _st2.get("p_break") + _ms.append(f"Stop-Seite {_st2['level']} · " + + ("bricht wahrscheinlich" if (_pb3 or 0) >= 50 + else "haelt wahrscheinlich")) + # ⚠ `self.calendar` ist das Objekt (kein `_last_*`-Feld) — + # `blackout()` ist ein reiner Speicher-Zugriff, kein Netz. + _bl = self.calendar.blackout() + if _bl: + _ms.append(f"News-Blackout: {_bl.get('title') or ''}") + # ⚠⚠ ASCII-SICHER. Der Indikator liest die CSV NICHT als + # UTF-8 — Emoji und Sonderzeichen kommen als Leerzeichen an + # (real gesehen: „🎯 Kompression — …" wurde „ Kompression + # …"). Genau deshalb steht im Indikator auch + # „Unterstuetzung" statt „Unterstützung". Ersetzen statt + # hoffen. + _ers = {"—": "-", "–": "-", "·": "-", "→": "->", "≥": ">=", + "≤": "<=", "ü": "ue", "ö": "oe", "ä": "ae", + "Ü": "Ue", "Ö": "Oe", "Ä": "Ae", "ß": "ss"} + for _z in _ms[:5]: # Deckel: der Chart soll lesbar bleiben + _t = str(_z) + for _a, _b in _ers.items(): + _t = _t.replace(_a, _b) + _t = "".join(c for c in _t if 32 <= ord(c) < 127) + _t = " ".join(_t.split()) # Mehrfach-Leerzeichen weg + if _t: + lines.append("MS;" + _t.replace(";", ",")[:70]) + except Exception: + pass + # Trade-Marker: TR;; → der Indikator umrandet die Kerze, + # in der eröffnet (hellgrün) bzw. geschlossen (dunkelgrün) wurde + # (User-Vorgabe 2026-07-30). Offene Position + die letzten Trades des Tages. + # ⚠ Per Default AUS (User-Wunsch 2026-07-30: „entferne die Umrandungen + # für Trade open und close" — kurz nach dem Einbau wieder verworfen). + # Code bleibt reaktivierbar via `[trading] export_trade_marks=true`; + # spart im Aus-Zustand die DB-Abfrage im ~5-s-Export-Takt. + # ⚠ Kein `raise StopIteration` mehr als Sprung (2026-07-31, Audit): + # StopIteration IST eine Exception und wurde vom breiten `except` unten + # gefangen — funktionierte, wäre aber sofort fragil, sobald dort jemand + # Logging ergänzt (Phantom-Fehler bei JEDEM Export-Takt). + if self._export_trade_marks: + try: + boff = self.trader._broker_offset_s() + marks: list[tuple[str, int]] = [] + ps = self.trader.snapshot() + if ps.get("ticket") and ps.get("open_time"): + marks.append(("O", int(float(ps["open_time"]) + boff))) + for t in (self.history.last_closed_trades(_TRADE_MARKS) or []): + if t.get("entry_time"): + marks.append(("O", int(float(t["entry_time"]) + boff))) + if t.get("exit_time"): + marks.append(("C", int(float(t["exit_time"]) + boff))) + for kind, ts in marks[:2 * _TRADE_MARKS + 1]: + lines.append(f"TR;{kind};{ts}") + except Exception: + pass + # Zonen NICHT mehr aus der (statischen) [zones]-Config — die dynamische + # Handels-Range zeichnet der Indikator selbst aus den Chart-Kerzen (TF-genau). + path = os.path.join(self._mql5_files_dir, "sr_levels.csv") + with open(path + ".tmp", "w", encoding="ascii", errors="ignore") as f: + f.write("\n".join(lines) + "\n") + os.replace(path + ".tmp", path) + except Exception as e: + log.debug(f"write_levels: {e}") + + def _verdict(self, market, wave_signal, wave_snap, tu, agent, elliott, + snap_parts_patterns=None) -> dict: + """Gesamtempfehlung aus den Modulen für die Widget-Grafik. + + Headline = die **validierte Welle** (einziges Modul mit belegtem Edge); + die übrigen Module bilden einen gewichteten Konsens-Balken. REINE + ANZEIGE — ändert NICHT die Order-Logik (die hört nur auf die Welle). + Votes: +1 LONG, −1 SHORT, 0 neutral. + """ + def word(v): + return "auf" if v > 0 else "ab" if v < 0 else "flach" + + bid = (market or {}).get("bid") + votes = [] + + def add(name, vote, weight, detail, used=True): + """`used=False` = das Modul hat DAUERHAFT kein Stimmrecht (gemessen + ohne Inhalt) und wird im Dashboard NICHT mehr als Chip gezeigt. + ⚠ Es bleibt trotzdem in `votes` — `log_verdict_votes` speist daraus + die Telemetrie, und die 25.000-Verdict-Messung braucht die Reihe + weiter. Ausgeblendet wird nur die Anzeige, nicht die Messung. + Nicht zu verwechseln mit `weight=0` aus SITUATIVER Stille (Welle bei + WARTEN, Squeeze ohne Ausbruch): die sprechen zeitweise und bleiben + sichtbar.""" + votes.append({"name": name, "vote": int(vote), "weight": weight, + "detail": detail, "used": bool(used)}) + + # 1) Welle — primär (validierter Edge). ⚠ Bei WARTEN hat sie KEINE Aussage + # → Gewicht 0 (Fix 2026-07-30). Vorher stand w=3,0 permanent im Nenner und + # zog die Bias-Nadel dauerhaft zur Mitte — derselbe Logikfehler, der am + # 2026-07-15 beim Squeeze behoben wurde, hier übersehen. Das wog schwer: + # gemessen sind ~91 % der letzten 2000 Empfehlungen WARTEN, die Nadel war + # also fast immer künstlich gedämpft (real: bias 0,312 statt 0,500). + wsig = (wave_signal or {}).get("signal") + wv = 1 if wsig == "LONG" else -1 if wsig == "SHORT" else 0 + add("Welle", wv, 3.0 if wv != 0 else 0.0, + f"{wsig or '—'} · {(wave_signal or {}).get('conf_pct', 0)}%") + + # 2/3) M30 / H1 — die EMA-Signale, die die Welle tatsächlich nutzt. + # ⚠ Gewicht 0 OHNE Aussage (Fix 2026-07-31) — dieselbe Regel wie bei Welle, + # Squeeze, Elliott und KI; bei der Verdict-Überarbeitung am 30.07. hier + # übersehen. Eine 0-Stimme bedeutet „EMA-Abstand im Totband" = keine Aussage, + # nicht „neutral" — mit vollem Gewicht im Nenner zog sie die Bias-Nadel + # dauerhaft zur Mitte. + # GEMESSEN an 7.074 geloggten Verdicts (`verdict_votes`): **M30 ist in 57,9 % + # der Fälle ohne Aussage** (H1 nur in 1,8 %) → Ø-Bias-Betrag 0,371 → **0,441**, + # Median-Verstärkung **1,30×**, in **33,3 %** der Zeilen deutlich stärkere Nadel. + # ⚠ Anlass war die User-Frage „H1 aus der Gesamtempfehlung entfernen?". Die + # Antwort ist NEIN — die vermutete Doppelzählung ist gemessen schwach (bei + # gerichteter Welle stimmt M30 nur zu 14,9 % mit ihr überein, H1 zu 47 %; M30 + # und H1 untereinander nur zu 33,8 %). Der eigentliche Defekt war dieser hier. + # REINE ANZEIGE — die Order-Logik hängt an der Wellen-Headline, nicht am Bias. + m30 = int((wave_snap or {}).get("htf_trend", 0) or 0) + h1 = int((wave_snap or {}).get("h1_trend", 0) or 0) + add("M30", m30, 1.5 if m30 != 0 else 0.0, word(m30)) + add("H1", h1, 1.5 if h1 != 0 else 0.0, word(h1)) + + # 4) TradersUnion ENTFERNT aus dem Verdict-Konsens (User-Vorgabe 2026-07-06): + # lagt + nicht backtestbar + Standard-Indikator-Konfluenz = kein Edge. Bleibt + # nur noch reine Anzeige (Snapshot `tu`), zählt NICHT mehr in Bias/Agree. + + # 5) KI-Copilot — Gewicht NUR bei gerichteter Aussage (LONG/SHORT). + # ⚠ GEÄNDERT 2026-07-31 (Modul-Audit): vorher zählte ein explizites "NEUTRAL" + # als echte Stimme mit Gewicht 1,0. Gemessen an 7.084 Verdicts ist das aber + # **in 93,1 % der Fälle** der Zustand — also der Normalzustand des Copiloten, + # kein abgewogenes Urteil (Elliott zeigt dasselbe Muster nur in 1,3 %). + # Damit hatte ausgerechnet das Modul mit der SCHWÄCHSTEN Beleglage + # (`analyze_verdict_calibration.py`: nicht robust prädiktiv) den GRÖSSTEN + # dämpfenden Einfluss auf die Bias-Nadel. Wirkung der Angleichung: + # Ø|bias| 0,441 → 0,569 (Median-Verstärkung 1,25×, betrifft 79,5 % der Zeilen); + # kumuliert mit den Fixes von Welle/Squeeze/M30/H1: 0,370 → 0,569 = 1,54×. + # Der Chip bleibt sichtbar und zeigt „NEUTRAL", er stimmt nur nicht mehr mit ab. + adv = (agent or {}).get("advisory") or {} + ab = adv.get("bias") + av = 1 if ab == "LONG" else -1 if ab == "SHORT" else 0 + add("KI-Copilot", av, 1.0 if av != 0 else 0.0, + f"{ab or '—'} · {adv.get('confidence', 0)}%") + + # 6) Elliott/FVG — nur stimmberechtigt, wenn ein Ziel existiert (sonst w=0) + ev, edet, ew = 0, "—", 0.0 + tgt = (elliott or {}).get("target") + if tgt is not None and bid: + ev = -1 if tgt < bid else 1 if tgt > bid else 0 + edet = f"{(elliott or {}).get('wave', '—')} → {tgt:.3f}" + # ⚠⚠ GEWICHT AUF 0 GESETZT (2026-08-06, gemessen + # `analyze_verdict_modules.py` über 15.544 Zeilen). Elliott hatte mit + # **1.384 Episoden die grösste Stichprobe** der ganzen Tabelle und ist + # darin **flach**: Überschuss −0,075 / +0,032 ×ATR, **50 % Treffer**. + # Gleichzeitig war es mit **25,9 % die zweitlauteste Stimme** der + # Bias-Nadel — weil es praktisch nie schweigt (stumm in 1 % der Zeilen). + # Zusammen mit H1 (38,1 %) bildete es einen Dauerhintergrund von ~64 %, + # gegen den die episodischen Module erst anschieben mussten. + # Ein Modul mit der grössten Stichprobe und ohne messbaren Inhalt darf + # den Konsens nicht dominieren. Der CHIP bleibt (reine Anzeige, das Ziel + # ist als Kontext nützlich) — nur die Stimme entfällt. + # ⚠ Reine ANZEIGE-Änderung: die Order steuert die Welle über die + # Headline, nicht der Konsens. Zurück: `ew = 1.0 if ev != 0 else 0.0`. + ew = 0.0 + add("Elliott", ev, ew, edet, used=False) + + # 7) Squeeze-Breakout — Event-Signal: stimmt NUR bei `active` mit (dann 2,0, + # gemessen 2-Stichproben-positiv, unter der Welle=3,0). armed/keine → Gewicht 0 + # — sonst dämpft das fast immer neutrale Modul die Bias-Nadel dauerhaft + # (Logikfehler bis 2026-07-15: w=2,0 stand permanent im Nenner → Nadel ~×0,8). + sq = (wave_snap or {}).get("squeeze") or {} + sq_active = sq.get("state") == "active" + sqv = (1 if sq.get("dir") == "LONG" else -1) if sq_active else 0 + add("Squeeze", sqv, 2.0 if sq_active else 0.0, + f"{sq.get('state') or '—'}" + (f" {sq.get('dir')}" if sq.get("dir") else "")) + + # 8) Chartmuster — User-Vorgabe 2026-07-30 („berücksichtige die Chartmuster + # in der Gesamtempfehlung, sobald eins erkannt wird"). Gewicht bewusst KLEIN: + # der fällige KONTROLLTEST (2026-07-30) zeigt, dass die Muster einen zwar in + # BEIDEN Hälften positiven, aber kleinen Zusatz über einen GENERISCHEN + # Breakout hinaus liefern — Muster ØR +0,206/+0,328 vs. beliebiger Swing-Bruch + # +0,184/+0,220 (Delta nur +0,022 in H1, +0,108 in H2). Der Löwenanteil des + # Ertrags kommt also vom Breakout+Trailing, nicht vom Muster. `forming` zählt + # nur halb, weil das Muster dort noch nicht bestätigt ist. Ziel-Trefferquote + # bleibt niedrig (23–26 %), die Stimme sagt also „Richtungstendenz", nicht + # „Kursziel". + # ⚠ ABGESENKT 1,0 → `_PAT_W`=0,25 (User-Vorgabe 2026-08-01 „geben wir dem + # Muster doch 5 %"). Anlass war die Nachmessung der 2026-07-31 ergänzten + # Handbuch-Muster: von den 8 gemessenen Alt-Typen schlägt nur `cup` die + # Kontrolle, und unter 6 neu getesteten Typen war `wedge_rise` der einzige + # Treffer — bei 6 Vergleichen ist das gut mit Zufall vereinbar. Empfehlung war + # Gewicht 0 (reine Anzeige); der User wollte die Stimme behalten, aber klein. + # 0,25 ist EMPIRISCH gesetzt, nicht geraten: über die letzten 4.000 + # `verdict_votes` liegt die Gewichtssumme der übrigen Module im Median bei + # 4,50 → Anteil 0,25/(4,50+0,25) = **5,3 %** (die alten 1,0 waren 18,2 %). + # Ändert sich das Modul-Gefüge, mit derselben Rechnung nachziehen. + # REINE ANZEIGE — der Bias steuert keine Order (die hängt an der Wellen- + # Headline); die Stimme wird in `verdict_votes` weiter mitgeloggt. + pv, pdet, pw = 0, "—", 0.0 + try: + _pats = (snap_parts_patterns or {}).get("patterns") or [] + # ⚠ NUR GEMESSENE Muster-Typen stimmen mit (Fix 2026-07-31): das Gewicht + # 1,0 stammt aus dem Kontrolltest (`backtest_patterns.py` gegen einen + # generischen Swing-Bruch), der nur Doppeltop/-boden, Tasse/inv. Tasse, + # SKS/inv. SKS und auf-/absteigendes Dreieck abdeckte. Die am selben Tag + # aus dem Handbuch ergänzten Typen (Flagge, Wimpel, Rechteck, Keil, + # Dreifach) sind REINE ANZEIGE — sonst würde ein validiertes Gewicht + # still mit unvalidierten Eingaben gefüttert (genau der Deployment-Drift- + # Fehler, der heute fünfmal aufgefallen ist). `measured` kommt aus + # `core/patterns.py`; fehlt das Feld (Alt-Snapshot), gilt es als gemessen. + _p0 = next((m for m in _pats + if m.get("dir") in ("bull", "bear") and m.get("measured", True)), + None) + if _p0: + pv = 1 if _p0["dir"] == "bull" else -1 + _conf = _p0.get("status") == "confirmed" + pw = _PAT_W if _conf else _PAT_W / 2 + pdet = f"{_p0.get('name', '—')} · {'bestätigt' if _conf else 'bildet sich'}" + except Exception: + pass + add("Muster", pv, pw, pdet) + + # 9/10) ORDERBUCH-Imbalance + LIQUIDITÄTS-TREND (User-Vorgabe 2026-07-30). + # ⚠ BEIDE sind UNGEMESSEN — anders als alle anderen Stimmen. Die Übertragbar- + # keit von Hyperliquid-Orderbuchdaten auf den Pepperstone-CFD wird gerade + # erst erhoben (`analyze_bookflow.py`, ab ~50k Snapshots ≈ 3 Tage erster Blick). + # ⚠ Der Lead-Lag-Test (2026-07-30, 72 h) zeigt sogar, dass **Pepperstone + # FÜHRT** (Peak bei k=−1, r=0,525) und HL folgt — HL ist ein Oracle-basierter + # HIP-3-Perp, also strukturell Follower. Das dämpft die Erwartung erheblich. + # Deshalb bewusst das KLEINSTE Gewicht im ganzen Verdict (0,5 je Stimme; + # zum Vergleich: Welle 3,0 · Squeeze 2,0 · Muster/Elliott/KI 1,0). + # Beide werden in `verdict_votes` mitgeloggt → in ein paar Wochen ist + # datenbasiert entscheidbar, ob sie bleiben, mehr Gewicht bekommen oder + # rausfliegen (wie TU 2026-07-06). + ov, odet, ow = 0, "—", 0.0 + lv, ldet, lw = 0, "—", 0.0 + try: + w = self.hl_walls.walls() + if w: + imb = w.get("imb5") + if isinstance(imb, (int, float)) and abs(imb) >= _OB_IMB_MIN: + ov = 1 if imb > 0 else -1 + # ⚠⚠ GEWICHT AUF 0 (2026-08-06). Das Orderbuch-Modul ist aus + # ZWEI unabhängigen Richtungen als nicht tragfähig gemessen: + # · `analyze_verdict_modules.py` (15.544 Zeilen): über **2.688 + # Episoden beidhälftig NEGATIV** (−0,054/−0,066 ×ATR, 49 % + # Treffer) — der einzige konsistent falsche Beitrag im Feld. + # · Das HL-Projekt selbst (`bookflow_report.txt`): Bid/Ask-Druck + # auf den CFD übertragen −0,61/−0,52 bp und KIPPT; die eigene + # Schwelle dort lautet „< ~3 bp ist von den Kosten gefressen". + # Dazu der Lead-Lag-Befund: **Pepperstone führt** (Peak k=−1 ≈ 6 s), + # HL-Richtung trifft nach 6 s zu 51 % (n=545) — alles + # Preis-Abgeleitete von HL läuft hinterher. + # Der CHIP bleibt (Imbalance ist als Kontext lesbar), nur die + # Stimme entfällt. Zurück: `ow = 0.5`. + ow = 0.0 + odet = f"Imbalance {imb:+.2f}" + elif isinstance(imb, (int, float)): + odet = f"Imbalance {imb:+.2f} (zu schwach)" + tr = w.get("trend") or {} + if tr.get("dir") in ("up", "down"): + lv = 1 if tr["dir"] == "up" else -1 + lw = 0.5 + ldet = f"{tr['dir']} {tr.get('slope_per_h', 0):+.2f}$/h" + except Exception: + pass + add("Orderbuch", ov, ow, odet, used=False) + add("Liq-Trend", lv, lw, ldet) + + # Bias nur über stimmberechtigte Module (weight>0); agree/total = wie viele + # der ANDEREN stimmberechtigten Module die Wellen-Richtung teilen (die Welle + # zählte sich vorher selbst mit — „3/6 für LONG" war geschönt). + wsum = sum(v["weight"] for v in votes) or 1.0 + bias = sum(v["weight"] * v["vote"] for v in votes) / wsum + others = [v for v in votes if v["name"] != "Welle" and v["weight"] > 0] + # Referenzrichtung für „agree": normal die Welle. Sagt sie WARTEN (91 % der + # Zeit), gibt es keine Wellen-Richtung — dann zählt die Übereinstimmung mit + # der BIAS-Richtung (Fix 2026-07-30). Vorher stand dort immer „0/N", obwohl + # sich die anderen Module durchaus einig waren. + ref = wv if wv != 0 else (1 if bias > 0 else -1 if bias < 0 else 0) + agree = sum(1 for v in others if ref != 0 and v["vote"] == ref) + return { + "headline": wsig, "conf": (wave_signal or {}).get("conf_pct", 0), + "tf": (wave_snap or {}).get("tf"), # Zeitebene, für die die Empfehlung gilt + "bias": round(bias, 3), "agree": agree, "total": len(others), + # ⚠ `ref_dir` = WORAUF sich `agree` bezieht (Fix 2026-07-31). Bei WARTEN + # ist das NICHT die Headline, sondern die Bias-Richtung — das Frontend + # beschriftete es vorher mit der Headline und schrieb dadurch + # „stark LONG · 4/4 Module für WARTEN". Die Richtung kommt jetzt VON HIER, + # statt im Frontend aus `bias` nachgebaut zu werden (dort galt ein + # ±0,05-Totband, hier `bias > 0` — die Nachbildung wäre am Rand + # auseinandergelaufen; genau die Sorte Divergenz, die heute mehrfach + # aufgefallen ist). + "ref_dir": "LONG" if ref > 0 else "SHORT" if ref < 0 else None, + # Wie viele Module HABEN gerade keine Aussage (Gewicht 0)? Ohne diese Zahl + # las sich „4/4" widersprüchlich zu den 9 sichtbaren Chips. + "silent": sum(1 for v in votes if not v["weight"]), + "votes": votes, + } + + # ══════════════════════════════════════════════════════════════════════ + # SNAPSHOT (ein JSON-fähiger Zustand für /api/snapshot + WebSocket) + # ══════════════════════════════════════════════════════════════════════ + _SNAP_TTL_S = 0.8 # geteilter Snapshot gilt so lange als frisch + + def set_manual_margin(self, eur: float) -> float: + """Feste Einsatz-Margin in Kontowährung (User-Wunsch 2026-08-04). + 0 = automatisch (wie bisher `margin_buffer_pct` % der freien Margin). + + ⚠ Wirkt auf ALLE neuen Positionen — manuelle wie autonome (Squeeze/Signal). + Die freie Margin bleibt Obergrenze; `calc_lots` deckelt und loggt das. + Neustart-fest über `runtime_state.json`.""" + from core.config import set_manual_margin as _set + v = _set(eur) + self._manual_margin = v + self._save_runtime_state() + return v + + def set_margin_pct(self, pct: float) -> float: + """Einsatz als PROZENT der freien Margin (User-Wunsch 2026-08-05). + + Das ist die zweite Hälfte des Einsatz-Felds: `set_manual_margin` gibt einen + FESTEN Betrag vor, das hier den ANTEIL. Beides existierte bisher nur in der + `ini` (`margin_buffer_pct`, live 95) — die Prozent-Schraube hatte keine UI. + + ⚠ VORRANG: ein fester Betrag (`manual_margin > 0`) schlägt den Prozentsatz — + `calc_lots` prüft ihn zuerst. Der Prozentsatz bleibt dabei die Obergrenze + (freie Margin × pct), ein zu hoher Fixbetrag wird darauf gedeckelt. + ⚠ Wirkt auf ALLE neuen Positionen, auch die autonomen. Neustart-fest. + ⚠ `set_margin_buffer` klemmt auf 1–99 % — 100 % wäre ohnehin unhaltbar + (kein Puffer für Spread/Swap → Margin-Call beim Öffnen).""" + from core.config import set_margin_buffer as _set, get_margin_buffer + _set(pct) + self._margin_pct = round(get_margin_buffer() * 100.0, 1) + self._save_runtime_state() + return self._margin_pct + + def _manual_stats_cached(self) -> dict | None: + """Kennzahlen der manuellen Trades für die Karte „Manuelle Trades" + (2026-08-04). Gleiches Cache-Muster wie `_alignment_cached` — die + Aggregation läuft über die ganze `trades`-Tabelle und hat im 1-s-Snapshot + nichts zu suchen.""" + now = time.time() + if now - getattr(self, "_manual_ts", 0.0) > 120: + try: + self._manual_cache = self.history.manual_stats(30) + except Exception as e: + log.debug(f"manual_stats: {e}") + self._manual_cache = None + self._manual_ts = now + return getattr(self, "_manual_cache", None) + + def _alignment_cached(self) -> dict | None: + """Ausrichtungs-Split (mit/gegen/ohne Empfehlung) für den Order-Dialog — + ehrliche eigene Zahlen statt generischem Hinweis (2026-07-24; Gegen-Signal- + Trades sind die gemessene Kern-Leckage). + ⚠ Seit 2026-08-06 über die **volle Historie** (1.216 Trades) statt der + letzten 40 — der Befund ist dort beidhälftig robust, über 40 war er eine + Momentaufnahme. Zusätzlich je Lot normiert (`pnl_lot`), weil absolute + Euro-Vergleiche bei wechselnder Positionsgröße ungültig sind. + DB-Query gecacht (~120 s), Snapshot bleibt billig.""" + now = time.time() + if now - getattr(self, "_align_ts", 0.0) > 120: + try: + self._align_cache = self.history.alignment_stats(0) + except Exception as e: + log.debug(f"alignment_stats: {e}") + self._align_cache = None + self._align_ts = now + return getattr(self, "_align_cache", None) + + def _squeeze_b5_cached(self) -> dict | None: + """B5-Stand des Auto-Squeeze fuer die Breakout-Karte (2026-08-19). + + ⚠⚠ WARUM EIN EIGENES FELD und nicht `squeeze_monitor.live`: der Monitor + rechnet ueber ALLE SQUEEZE_*-Trades (n=42). Die B5-Abbruchregel zaehlt + aber ausdruecklich **ab dem 01.08.** (n=11). Beide Zahlen im selben Bild + zu zeigen, ohne sie zu trennen, waere genau die Sorte stille + Fenster-Verwechslung, an der `pbreak_accuracy_v2` und die + Verdict-Faelligkeit schon aufgelaufen sind. + + ⚠ Der Monitor meldet zudem `verdict: ontrack` aus der **Mechanik** + (Regel neu gerechnet, OeR +0,163 / PF 1,24) — waehrend die LIVE-Bilanz + im selben Moment bei Verhaeltnis 0,14 / PF 0,39 steht. Genau diese + Luecke ist der B4-Befund; die Karte muss beide Zahlen nebeneinander + zeigen, sonst liest sich 'ontrack' wie eine Entwarnung. + + DB-Query ~120 s gecacht (Muster wie `_alignment_cached`).""" + now = time.time() + if now - getattr(self, "_b5_ts", 0.0) > 120: + self._b5_cache = None + try: + ab = int(datetime(2026, 8, 1, tzinfo=_BERLIN).timestamp()) + rows = self.history.fetch_squeeze_since(ab) + g = [x for x in rows if x > 0] + v = [x for x in rows if x <= 0] + og = (sum(g) / len(g)) if g else 0.0 + ov = abs(sum(v) / len(v)) if v else 0.0 + self._b5_cache = { + "n": len(rows), "ziel": 20, + "wins": len(g), "losses": len(v), + "avg_win": round(og, 2), "avg_loss": round(-ov, 2), + # die beiden Groessen, an denen die Regel haengt + "ratio": round(og / ov, 2) if ov else None, + "pf": round(sum(g) / abs(sum(v)), 2) if v and sum(v) else None, + "sum_eur": round(sum(rows), 2), + "latte": 1.0, + } + except Exception as e: + log.debug(f"squeeze_b5: {e}") + self._b5_ts = now + return getattr(self, "_b5_cache", None) + + def _hl_ctx_refresh(self): + """Holt den HL-Kontext im Hintergrund (s. Aufrufstelle im Trend-Loop).""" + try: + d = self.hl_walls.market() + if d: + self._hl_ctx = d + except Exception as e: + log.debug(f"hl market: {e}") + finally: + self._hl_ctx_busy = False + + def _block_mix_cached(self) -> dict | None: + """WELCHE Gates blocken heute, und wie oft? (2026-08-06) + + WARUM SICHTBAR: auf die Frage „warum gab es heute so wenige Empfehlungen?" + liess sich das nur per DB-Abfrage beantworten. Real am 06.08.: von 646 + Zeilen waren **6 ungeblockt** — entry_room 274 · min_conf 225 · + breakout_pending 88 · htf_counter 50. Die Karte sagt das jetzt selbst. + Reine Anzeige, ~120 s gecacht.""" + now = time.time() + if now - getattr(self, "_blockmix_ts", 0.0) > 120: + try: + import sqlite3 + con = sqlite3.connect(HISTORY_DB_FILE, timeout=5.0) + rows = con.execute( + "SELECT COALESCE(block_reason,'') b, COUNT(*) n FROM recommendations " + "WHERE date(timestamp,'unixepoch','localtime') = date('now','localtime') " + "GROUP BY b ORDER BY n DESC").fetchall() + con.close() + ges = sum(r[1] for r in rows) + self._blockmix_cache = { + "total": ges, + "frei": next((r[1] for r in rows if not r[0]), 0), + "top": [{"grund": r[0], "n": r[1]} for r in rows if r[0]][:4], + } if ges else None + except Exception as e: + log.debug(f"block_mix: {e}") + self._blockmix_cache = None + self._blockmix_ts = now + return getattr(self, "_blockmix_cache", None) + + def _m15_track_cached(self) -> dict | None: + """Selbstmessung der M15-Karte — Trefferquote ihrer eigenen Lesart. + + ⚠ Gegen 50 % zu lesen. Gemessen wird NUR die bedingte Lesart am Level + („Abprall → eher abwaerts"), nicht eine freie Richtungsprognose — eine + solche ist im Projekt gemessen unmoeglich (AUC 0,50 oos / 0,52 + in-sample). ~120 s gecacht.""" + now = time.time() + if now - getattr(self, "_m15track_ts", 0.0) > 120: + try: + self._m15track_cache = (self.history.m15_accuracy(200) + if self.history else None) + except Exception as e: + log.debug(f"m15_track: {e}") + self._m15track_cache = None + self._m15track_ts = now + return getattr(self, "_m15track_cache", None) + + def _entry_gap_cached(self) -> dict | None: + """AUSFUEHRUNGS-QUALITAET: kommt der Bot-Einstieg AM Level an? + + ⚠⚠ WARUM DAS DIE WICHTIGSTE UNBEOBACHTETE ZAHL WAR. Der Umbau auf + ruhende Stop-Orders (05.08.) hatte als einziges Erfolgskriterium: der + Abstand Einstieg↔Level muss gegen 0 gehen (vorher Median +0,275×ATR). + Das ist der groesste je gemessene Hebel des Projekts (+0,32 R gegenueber + Market) — und niemand hat je hineingesehen. + + ⚠ DIE ERFOLGSMELDUNG IST PARADOX: eine per PENDING gefuellte Order + erzeugt **keine** `AUTO-SQUEEZE-ENTRY`-Logzeile (die entsteht nur im + Market-Fallback). Bot-Trades OHNE Log-Treffer sind also der ERFOLG. + Hier wird deshalb das gezaehlt, was ohne Logdatei bestimmbar ist: + wie viele Bot-Trades es seit dem Umbau gab und wie viele davon ueber + den Market-Fallback liefen (erkennbar am Zaehler `squeeze_entry_count` + bzw. am Setup-Tag). ~300 s gecacht — DB-Abfrage gehoert nicht in den + 1-s-Snapshot.""" + now = time.time() + if now - getattr(self, "_entrygap_ts", 0.0) > 300: + try: + import sqlite3 + con = sqlite3.connect(HISTORY_DB_FILE, timeout=5.0) + # Stichtag = Umbau auf ruhende Stop-Orders (05.08. 08:55) + seit = 1786000500 + n, = con.execute( + "SELECT COUNT(*) FROM trades WHERE entry_time >= ? " + "AND setup LIKE 'SQUEEZE%'", (seit,)).fetchone() + con.close() + self._entrygap_cache = {"n_seit_umbau": n, "soll": 12} + except Exception as e: + log.debug(f"entry_gap: {e}") + self._entrygap_cache = None + self._entrygap_ts = now + return getattr(self, "_entrygap_cache", None) + + def _rec_track_cached(self) -> dict | None: + """Selbst-Kalibrierung: Trefferquote der letzten Empfehlungs-Episoden. + + ⚠ Gegen 50 % zu lesen, nicht gegen 0 — es ist eine Richtungsaussage. + Gemessener Ausgangsstand (`analyze_signal_live.py`, 1.071 Episoden): + 48–51 %. Diese Zeile macht laufend sichtbar, ob sich das aendert, statt + dass es erst auf Nachfrage rueckwirkend gemessen wird. ~120 s gecacht.""" + now = time.time() + if now - getattr(self, "_rectrack_ts", 0.0) > 120: + try: + self._rectrack_cache = (self.history.rec_accuracy(200) + if self.history else None) + except Exception as e: + log.debug(f"rec_accuracy: {e}") + self._rectrack_cache = None + self._rectrack_ts = now + return getattr(self, "_rectrack_cache", None) + + def _loss_streak_today(self) -> tuple[int, int]: + """(Verlust-Trades heute, Trades heute) — für den Mental-Check der Entry- + Checkliste („nach 2 Losses Session beenden"). DB-Query ~120 s gecacht.""" + now = time.time() + if now - getattr(self, "_streak_ts", 0.0) > 120: + try: + import datetime as _dt + t0 = _dt.datetime.now(_BERLIN).replace( + hour=0, minute=0, second=0, microsecond=0).timestamp() + with self.history._connect() as conn: + rows = conn.execute( + "SELECT pnl FROM trades WHERE exit_time>=? AND pnl IS NOT NULL", + (t0,)).fetchall() + self._streak_cache = (sum(1 for r in rows if (r[0] or 0) < 0), len(rows)) + except Exception as e: + log.debug(f"loss_streak: {e}") + self._streak_cache = (0, 0) + self._streak_ts = now + return getattr(self, "_streak_cache", (0, 0)) + + def _entry_checklist(self, side: str, snap_parts: dict) -> dict: + """Prüft die Trade-Setup-Checkliste (User-Vorgabe 2026-07-30) für eine + geplante Eröffnung und liefert die VERLETZTEN Punkte zurück. + + Warum server-seitig: die Daten liegen alle hier (TF-Ampel, Struktur, Level, + News, Tages-P&L, Trade-Historie) — das Frontend soll nur anzeigen. + + Jeder Punkt: `state` = "fail" (klar dagegen) | "warn" (Vorsicht) | "ok". + Reine ENTSCHEIDUNGSHILFE — sie blockt nichts (wie der bisherige Dialog), + macht aber sichtbar, was gegen den Trade spricht. Die Gewichtung ist NICHT + aus einem Backtest, sondern aus (a) den im Projekt GEMESSENEN Leckagen + (Handeln ohne Signal, Nacht/teure Stunden, Verlust-Serien) und (b) der + vom User vorgegebenen Checkliste.""" + want = "LONG" if side == "long" else "SHORT" + d = 1 if want == "LONG" else -1 + items: list[dict] = [] + add = lambda k, st, lab, det: items.append( + {"key": k, "state": st, "label": lab, "detail": det}) + + wave = snap_parts.get("wave") or {} + market = snap_parts.get("market") or {} + # ── 1. Signal-Deckung (die gemessene Kern-Leckage) ─────────────────── + sig = (wave.get("signal") or "WARTEN") if isinstance(wave.get("signal"), str) \ + else ((wave.get("signal") or {}).get("signal") or "WARTEN") + if sig == want: + add("signal", "ok", "Empfehlung deckt den Trade", f"Signal {sig}") + elif sig in ("WARTEN", "—", None): + add("signal", "fail", "Keine Empfehlung (WARTEN)", + "gemessen: Trades ohne Signal sind die größte Verlustquelle") + else: + add("signal", "fail", f"Empfehlung steht GEGEN dich ({sig})", + "gemessen: 40 % Trefferquote gegen das Signal") + # ── 2. HTF-Trend klar? (Checklisten-Punkt 1) ───────────────────────── + turns = wave.get("tf_turns") or {} + htf = [turns.get(t, {}).get("dir") if isinstance(turns.get(t), dict) + else turns.get(t) for t in ("M30", "H1")] + htf = [x for x in htf if x in (1, -1, 0)] + if htf and all(x == d for x in htf): + add("htf", "ok", "HTF-Trend (M30+H1) trägt die Richtung", "") + elif htf and any(x == -d for x in htf): + add("htf", "fail", "HTF-Trend steht dagegen (M30/H1)", + "deine Regel: nur MIT dem HTF-Trend, niemals dagegen") + else: + add("htf", "warn", "HTF-Trend unklar/seitwärts", "kein klarer M30/H1-Trend") + # ── 3. Entry-Raum bis zum Gegenlevel (gemessen monoton) ────────────── + try: + dl = self._draw_levels() + atr = float((wave.get("pb_feats") or {}).get("atr") or 0.0) or 0.2 + bid = market.get("bid") + tgt = (dl.get("res") or [None])[0] if d > 0 else (dl.get("sup") or [None])[0] + if bid and tgt: + room = abs(tgt - bid) / atr + if room < 0.6: + add("room", "fail", f"Kein Raum: Level nur {room:.1f}×ATR entfernt", + "gemessen: <0,6×ATR Raum = negativer Erwartungswert") + elif room < 1.0: + add("room", "warn", f"Wenig Raum ({room:.1f}×ATR bis zum Level)", "") + else: + add("room", "ok", f"Raum bis zum Level: {room:.1f}×ATR", "") + except Exception: + pass + # ── 4. News-Konflikt (Checklisten-Punkt 3) ─────────────────────────── + ns = snap_parts.get("news_sentiment") + if isinstance(ns, (int, float)) and abs(ns) >= 0.5: + if (ns > 0) != (d > 0): + add("news", "fail", + f"News-Flow steht dagegen ({ns:+.2f})", + "stark bullische/bärische Schlagzeilen gegen deine Richtung") + else: + add("news", "ok", f"News-Flow stützt ({ns:+.2f})", "") + # ── 5. Kosten (Spread/ATR — gemessen Ø 0,265) ──────────────────────── + cr = snap_parts.get("cost_ratio") + if isinstance(cr, (int, float)) and cr > 0: + if cr >= 0.32: + add("cost", "fail", f"Kosten hoch ({cr:.2f}×ATR)", + "gemessen: ab ~0,32×ATR frisst der Spread den Edge") + elif cr >= 0.20: + add("cost", "warn", f"Kosten erhöht ({cr:.2f}×ATR)", "") + else: + add("cost", "ok", f"Kosten günstig ({cr:.2f}×ATR)", "") + # ── 6. Mental-Check: Verlust-Serie heute (Checklisten-Punkt 5) ─────── + losses, total = self._loss_streak_today() + day_total = float((snap_parts.get("day_pl") or {}).get("total") or 0.0) + if losses >= 2: + bal = float(self.data.balance or 0.0) + pct = (abs(day_total) / bal * 100.0) if (bal and day_total < 0) else 0.0 + add("streak", "fail", + f"Heute schon {losses} Verluste ({total} Trades, {day_total:+.0f} €" + + (f" = {pct:.0f}% des Kontos)" if pct else ")"), + "deine Regel: nach 2 Losses Session beenden") + elif day_total < 0: + add("streak", "warn", f"Tag im Minus ({day_total:+.0f} €)", "") + else: + add("streak", "ok", f"Tagesbilanz {day_total:+.0f} €", "") + # ── 7. Nacht-/Kostenfalle (gemessen: 0–7 Uhr Berlin) ───────────────── + try: + hb = datetime.now(_BERLIN).hour + if hb in _SQUEEZE_NIGHT: + add("hour", "fail", f"Nacht-Stunde ({hb}:00 Berlin)", + "gemessen: 0–7 Uhr = Kostenfalle (Spread ÷ dünner ATR)") + except Exception: + pass + # ── 8. Wirtschaftskalender (Handbuch: „keine Trades vor High-Impact-News") + # ⚠ NICHT gemessen (zu wenige Events für eine 2-Stichproben-Aussage) — + # deshalb hier nur „warn", nicht „fail": es soll den Verdict nicht allein + # auf `stop` kippen. Der Bot selbst pausiert im Fenster (autonome Entries). + try: + bl = self.calendar.blackout() + if bl: + m = bl["minutes"] + when = (f"in {m:.0f} min" if m > 0 else f"vor {abs(m):.0f} min") + add("event", "warn", f"News-Fenster: {bl['title']} ({when})", + "Handbuch-Regel: 15 min um High-Impact-Events nicht handeln " + "(im Projekt nicht gegengemessen)") + else: + nx = self.calendar.next_event() + if nx and nx["minutes"] <= 90: + add("event", "ok", + f"Nächstes Event: {nx['title']} in {nx['minutes']} min", "") + except Exception: + pass + fails = sum(1 for x in items if x["state"] == "fail") + warns = sum(1 for x in items if x["state"] == "warn") + return {"side": want, "items": items, "fails": fails, "warns": warns, + "verdict": ("stop" if fails >= 2 else "warn" if (fails or warns) else "ok")} + + def snapshot_cached(self) -> dict: + """Teilt EINEN frisch berechneten Snapshot über alle WS-Clients/HTTP- + Aufrufe (TTL ~0,8 s). Spart SQLite/Verdict-Last bei mehreren Geräten — + unabhängig von der Client-Anzahl wird nur ~1×/TTL gerechnet.""" + now = time.time() + with self._snap_lock: + if self._snap_cache and (now - self._snap_cache_ts) < self._SNAP_TTL_S: + return self._snap_cache + snap = self.snapshot() # teuer → außerhalb des Locks rechnen + with self._snap_lock: + self._snap_cache = snap + self._snap_cache_ts = time.time() + return snap + + def _fetch_m5_monitor(self, days: int = 30): + """M5-Bars für den Squeeze-Monitor (unter mt5_lock, aus der Server-Instanz).""" + import MetaTrader5 as mt5 + from core.mt5_utils import mt5_lock + sym = self.data.symbol + if not sym: + return None + with mt5_lock(timeout=5) as got: + if not got: + return None + bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, days * 288 + 30) + si = mt5.symbol_info(sym) + point = si.point if si else 0.01 + if bars is None or len(bars) == 0: + return None + return {"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]} + + def _evaluate_pbreak_predictions(self, window_min: int = 60): + """Wertet offene Abprall/Durchbruch-Vorhersagen anhand von `candles_m1` aus — + DIESELBE Definition wie beim Modell-Training (`_BRK_ATR=0,5`/`_BRK_W=12` M5- + Bars = 60 min in `backtest_srclose_prob.py`): erreicht der Kurs zuerst + `confirm_price` → 'break', zuerst `reject_price` → 'bounce', keins von beiden + binnen 60 min → 'bounce' (Timeout, wie im Training). Self-throttled (~5 min), + aus `_trend_loop`. `candles_m1.time` ist rohe Broker-Zeit — Offset nötig.""" + now = time.time() + if now - getattr(self, "_pbreak_eval_ts", 0.0) < 300: + return + self._pbreak_eval_ts = now + try: + import sqlite3 + off = self.trader._broker_offset_s() + con = sqlite3.connect(HISTORY_DB_FILE, timeout=5.0) + con.row_factory = sqlite3.Row + pend = con.execute( + "SELECT id, ts, direction, predicted, confirm_price, reject_price " + "FROM pbreak_predictions WHERE outcome IS NULL" + ).fetchall() + for r in pend: + d = 1 if r["direction"] == "LONG" else -1 + t0_b = r["ts"] + off # Vorhersage-Zeit in Broker-Zeit + t1_b = t0_b + window_min * 60 + bars = con.execute( + "SELECT h, l, time FROM candles_m1 WHERE time > ? AND time <= ? " + "ORDER BY time ASC", (t0_b, t1_b)).fetchall() + outcome = None + for b in bars: + hit_confirm = (b["h"] >= r["confirm_price"]) if d > 0 else (b["l"] <= r["confirm_price"]) + hit_reject = (b["l"] <= r["reject_price"]) if d > 0 else (b["h"] >= r["reject_price"]) + if hit_confirm: + outcome = "break"; break + if hit_reject: + outcome = "bounce"; break + if outcome is None and time.time() >= (r["ts"] + window_min * 60): + outcome = "bounce" # Timeout, wie im Training (brk=False) + if outcome: + correct = 1 if r["predicted"] == outcome else 0 + con.execute( + "UPDATE pbreak_predictions SET outcome=?, outcome_ts=?, correct=? WHERE id=?", + (outcome, int(time.time()), correct, r["id"])) + con.commit(); con.close() + except Exception as e: + log.debug(f"pbreak_predictions auswerten: {e}") + + def _log_m15_state(self, s: dict | None, market): + """Loggt einen Zustandswechsel der M15-Karte — die Rueckkopplung, die + der Karte bisher fehlte. + + ⚠⚠ NUR BEI WECHSEL. Wer je Tick loggt, bekommt dieselbe Cluster- + Verzerrung, die am 07.08. elf widerlegte Hypothesen ausgeloest hat + (roh 922 Zeilen 52,6 %, entkoppelt 93 Zeilen 45,2 %) — und die mein + erster Tagestest reproduzierte (46 fortlaufende Bars als 46 Trades). + Der Zustand wird aus Status UND Lesart gebildet: wechselt nur das + Level, ist es ein neuer Zustand; bleibt alles gleich, passiert nichts. + ⚠ Fail-safe: ein Fehler hier darf den Snapshot nie stoeren. + """ + try: + if not s: + return + zonen = s.get("zonen") or [] + kosten = (s.get("kosten") or {}).get("ratio") + atr = s.get("atr_m15") + bid = (market or {}).get("bid") + if not atr or not bid: + return + # ⚠⚠ Status und Lesart werden NICHT mehr hier nachgerechnet + # (2026-08-12). Sie kommen aus `_m15_setup` — EINE Quelle fuer + # Anzeige UND Messung, mit Hysterese gegen das Schwellen-Flattern. + # Vorher gab es drei unabhaengige Fassungen, und sie wichen bereits + # ab: dieser Logger kannte den „kein Veto"-Fall des Frontends nicht. + nah = s.get("nah") + status = s.get("status") + lesart = s.get("lesart") + if not status: + return + # ⚠ Das Level bleibt im Schluessel: ein anderes Level IST ein anderes + # Setup. Gemessen ist es ohnehin nicht der Treiber (nur 7,6 % der + # Wechsel gingen allein darauf zurueck). + schluessel = (status, lesart, + None if not nah else round(nah["level"], 3)) + if schluessel == getattr(self, "_m15_last_key", None): + return # kein Wechsel → nichts loggen + self._m15_last_key = schluessel + self.history.log_m15_state( + status=status, veto=(s.get("bias") or {}).get("verboten"), + kosten=kosten, level=None if not nah else nah["level"], + gap_atr=None if not nah else nah["gap_atr15"], + p_break=None if not nah else nah.get("p_break"), + lesart=lesart, price=bid, atr=atr) + except Exception as e: + log.debug(f"m15_state log: {e}") + + def _check_m15_alert(self, s: dict | None): + """Telegram, sobald die M15-Karte auf GRÜN mit gerichteter Lesart springt. + + User-Wunsch 2026-08-14: „wenn es eine Empfehlung gibt (grün), schicke + eine Telegram-Nachricht." + + ⚠⚠ ENTPRELLUNG IST HIER DER GANZE BAU, nicht ein Detail. Roh gemessen + (`m15_states`, 3 Tage nach der Hysterese vom 12.08.) springt die Karte + **85× am Tag** auf grün — das waere Spam, kein Signal. Zwei Stufen, + beide an den echten Daten kalibriert statt geraten: + · **Level + Richtung** als Schluessel: gruen→gelb→gruen am SELBEN + Level ist dasselbe Setup, das flackert, keine neue Gelegenheit. + 85 → 23,7/Tag. + · **Mindestabstand** `m15_alert_cooldown_min` (30) → 13,3/Tag, + und das **Stundenfenster** `m15_alert_hours` (8–20) → **7,7/Tag**. + Zum Vergleich: die Close-Benachrichtigung liefert 2,4–6,1/Tag. + ⚠ Nach `_ALERT_NEU_S` (4 h) darf dasselbe Level erneut melden — sonst + verschwiegen wir ein Setup, das den ganzen Tag wiederkommt. + + ⚠⚠ EHRLICH IM TEXT: die Karte ist gemessen KEIN Richtungssignal. Sie + automatisch zu handeln faellt durch (`backtest_m15_auto.py` 13.08.: + ØR −0,162/−0,094, KI ohne Null, schlechter als HTF-Richtung UND als das + eigene Gegenteil). Die Meldung ist deshalb ein **Hinweis zum Hinsehen** + fuer den Menschen — dessen Auswahl-Vorteil unabhaengig belegt ist + (+3,20 €/Lot ueber 146 Trades) — und nennt das im Text. + `m15_alert=false` schaltet ab. + """ + if not self._m15_alert or not s: + return + try: + if s.get("status") != "gruen" or s.get("lesart") not in ("auf", "ab"): + self._m15_alert_war = False + return + if self._m15_alert_war: # nur die FLANKE, nicht der Zustand + return + self._m15_alert_war = True + now = time.time() + h = datetime.now(_BERLIN).hour + lo, hi = self._m15_alert_hours + if not (lo <= h < hi): + return + nah = s.get("nah") or {} + key = (round(nah.get("level") or 0, 2), s.get("lesart")) + alt_ts = self._m15_alert_ts + if (now - alt_ts) < self._m15_alert_cd: + return + if key == self._m15_alert_key and (now - alt_ts) < _ALERT_NEU_S: + return + tg = self.cfg["telegram"] + if tg.get("enabled", "false").strip().lower() != "true": + return + self._m15_alert_ts, self._m15_alert_key = now, key + pb = nah.get("p_break") + lese = ("unentschieden" if pb is None or 45 <= pb <= 55 else + f"{pb} % Bruch" if pb > 55 else f"{100 - pb} % Abprall") + seite = "Widerstand" if nah.get("seite") == "res" else "Support" + erl = {"SHORT": "LONG", "LONG": "SHORT"}.get( + (s.get("bias") or {}).get("verboten")) + k = (s.get("kosten") or {}).get("ratio") + send_telegram( + f"🟢 M15 · Setup bereit\n" + f"{seite} M30 {nah.get('level')} · {nah.get('gap_atr15')}×ATR_M15\n" + f"Level {lese}" + + (f" · erlaubt wäre {erl}" if erl else " · kein Trend-Veto") + + (f" · Kosten {k}×ATR" if k is not None else "") + + "\nBedingung erfüllt — keine Richtungsprognose " + "(gemessen 48–51 % Treffer).", + tg.get("bot_token", "").strip(), tg.get("chat_id", "").strip()) + log.info(f"📨 M15-Alert {seite} {nah.get('level')} ({lese})") + except Exception as e: + log.debug(f"m15_alert: {e}") + + def _log_cone_check(self, cone: dict | None): + """Legt je Horizont EINEN nicht-ueberlappenden Abdeckungs-Check an. + + ⚠⚠ NICHT-UEBERLAPPEND ist hier kein Detail: ein 120-min-Fenster alle + 5 min waere 24-fach ueberlappend. Genau daran ist `analyze_hl_funding.py` + (06.08.) aufgelaufen, und die Faelligkeitsbedingung `pbreak_accuracy_v2` + ueberschaetzte damit die Beweismenge um das Zehnfache (07.08.). Ein neuer + Check startet je Horizont erst, wenn der vorige abgelaufen ist.""" + try: + if not cone or not self.history: + return + now = time.time() + letzte = getattr(self, "_cone_check_ts", None) + if letzte is None: + letzte = self._cone_check_ts = {} + for lv in cone.get("levels") or []: + if lv.get("band") != 80: + continue + b = lv.get("bars") + fenster = (lv.get("minutes") or 0) * 60 + if not b or not fenster: + continue + if now - letzte.get(b, 0.0) < fenster: + continue # voriger Check laeuft noch + letzte[b] = now + self.history.log_cone_check( + bars=b, band=80, price=cone.get("price") or 0.0, + lo=lv["lo"], hi=lv["hi"], skala=lv.get("skala") or 1.0) + except Exception as e: + log.debug(f"cone_check log: {e}") + + def _evaluate_cone_checks(self): + """Loest faellige Kegel-Checks auf und fuehrt den ACI-Schritt aus. + + ⚠ Der Kegel war dokumentiert **3–5 Pp zu eng** (80 % nominal → real + 76,7 / 76,6 / 75,3 %) und blieb es seit dem 31.07., weil Nachfitten das + Problem nur verschiebt. ACI justiert stattdessen laufend nach. + ⚠ `candles_m1.time` ist ROHE Broker-Zeit — Offset noetig.""" + now = time.time() + if now - getattr(self, "_cone_eval_ts", 0.0) < 300: + return + self._cone_eval_ts = now + try: + import sqlite3 + from core.cone import MINUTES, aci_update + off = self.trader._broker_offset_s() + con = sqlite3.connect(HISTORY_DB_FILE, timeout=5.0) + con.row_factory = sqlite3.Row + offen = con.execute( + "SELECT id, ts, bars, band, lo, hi, skala FROM cone_checks " + "WHERE done IS NULL OR done = 0").fetchall() + skalen = dict(getattr(self, "_cone_skalen", {}) or {}) + for r in offen: + ziel = r["ts"] + MINUTES.get(r["bars"], 30) * 60 + if now < ziel: + continue + row = con.execute( + "SELECT c FROM candles_m1 WHERE time <= ? AND time > ? " + "ORDER BY time DESC LIMIT 1", + (ziel + off, ziel + off - 900)).fetchone() + if not row: + if now >= r["ts"] + 6 * 3600: # keine Bars mehr zu erwarten + con.execute("UPDATE cone_checks SET done=1 WHERE id=?", (r["id"],)) + continue + drin = 1 if r["lo"] <= row["c"] <= r["hi"] else 0 + con.execute("UPDATE cone_checks SET drin=?, done=1 WHERE id=?", + (drin, r["id"])) + s = float(skalen.get(r["bars"]) or 1.0) + skalen[r["bars"]] = aci_update(s, verfehlt=(drin == 0), + band=r["band"] or 80) + con.commit(); con.close() + if skalen != getattr(self, "_cone_skalen", None): + self._cone_skalen = skalen + self._save_runtime_state() # Neustart-fest + log.info("Kegel-ACI: Skalen " + ", ".join( + f"{k}→{v:.3f}" for k, v in sorted(skalen.items()))) + except Exception as e: + log.debug(f"cone_checks auswerten: {e}") + + def _evaluate_m15_states(self): + """Traegt den Forward-Return offener M15-Zustaende nach. + + ⚠ Gewertet wird die LESART, nicht der Status: `hit=1`, wenn der Kurs in + die Richtung lief, die die Karte am Level angezeigt hat. Zustaende ohne + gerichtete Lesart (Muenzwurf-Zone 45–55 %) bekommen keine Richtung und + zaehlen in der Quote nicht mit. + ⚠ `candles_m1.time` ist ROHE Broker-Zeit — Offset noetig (die im Projekt + mehrfach dokumentierte Falle).""" + now = time.time() + if now - getattr(self, "_m15_eval_ts", 0.0) < 300: + return + self._m15_eval_ts = now + try: + import sqlite3 + off = self.trader._broker_offset_s() + con = sqlite3.connect(HISTORY_DB_FILE, timeout=5.0) + con.row_factory = sqlite3.Row + offen = con.execute( + "SELECT id, ts, lesart, price, atr, ret30, ret60 FROM m15_states " + "WHERE done IS NULL OR done = 0").fetchall() + for r in offen: + if r["lesart"] not in ("auf", "ab") or not r["atr"] or r["atr"] <= 0: + con.execute("UPDATE m15_states SET done=1 WHERE id=?", (r["id"],)) + continue + d = 1 if r["lesart"] == "auf" else -1 + sets, args = [], [] + for feld, minuten in (("ret30", 30), ("ret60", 60)): + if r[feld] is not None: + continue + ziel = r["ts"] + minuten * 60 + if now < ziel: + continue + row = con.execute( + "SELECT c FROM candles_m1 WHERE time <= ? AND time > ? " + "ORDER BY time DESC LIMIT 1", + (ziel + off, ziel + off - 900)).fetchone() + if row: + v = (row["c"] - r["price"]) * d / r["atr"] + sets.append(f"{feld}=?"); args.append(round(v, 4)) + sets.append(f"hit{feld[3:]}=?"); args.append(1 if v > 0 else 0) + if now >= r["ts"] + 60 * 60 and (r["ret60"] is not None + or any("ret60" in x for x in sets)): + sets.append("done=?"); args.append(1) + elif now >= r["ts"] + 6 * 3600: + sets.append("done=?"); args.append(1) # keine Bars mehr zu erwarten + if sets: + args.append(r["id"]) + con.execute(f"UPDATE m15_states SET {', '.join(sets)} WHERE id=?", args) + con.commit(); con.close() + except Exception as e: + log.debug(f"m15_states auswerten: {e}") + + def _evaluate_rec_outcomes(self): + """Traegt fuer offene Empfehlungs-Episoden den Forward-Return nach. + + ⚠ `candles_m1.time` ist ROHE Broker-Zeit (UTC+3), `rec_outcomes.ts` ist + lokale Epoch — der Offset muss drauf (dieselbe Falle wie bei + `_evaluate_pbreak_predictions`). Genommen wird der letzte M1-Close bis + zum Zielzeitpunkt; fehlen Bars (Wochenende, Broker-Ausfall), bleibt die + Zeile offen statt falsch bewertet zu werden. + + `hit` = 1, wenn der Kurs in Signalrichtung lief. Die Quote ist gegen 50 % + zu lesen, nicht gegen 0.""" + now = time.time() + if now - getattr(self, "_rec_eval_ts", 0.0) < 300: + return + self._rec_eval_ts = now + try: + import sqlite3 + off = self.trader._broker_offset_s() + con = sqlite3.connect(HISTORY_DB_FILE, timeout=5.0) + con.row_factory = sqlite3.Row + offen = con.execute( + "SELECT id, ts, signal, price, atr, ret30, ret60 FROM rec_outcomes " + "WHERE done IS NULL OR done = 0").fetchall() + for r in offen: + d = 1 if r["signal"] == "LONG" else -1 + if not r["atr"] or r["atr"] <= 0: + con.execute("UPDATE rec_outcomes SET done=1 WHERE id=?", (r["id"],)) + continue + werte = {} + for feld, minuten in (("ret30", 30), ("ret60", 60)): + if r[feld] is not None: + werte[feld] = r[feld] + continue + ziel = r["ts"] + minuten * 60 + if now < ziel: + continue # noch nicht faellig + row = con.execute( + "SELECT c FROM candles_m1 WHERE time <= ? AND time > ? " + "ORDER BY time DESC LIMIT 1", + (ziel + off, ziel + off - 900)).fetchone() + if row: + werte[feld] = (row["c"] - r["price"]) * d / r["atr"] + sets, args = [], [] + for feld in ("ret30", "ret60"): + if feld in werte and r[feld] is None: + sets.append(f"{feld}=?"); args.append(round(werte[feld], 4)) + sets.append(f"hit{feld[3:]}=?"); args.append(1 if werte[feld] > 0 else 0) + faellig60 = now >= r["ts"] + 60 * 60 + if faellig60 and ("ret60" in werte or r["ret60"] is not None): + sets.append("done=?"); args.append(1) + elif now >= r["ts"] + 6 * 3600: + # Nach 6 h keine Bars mehr zu erwarten (Wochenende/Ausfall) → + # schliessen, damit die Zeile die Auswertung nicht ewig belegt. + sets.append("done=?"); args.append(1) + if sets: + args.append(r["id"]) + con.execute(f"UPDATE rec_outcomes SET {', '.join(sets)} WHERE id=?", + args) + con.commit(); con.close() + except Exception as e: + log.debug(f"rec_outcomes auswerten: {e}") + + def squeeze_monitor(self) -> dict: + """Auto-Squeeze-Monitor (B4) für das Dashboard: (1) LIVE-Trades (echt, DB — + das Gesamtsystem inkl. realer Exits) und (2) die MECHANISCHE Neuberechnung + der Squeeze-Regel auf ~30 Tagen M5 (Entry-Edge ISOLIERT, Backtest-Exit) gegen + die Erwartung (ØR +0,14…+0,23 · PF>1). Cache TTL 60 s (M5-Fetch+Scan).""" + import sqlite3 + now = time.time() + if self._sqmon_cache and now - self._sqmon_cache_ts < 60: + return self._sqmon_cache + exp = {"or_lo": 0.14, "or_hi": 0.23} + + # ── 1) Live-Trades (echt) — SQUEEZE_* aus der DB ────────────────────── + live = {"n": 0} + try: + con = sqlite3.connect(HISTORY_DB_FILE); con.row_factory = sqlite3.Row + rows = [r for r in con.execute( + "SELECT direction, entry_price, exit_price, sl_at_entry, pnl, entry_time " + "FROM trades WHERE setup LIKE 'SQUEEZE%' AND exit_time IS NOT NULL")] + con.close() + pnls = [r["pnl"] for r in rows if r["pnl"] is not None] + if pnls: + n = len(pnls); wins = [p for p in pnls if p > 0]; losses = [p for p in pnls if p < 0] + Rs = [] + for r in rows: # ØR-Näherung via SL-Distanz (SL≈2×ATR) + d = 1 if r["direction"] in ("BUY", "LONG") else -1 + sld = abs((r["entry_price"] or 0) - (r["sl_at_entry"] or 0)) + if sld > 0 and r["exit_price"] and r["entry_price"]: + Rs.append(2.0 * (r["exit_price"] - r["entry_price"]) * d / sld) + day, night = [], [] + for r in rows: + if r["pnl"] is None: continue + h = (datetime.fromtimestamp(r["entry_time"], _BERLIN).hour + if r["entry_time"] else 12) + (night if h in _SQUEEZE_NIGHT else day).append(r["pnl"]) + live = { + "n": n, "wr": round(100 * len(wins) / n), + "avg_win": round(sum(wins) / len(wins), 2) if wins else 0.0, + "avg_loss": round(sum(losses) / len(losses), 2) if losses else 0.0, + "ratio": (round((sum(wins) / len(wins)) / abs(sum(losses) / len(losses)), 2) + if wins and losses else None), + "sum_eur": round(sum(pnls), 2), + "oR": round(sum(Rs) / len(Rs), 3) if Rs else None, + "day": {"n": len(day), "sum": round(sum(day), 2)}, + "night": {"n": len(night), "sum": round(sum(night), 2)}, + } + except Exception as e: + live = {"n": 0, "error": str(e)[:100]} + + # ── 2) Mechanik (B4) — Squeeze-Regel neu gerechnet, Entry-Edge isoliert ── + mech = {"error": "keine M5-Daten"} + try: + m5 = self._fetch_m5_monitor() + if m5 and len(m5["C"]) >= 300: + # ⚠ Frueher: `import backtest_breakout_squeeze as BQ` PLUS + # `BQ._N = ...; BQ._K = ...`. Damit haing LIVE-Code an einem + # Backtest-Skript, und die Live-Parameter wurden ueber MUTIERTE + # MODUL-GLOBALE eingeschleust (prozessweit wirksam). Jetzt liegt + # die Mechanik in `core/` und die Parameter stehen in der + # Signatur. Verhalten unveraendert (bitgenau geprueft, + # `tests/test_squeeze_scan.py`). + from core.squeeze_scan import atr_series, scan + from core.wave_rec import _SQ_N, _SQ_MULT, _SQ_K + H, L, C, SP = m5["H"], m5["L"], m5["C"], m5["SP"] + A = atr_series(H, L, C) + sq, _ = scan(H, L, C, A, SP, 1.0, 0, len(C), _SQ_MULT, + box_n=_SQ_N, k=_SQ_K) + n = len(sq) + if n: + w = sum(1 for x in sq if x > 0); s = sum(sq) + up = sum(x for x in sq if x > 0); dn = -sum(x for x in sq if x < 0) + mech = {"n": n, "wr": round(100 * w / n), "oR": round(s / n, 3), + "pf": round(up / dn, 2) if dn > 0 else 99.9, "sum": round(s)} + else: + mech = {"n": 0} + except Exception as e: + mech = {"error": str(e)[:100]} + + # ── 3) Verdict aus der Mechanik (isoliert = apples-to-apples zum Backtest) ── + verdict = "watch" + if mech.get("n", 0) >= 6 and mech.get("oR") is not None: + if mech["oR"] > 0 and mech.get("pf", 0) > 1: + verdict = "ontrack" if mech["oR"] >= exp["or_lo"] else "weak" + else: + verdict = "drift" + + res = {"live": live, "mech": mech, "expect": exp, "verdict": verdict, + "params": {"box": None}} + try: + from core.wave_rec import _SQ_N, _SQ_MULT, _SQ_K + res["params"] = {"box": _SQ_N, "k": _SQ_K, "mult": _SQ_MULT} + except Exception: + pass + self._sqmon_cache = res; self._sqmon_cache_ts = now + return res + + def _compact_stats(self, period: str) -> dict: + """Kompakte Kennzahlen für die Live-Statistik-Kachel (WR, PF, Ø-Gewinn/ + -Verlust, Verhältnis, Trades, PnL) — aus `stats_overview`.""" + try: + ov = self.history.stats_overview(period) or {} + except Exception: + return {} + if ov.get("error"): + return {} + aw = ov.get("avg_win", 0.0) or 0.0 + al = ov.get("avg_loss", 0.0) or 0.0 + pf = ov.get("profit_factor") + return { + "n": ov.get("n_trades", 0) or 0, + "wr": round(ov.get("winrate", 0.0) or 0.0), + "pf": round(pf, 2) if pf is not None else None, + "avg_win": round(aw, 2), + "avg_loss": round(al, 2), + "ratio": round(aw / abs(al), 2) if al < 0 else None, + "pnl": round(ov.get("total_pnl", 0.0) or 0.0, 2), + } + + def snapshot(self) -> dict: + try: + # 5 → 10 (User-Wunsch 2026-08-04). ⚠ Auch das `slice(0, 10)` in + # `app.js` mitziehen — es begrenzt unabhängig davon nochmal. + last_trades = self.history.last_closed_trades(10) + except Exception as e: + log.warning(f"last_closed_trades: {e}") + last_trades = [] + market = self.data.snapshot() + wave_signal = self.wave.signal() + wave_snap = self.wave.snapshot() + elliott = self.elliott.snapshot() + tu = self.tu.snapshot() + agent = self.agent.snapshot() + position = self.trader.snapshot() + # Tages-P/L: realisiert (heute geschlossene Trades) + offene Position + try: + day_realized = float((self.history.stats_overview("today") or {}) + .get("total_pnl") or 0.0) + except Exception as e: + log.warning(f"day stats: {e}"); day_realized = 0.0 + open_pnl = float(position.get("pnl") or 0.0) + day_pl = {"realized": round(day_realized, 2), "open": round(open_pnl, 2), + "total": round(day_realized + open_pnl, 2)} + # Circuit Breaker — Status für die UI (Tagesverlust-Stopp) + _bal = float(self.data.balance or 0.0) + _cb_limit_eur = round(self._cb_limit_pct / 100.0 * _bal, 2) if self._cb_limit_pct > 0 else None + circuit_breaker = { + "enabled": self._cb_limit_pct > 0, "limit_pct": self._cb_limit_pct, + "limit_eur": _cb_limit_eur, "day_total": day_pl["total"], + "tripped": self._circuit_halted(), + } + sr_close_hint = self._sr_close_hint(market, position, wave_snap) + stop_approach = self._stop_approach_hint(market, position, wave_snap) + _cone = _cone_build( + (market or {}).get("bid"), + float(((wave_snap or {}).get("pb_feats") or {}).get("atr") or 0.0), + skalen=getattr(self, "_cone_skalen", None)) + # M15-Setup EINMAL rechnen (nicht im Dict), damit die Selbstmessung + # daran haengen kann — ein zweiter Aufruf waere Arbeit fuer dieselben + # Zahlen (der Konsens-Pfeil-Fehler). + # ⚠ MUSS hier stehen, nicht in `_run_analysis`: dort gibt es weder + # `market` noch `wave_snap`. Der erste Entwurf tat genau das und + # erzeugte einen NameError im 1-Sekunden-Pfad. `py_compile` sieht das + # NICHT, `ruff F821` schon (4 Befunde, darunter `_m15` selbst) — genau + # die Fehlerklasse, fuer die der Linter am 07.08. eingebaut wurde. + _m15 = self._m15_setup(market, wave_snap, _cone, + self._alignment_cached(), wave_signal) + self._log_m15_state(_m15, market) + self._check_m15_alert(_m15) # Telegram bei Flanke auf GRUEN + self._log_cone_check(_cone) + # Für den KI-Copiloten festhalten (2026-08-02) — er bekam bis dahin nur + # das Wellen-Signal und sah ausgerechnet die beiden kalibrierten Größen + # des Projekts nicht. Hier nur ZUWEISEN, nicht neu rechnen. + self._last_sr_close_hint = sr_close_hint + self._last_stop_approach = stop_approach + self._last_cone = _cone + # einmal holen — Verdict-Stimme UND Snapshot-Feld nutzen dasselbe Objekt + _pat_snap = self.patterns.snapshot() + # ── Marktstatus + Hyperliquid-Kurs (User 2026-08-01) ───────────────── + # ⚠ ZWEI Sackgassen bei der Markt-zu-Erkennung, beide verworfen: + # (1) `tick_local_ts` ist die lokale ABHOLZEIT — erneuert sich bei jedem + # Abruf, auch wenn der Broker denselben alten Tick liefert. + # (2) `tick_server_ts` minus `trader._broker_offset_s()` ist ZIRKULÄR: die + # Offset-Funktion leitet den Zeitzonen-Versatz selbst aus dem letzten + # Tick ab (`off = tick.time - now`). Bei geschlossenem Markt misst sie + # damit die Tick-Veraltung statt der Zeitzone (real −7,41 h statt +3 h) + # und das Alter hebt sich rechnerisch auf (Ergebnis: −347 s). + # Verwendet wird daher der direkte Beleg: **wann hat sich der Kurs zuletzt + # BEWEGT?** Kein Zeitzonen-, Kalender- oder Feiertagswissen nötig. + # ⚠ NACHTRAG 2026-08-01: Punkt (2) galt nur, solange `_broker_offset_s()` + # den Offset aus einem MÖGLICHERWEISE STALEN Tick ableitete. Seit dem Fix + # dort (Offset nur aus frischem Tick, sonst letzter gültiger Wert) ist die + # Rechnung nicht mehr zirkulär — deshalb dient das echte Tick-Alter jetzt + # als STARTWERT der Stillstands-Uhr. Ohne ihn begann die Uhr bei jedem + # Neustart bei null und der Bot brauchte 180 s, um ein längst geschlossenes + # Wochenende zu bemerken (real: Reload direkt nach `restart_server.bat` + # zeigte „KURS" statt „KURS · ZU" und den eingefrorenen Broker-G/V). + # Laufender Betrieb bleibt bewusst bewegungs-basiert: das erkennt auch + # einen eingefrorenen Feed, dessen Zeitstempel weiterlaufen. + _now = time.time() + try: + _b = (market or {}).get("bid") + if _b is not None: + if self._last_bid is None: + self._last_bid = float(_b) + _srv = (market or {}).get("tick_server_ts") + _age0 = 0.0 + if _srv: + _a = _now + self.trader._broker_offset_s() - float(_srv) + if 0.0 <= _a <= 14 * 86400: # plausibel: bis ~2 Wochen Stillstand + _age0 = _a + self._last_bid_move_ts = _now - _age0 + elif abs(float(_b) - self._last_bid) > 1e-9: + self._last_bid = float(_b) + self._last_bid_move_ts = _now + except Exception: + pass + _tick_age = (round(_now - self._last_bid_move_ts) + if self._last_bid_move_ts else None) + _mkt_closed = bool(_tick_age is not None and _tick_age > _TICK_STALE_S) + + # HL-Kurs IMMER mitliefern (User: „blende den Hyperliquid-Kurs NEBEN den + # Pepperstone-Kurs ein") — nicht nur bei geschlossenem Markt. So sieht man + # laufend die Abweichung; am Wochenende ist es der einzige lebende Kurs. + _hl_live = None + try: + _w = self.hl_walls.walls() + if _w and _w.get("mid_mt5") is not None: + _hl_live = { + "mid_mt5": _w["mid_mt5"], "hl_mid": _w.get("hl_mid"), + "basis": _w.get("basis"), "age_s": _w.get("age_s"), + # Basis auf dem letzten Stand mit lebendem Broker eingefroren + # (Fix 2026-08-02, s. `core/hl_walls.py`) — ohne das war die + # Umrechnung bei geschlossenem Markt zirkulär. + "basis_stale": _w.get("basis_stale"), + "bid": _w.get("bid"), "ask": _w.get("ask"), + "bid_sz": _w.get("bid_sz"), "ask_sz": _w.get("ask_sz"), + "imb5": _w.get("imb5"), + "trend": (_w.get("trend") or {}).get("dir"), + "slope_per_h": (_w.get("trend") or {}).get("slope_per_h"), + "vs_mt5": (round(_w["mid_mt5"] - float(market["bid"]), 3) + if (market or {}).get("bid") else None), + } + except Exception: + pass + + # ── G/V mit HL-Kurs, wenn der Broker eingefroren ist (User 2026-08-01) ── + # Bei geschlossenem Markt steht `position.pnl` still. Die Schätzung setzt den + # HL-Kurs (basis-korrigiert, also MT5-Niveau) in `trader.live_pnl()` ein — die + # BEREITS VORHANDENE, korrekte Umrechnung über `tick_size`/`tick_value` inkl. + # Swap und Währungsumrechnung. + # ⚠ Erster Versuch leitete den EUR-je-Punkt-Faktor aus `pnl / Kursbewegung` + # ab — das ergab 155 statt ~86 EUR je 1,00 und Lot, weil `pnl` den über das + # Wochenende aufgelaufenen SWAP enthält. Eine Kennzahl aus einer anderen + # Kennzahl zurückzurechnen ist hier schlicht falsch, wenn zusätzliche Terme + # drinstecken. + # ⚠ Bleibt eine SCHÄTZUNG: HL ist ein anderer Kontrakt (Basis herausgerechnet, + # aber nicht exakt), und der Spread am Montags-Open ist unbekannt. + _pnl_hl = None + try: + if _mkt_closed and _hl_live and position and position.get("ticket"): + _mid = float(_hl_live["mid_mt5"]) + _sp = float((market or {}).get("spread") or 0.0) + _v = self.trader.live_pnl(_mid - _sp / 2.0, _mid + _sp / 2.0) + if _v is not None: + _pnl_hl = round(_v, 2) + except Exception: + pass + + snap = { + "ts": time.time(), + "connected": self._connected, + "running": self._running, + "error": self._error, + "symbol": self.data.symbol, + "wave_tf": self._wave_tf_label, + "min_conf": _MIN_CONF_DISPLAY, + "auth_required": self.require_token, + "market": market, + # Dashboard-S/R = GLEICHE Quelle wie die Chart-Linien (`_draw_levels` = + # rohe M5-Pivots), damit Kachel und Chart nicht mehr auseinanderlaufen. + # `_sr_levels` (Misch-Set) bleibt intern für Strategie/Konfidenz. + "sr_levels": self._dash_sr(market), + "session": session_state(), + "position": position, + "day_pl": day_pl, + "circuit_breaker": circuit_breaker, + "emergency_loss": self._emergency_loss, + "emergency_remember": self._emergency_remember, + "emergency_pct": self._emergency_pct, + "emergency_margin_pct": self._emergency_margin_pct, + "wht_pct": self._wht_pct, + "takeprofit": self._takeprofit, + "takeprofit_remember": self._takeprofit_remember, + "trail": self.trail.snapshot(), + "wave_signal": wave_signal, + "wave": wave_snap, + "elliott": elliott, + "tu": tu, + "agent": agent, + # ⚠ `verdict` ist am 2026-08-08 aus dem Snapshot GEFLOGEN (die + # Gesamtempfehlung wurde entfernt). `_verdict()` selbst LEBT WEITER — + # es speist 1×/min die Telemetrie `verdict_votes` (User-Entscheidung: + # weiter sammeln) und den optionalen MQL5-Konsens-Pfeil. Es hier im + # 1-s-Pfad zu rechnen waere Arbeit fuer eine Anzeige, die es nicht + # mehr gibt — genau der Fehler, der beim Konsens-Pfeil schon einmal + # dokumentiert wurde. + "daily_levels": self.daily.snapshot(), + "gaps": self.gaps.snapshot(), + "structure": self.structure.snapshot(), + "patterns": _pat_snap, + # Kerzen-Anatomie (M5) — reine Anzeige, KEIN Verdict-Gewicht (gemessen: + # Lehrbuch-Deutung invertiert, Handelssignal durchgefallen). + "candle_anat": self.candle_anat.snapshot(), + # Wahrscheinlichkeits-Kegel (Streuung, NICHT Richtung) — out-of-sample + # kalibriert (`analyze_cone.py`). Reine Anzeige. + "cone": _cone, + "stats_compact": {"week": self._compact_stats("week"), + "all": self._compact_stats("all")}, + "close_alert_count": self._close_alert_count, + "news_storm": self.news.storm_state(), + # News-Sentiment-Score (−1..+1) für den Konflikt-Chip (2026-07-24): + # steht der Headline-Flow stark gegen Signal/Position, zeigt das + # Frontend eine Warnung (reiner Kontext — Prädiktivität unbelegt, + # wird via recommendations.news_score gesammelt und später gemessen). + "news_sentiment": (lambda sc: round(float(sc), 2) if sc is not None else None)( + (self.news.sentiment or {}).get("score")), + "alignment": self._alignment_cached(), + # `block_mix` entfallen (2026-08-08): die Zeile `#vd-blocks` gehoerte + # zur Gesamtempfehlung. `_block_mix_cached()` bleibt als Methode + # stehen — die Abfrage ist fuer eine kuenftige Anzeige nuetzlich und + # kostet ungenutzt nichts (sie laeuft nur beim Aufruf). + "hl_ctx": getattr(self, "_hl_ctx", None), + "rec_track": self._rec_track_cached(), + # B5-Stand des Auto-Squeeze fuer die Breakout-Karte (2026-08-19). + # ⚠ Eigenes Fenster (ab 01.08.) — NICHT dasselbe wie + # `squeeze_monitor.live` (alle Trades). s. `_squeeze_b5_cached`. + "squeeze_b5": self._squeeze_b5_cached(), + # Kennzahlen der MANUELLEN Trades für die gleichnamige Karte + # (2026-08-04). Rein deskriptiv; ~120 s gecacht, damit der heiße + # Snapshot-Pfad nicht je Abruf die DB aggregiert. + "manual_stats": self._manual_stats_cached(), + # Manuell vorgegebene Einsatz-Margin (0 = automatisch, s. set_manual_margin) + "manual_margin": getattr(self, "_manual_margin", 0.0), + # Einsatz als % der freien Margin (`margin_buffer_pct`). Greift, solange + # kein fester Betrag gesetzt ist — der hat Vorrang (s. set_margin_pct). + "margin_pct": round(_cfg_margin_buffer() * 100.0, 1), + # Live-Handelskosten relativ zur Vola (2026-07-24): Spread/ATR(M5). + # Gemessen (`backtest_realcosts.py`): Ø 0,265×ATR, Nacht 0,32–0,50 = + # Kostenfalle, 15–17 Uhr ~0,13 = günstig. Reine Anzeige (Chip) — die + # informierende Alternative zum abgeschalteten Dead-Hours-Gate. + "cost_ratio": (lambda b, a, atr: round((a - b) / max(atr, 0.06), 2) + if (b and a and atr) else None)( + (market or {}).get("bid"), (market or {}).get("ask"), + ((wave_snap or {}).get("pb_feats") or {}).get("atr")), + # M15-SETUP-BEREITSCHAFT (2026-08-08) — Ersatz fuer die entfernte + # Gesamtempfehlung. ⚠ KEINE Richtungsaussage: auf M15 traegt gemessen + # keine (Squeeze n=33/Nachbarn kippen, VWAP-Baender ohne Edge). + # Zeigt Bedingungen, nicht Prognosen. + "m15_setup": _m15, + # Selbstmessung der Karte + Ausfuehrungsqualitaet (2026-08-08). + "m15_track": self._m15_track_cached(), + "entry_gap": self._entry_gap_cached(), + "sr_close_hint": sr_close_hint, + "stop_approach": stop_approach, + "auto_sr_close": self._auto_sr_close, + "sr_close_min_gain": self._sr_close_min_gain, + "sr_close_pbreak": self._sr_close_pbreak, + "sr_close_count": self._sr_close_count, + # Schwelle der Close-Benachrichtigung — sichtbar, damit belegbar ist, + # dass sie im LAUFENDEN Prozess angekommen ist. „Erfolg melden, wo + # nichts geprueft wurde" ist im Projekt bereits viermal passiert. + "close_notify_min": self._close_notify_min, + "auto_squeeze": self._auto_squeeze, + # Nacht-Fenster mitliefern: nach `restart_server.bat` ist ein NEUES + # Snapshot-Feld der einzige verlässliche Beleg, dass wirklich der neue + # Python-Code läuft (der Batch hat schon 2× still nicht neu gestartet). + "squeeze_night_hours": list(self._squeeze_night_hours) + if self._squeeze_skip_night else [], + # 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, + "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, + "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. + "calendar": self.calendar.snapshot(), + # ── MARKT ZU + HYPERLIQUID-ERSATZKURS (User-Wunsch 2026-08-01) ────── + # Pepperstone handelt WTI nicht am Wochenende; der Snapshot zeigt dann + # den EINGEFRORENEN Freitags-Kurs, ohne dass man es sieht (der grüne + # Punkt im Header ist die VERBINDUNG, nicht der Marktstatus). Hyperliquid + # (`xyz:CL`) läuft dagegen 24/7 weiter. + # `market_closed` wird NICHT aus dem Kalender abgeleitet, sondern aus dem + # ALTER DES LETZTEN TICKS — das deckt Wochenende, Feiertage und + # Broker-Ausfälle mit derselben Regel ab, ohne Kalenderpflege. + # `hl_live.mid_mt5` = HL-Mid + gemessene Basis (HL notiert ~0,45 $ unter + # dem CFD, anderer Kontrakt) → direkt mit dem MT5-Kurs vergleichbar. + "market_closed": _mkt_closed, + "tick_age_s": _tick_age, + "hl_live": _hl_live, + # G/V-Schaetzung auf HL-Basis, nur bei geschlossenem Markt (sonst None) + "pnl_hl": _pnl_hl, + } + # ── Entry-Checkliste je Richtung (User-Vorgabe 2026-07-30: „blende einen + # Hinweis ein, wenn die Checkliste gegen meine Eröffnung ist"). Wird im + # Order-Dialog angezeigt; blockt NICHT. Nutzt nur bereits berechnete Teile + # → kein zusätzlicher MT5-/DB-Zugriff im Snapshot-Pfad. + try: + _parts = {"wave": wave_snap, "market": market, + "news_sentiment": snap.get("news_sentiment"), + "cost_ratio": snap.get("cost_ratio"), "day_pl": day_pl} + snap["checklist"] = {"long": self._entry_checklist("long", _parts), + "short": self._entry_checklist("short", _parts)} + except Exception as e: + log.debug(f"entry_checklist: {e}") + snap["checklist"] = None + return snap + + def _sr_close_hint(self, market, position, wave_snap) -> dict | None: + """S/R-Close-Empfehlung mit kalibriertem P(Durchbruch)-Modell (gemessen + `backtest_srclose_prob.py`, Echtkosten, 2 Hälften): Kurs AM gegenüberliegenden + Level → P(break) aus `_p_break` schätzen; ist P < `sr_close_pbreak` (Default + 0,60 = User-Regel „laufen lassen wenn >60 %"), prallt das Level wahrscheinlich + ab → Close schlägt Laufenlassen deutlich (+6000/+7600 R vs. reines Trailing). + Liefert IMMER die P(break)-Zahl (auch wenn ≥Schwelle → `close=False`) für die + Live-Anzeige.""" + try: + if not position.get("ticket"): + self._pb_state = None + return None + is_long = position.get("order_type") == 0 + bid = (market or {}).get("bid") + # Features FEST aus M5 (`wave.pb_feats`) — Modell wurde auf M5 trainiert; + # die Wave-TF-Werte (bis M30) verschoben die Skala → P falsch (Bugfix). + pf = (wave_snap or {}).get("pb_feats") or {} + atr = pf.get("atr") + mom6 = pf.get("mom6") + mom3 = pf.get("mom3") + ediff = pf.get("ema_diff") + entry = position.get("entry_price") or bid + # Level = EXAKT die gezeichnete Linie (`_draw_levels`, geclustert + + # Hysterese) — für LONG die nächste Resistance, für SHORT die nächste + # Support. So referenziert die Close-Meldung dasselbe Level, das im + # Chart/Dashboard steht (vorher rohe pb_levels → Meldung ≠ Linie, + # User-Feedback 2026-07-15). P(break) auf M5-Features unverändert. + dl = self._draw_levels() + if is_long: + level = (dl.get("res") or [None])[0] + else: + level = (dl.get("sup") or [None])[0] + if not (bid and atr and level) or mom6 is None or mom3 is None or ediff is None: + self._pb_state = None + return None + d = 1 if is_long else -1 + gap = (level - bid) * d # Abstand zum Level (in Preis) + if gap > 0.6 * atr: # weit weg → gar kein Hinweis + self._pb_state = None + return None + wt = 1.0 if ediff * d > 0 else 0.0 + dist = abs(level - entry) / atr + p_raw = _p_break(mom6 * d, mom3 * d, wt, dist) + # ── Anti-Flattern (gemessen `analyze_pbreak_flicker.py`) ───────────── + # (1) P über ~60 s glätten (Zeit-EMA) — mom3 der laufenden M5-Kerze ließ + # P sonst 7–15 Pp je Bar springen. Reset bei Level-Wechsel. + # (2) Hysterese ±5 Pp um die Schwelle: close↔laufen wechselt erst, wenn + # das geglättete P die Schwelle klar durchstößt (kein Flip-Flop). + import math as _m + now = time.time() + st = self._pb_state + if not st or abs(st["level"] - level) > 1e-9: + st = {"level": level, "p": p_raw, "ts": now, "zone": None} + else: + a = 1.0 - _m.exp(-max(0.0, now - st["ts"]) / 60.0) + st["p"] += a * (p_raw - st["p"]) + st["ts"] = now + p_s = st["p"] + thr = self._sr_close_pbreak + zone = st.get("zone") + if zone is None: + zone = "close" if p_s < thr else "run" + elif zone == "close" and p_s >= thr + 0.05: + zone = "run" + elif zone == "run" and p_s < thr - 0.05: + zone = "close" + st["zone"] = zone + at_level = gap <= 0.15 * atr # Auslöse-Fenster (wie gemessen: Touch) + # Bruch-BESTÄTIGUNGS-Preis (Fix 2026-07-22, User-Frage „genauer berechnen"): + # dieselbe Definition wie beim TRAINING des Modells (`_BRK_ATR=0,5`, + # `backtest_srclose_prob.py`/`analyze_srwaves.py`) — ein Kurs, der die reine + # Levelzahl nur um wenige Cent überschreitet (Docht/Rauschen), zählt NICHT + # als „Durchbruch"; erst level±0,5×ATR gilt als bestätigt. Reale Analyse + # 2026-07-22: Trade bei Level 85,943 geschlossen, Kurs wischte kurz auf + # 85,994 (< Bestätigung 86,017), fiel danach zurück → laut Modell-eigener + # Definition eine ABLEHNUNG, kein Bruch — die Vorhersage war korrekt. + confirm = round(level + d * 0.5 * atr, 3) # Richtung Durchbruch + reject = round(level - d * 0.5 * atr, 3) # Richtung Abprall (Gegenbestätigung) + # Prognose-Logging (User-Wunsch 2026-07-23): EINMAL je frischem Touch + # (at_level False→True) die aktuelle Abprall/Durchbruch-Vorhersage + # protokollieren. Auswertung separat gegen `candles_m1` + # (`_evaluate_pbreak_predictions`, periodisch im Trend-Loop) — dieselbe + # confirm/reject-Definition wie beim Modell-Training, also fair verglichen. + if at_level and not st.get("was_at_level"): + try: + self.history.log_pbreak_prediction( + symbol=self.data.symbol or "", + direction=("LONG" if is_long else "SHORT"), + level=level, p_break=round(100 * p_s, 1), + predicted=("bounce" if zone == "close" else "break"), + price_at_pred=bid, confirm_price=confirm, + reject_price=reject, atr=atr) + except Exception as le: + log.debug(f"pbreak_prediction log: {le}") + st["was_at_level"] = at_level + self._pb_state = st + return {"level": round(level, 3), "p_break": round(100 * p_s), + "mom": round(mom3 * d, 2), "at_level": at_level, + "gap_atr": round(gap / atr, 2), "confirm": confirm, + # Auto-Close NUR exakt am Level; die Anzeige (0,15–0,6×ATR davor) + # ist reine Vorwarnung mit geglättetem P(break) + "close": at_level and zone == "close"} + except Exception as e: + log.debug(f"sr_close_hint: {e}") + return None + + def _vola_regime(self, atr15) -> dict | None: + """ΔATR = aktueller ATR_M15 / Median der letzten ~5 h. + + ⚠ KEIN zweiter MT5-Fetch: der ATR wird aus dem ohnehin laufenden + Turn-Fetch gesammelt (`wave.tf_atr`), hier nur gepuffert. Ein extra + Abruf fuer Zahlen, die schon da sind, war beim Konsens-Pfeil der Fehler. + + ⚠⚠ BELEGLAGE, ehrlich: gemessen (`analyze_slippage_vola.py`, 337 + SL-Closes) steigt die Exit-Slippage MONOTON ueber alle vier + ΔATR-Quartile — 0,0200 → 0,0390 → 0,0645 → 0,0839 $, Faktor 4,19×. + Die Slip-RATE bleibt dabei flach (20–23 %), die GROESSE waechst + (0,094 → 0,396 $): es rutscht nicht oefter, sondern weiter. + ABER: der Bootstrap auf die DIFFERENZ oben−unten ergibt + [−0,012 … +0,183] — **enthaelt die Null**. Bei n≈85 je Quartil ist der + Unterschied NICHT statistisch belegt. Deshalb reine ANZEIGE, kein Gate, + und die Beschriftung sagt das auch. + """ + try: + if not atr15 or atr15 <= 0: + return None + buf = getattr(self, "_atr15_buf", None) + if buf is None: + from collections import deque + buf = self._atr15_buf = deque(maxlen=1200) # ~5 h bei 15-s-Takt + buf.append(float(atr15)) + if len(buf) < 200: + return {"datr": None, "stufe": None} + import statistics as _st + med = _st.median(buf) + if med <= 0: + return {"datr": None, "stufe": None} + d = atr15 / med + return {"datr": round(d, 2), + "stufe": "hoch" if d >= 1.26 else "normal" if d >= 0.91 else "ruhig"} + except Exception: + return None + + def _system_status(self) -> list[str]: + """Klartext-Zeilen zum Systemzustand für den Tagesreport. + + ⚠⚠ WARUM ES DAS GIBT (2026-08-11). Das Projekt hat sieben Wächter, und + sie funktionieren — was fehlte, war der letzte Meter. Belegt an den + Marker-Dateien: `cfg_auto_sr_close` trägt den Zeitstempel **06.08. 18:10** + und wurde danach NIE wieder gemeldet. Fünf Tage lang stand ein Exit aus, + der am 11.08. mit **+350,81 € über 43 Trades** gemessen wurde. + `cfg_auto_squeeze` trägt sogar 02.08. — als der Schalter am 11.08. umgelegt + wurde, kam deshalb GAR KEINE Meldung. + ⚠ Die Ursache ist ein Kategorienfehler: die Marker-Logik behandelt einen + **Dauerzustand** wie ein **Einmal-Ereignis**. Deshalb die saubere Trennung + — **Popup = „etwas Neues ist passiert", Tagesreport = „so steht das System + gerade"**. Der Report kommt nachweislich jeden Tag an (Telegram-Log + lückenlos `ok`), der Popup verstummt nach dem ersten Mal. + ⚠ Reine Anzeige: nichts wird hier geschaltet oder entschieden. + """ + z: list[str] = [] + try: + an = lambda b: "AN" if b else "aus" # noqa: E731 + z.append(f"Automatik: BRK {an(self._auto_squeeze)} · " + f"S/R-Close {an(self._auto_sr_close)} · " + f"Flip {an(getattr(self, '_flip_close', False))}") + except Exception as e: + z.append(f"Automatik: nicht lesbar ({e})") + try: + puls = self.history.telemetrie_puls() if self.history else [] + z.extend(f"⚠ Puls: {p}" for p in puls) + if not puls: + z.append("Puls: alle Telemetrie-Tabellen schreiben") + except Exception as e: + z.append(f"⚠ Puls: Prüfung fehlgeschlagen ({e})") + # ── Config-Drift + fällige Messungen aus dem Reminder ──────────────── + # ⚠ LAZY und GEKAPSELT: `measurement_reminder` ist ein Auswertungs-Skript + # und gehört NICHT in den Live-Abhängigkeitsgraphen (derselbe Fehler wie + # der `backtest_breakout_squeeze`-Import). Der Import passiert deshalb + # erst hier, 1×/Tag, und ein Fehlschlag wird SICHTBAR gemeldet statt + # still verschluckt — ein fehlender Block sähe sonst aus wie „alles gut". + try: + import measurement_reminder as _mr + for d, cur, val in _mr.config_drift(): + z.append(f"⚠ Config: {d['key']} steht auf '{cur}' " + f"(validiert: '{val}')") + faellig = [] + for c in _mr.CHECKS: + try: + have = c["have"]() + if have >= c["need"]: + faellig.append(f"{c['key']} FÄLLIG") + elif have > 0: + faellig.append(f"{c['key']} {have:.0f}/{c['need']}") + except Exception: + continue + if faellig: + z.append("Messungen: " + " · ".join(faellig[:6])) + except Exception as e: + z.append(f"⚠ Config-/Messungs-Prüfung nicht verfügbar ({e})") + return z + + def _check_close_notify(self): + """Telegram bei jedem geschlossenen Trade ab `close_notify_min_eur`. + + ⚠⚠ WARUM ES DAS GIBT (2026-08-11): der Kanal war nachweislich intakt + (Testnachricht zugestellt), aber ALLE NEUN sendenden Auslöser waren aus — + Auto-Squeeze, Auto-Signal, S/R-Close, Flip-Close, 15-Minuten-Regel, + Circuit-Breaker und die drei Notfall-Stop-Modi. Jede Abschaltung war für + sich begründet; zusammen ergaben sie eine Stille, die niemand beschlossen + hatte. Diese Meldung hängt deshalb bewusst an **keiner** Automatik: sie + feuert auch bei manuellem Close und bei Broker-SL. + ⚠ Der Trade-Abschluss-Push war früher in `history.log_trade_close` und wurde + als Spam entfernt (jeder Close, ohne Schwelle). Der Unterschied ist die + **Schwelle** — und sie steckt hier in der Engine, nicht in der DB-Schicht. + """ + p = self._close_notify_pending + if not p: + return + ticket, versuche = p + tr = self.history.trade_by_ticket(ticket) + # ⚠ `exit_time IS NULL` heisst „DB-Schreiber noch nicht durch", nicht + # „kein Trade". Der Flat-Zustand kommt aus `trader.snapshot()` und ist + # dem Schreiber gelegentlich einen Tick voraus. + if not tr or tr.get("exit_time") is None or tr.get("pnl") is None: + p[1] = versuche + 1 + if versuche >= 30: # ~30 s — dann war es kein Bot-Trade + self._close_notify_pending = None + return + self._close_notify_pending = None + pnl = float(tr["pnl"] or 0.0) + if abs(pnl) < self._close_notify_min: + return + tg = self.cfg["telegram"] + if tg.get("enabled", "false").strip().lower() != "true": + return + try: + dauer = "" + if tr.get("entry_time"): + mins = (int(tr["exit_time"]) - int(tr["entry_time"])) / 60.0 + dauer = (f"{mins/60:.1f} h" if mins >= 90 else f"{mins:.0f} min") + grund = {"sl": "Broker-SL", "manual": "manuell", + "sr_close": "S/R-Auto-Close", "emergency": "Notfall-Stop", + "timestop": "Time-Stop", "adverse15": "15-Minuten-Regel", + "flip_close": "Signal-Flip", + "circuit_breaker": "Circuit Breaker", + "squeeze_reverse": "Squeeze-Reverse"}.get( + tr.get("closed_by"), tr.get("closed_by") or "?") + icon = "🟢" if pnl > 0 else "🔴" + send_telegram( + f"{icon} Trade geschlossen {pnl:+.2f} €\n" + f"{tr.get('symbol') or ''} {tr.get('direction') or ''} " + f"{tr.get('lots')} Lots · {tr.get('entry_price')} → " + f"{tr.get('exit_price')}\n" + f"{grund} · {dauer} · Setup {tr.get('setup') or '—'}", + tg.get("bot_token", "").strip(), tg.get("chat_id", "").strip()) + log.info(f"📨 Close-Push #{ticket} {pnl:+.2f} € ({grund})") + except Exception as e: + log.debug(f"close_notify: {e}") + + def _m15_setup(self, market, wave_snap, cone, alignment, + wave_signal=None) -> dict | None: + """SETUP-BEREITSCHAFT auf M15 — der Ersatz für die entfernte Gesamtempfehlung. + + ⚠⚠ BEWUSST KEINE RICHTUNGS-EMPFEHLUNG. Gemessen (2026-08-08) trägt auf + M15 KEINE Richtungsquelle: der Squeeze fällt dort durch (n=33 in H1, + Nachbarn kippen), VWAP-Bänder ebenso (Zufallslinie hält gleich gut, alle + Konfidenzintervalle enthalten die Null), und 24 Signal-Eingriffe sind + insgesamt gescheitert. Was BELEGT ist, sind Bedingungen — nicht Prognosen. + Die Karte zeigt deshalb das Schema des Users (Bias → Zone → Reichweite → + Veto) und überlässt den Auslöser dem Menschen, dessen Auswahl-Vorteil + unabhängig belegt ist. + + Zusammengesetzt aus bereits berechneten Teilen — NEU gerechnet wird nur + das Kosten-Verhältnis auf M15 und P(break) für das nächste Level. Der + Konsens-Pfeil hat einmal ein zweites `_verdict()` gezogen; das ist die + Lehre daraus. + """ + try: + bid = (market or {}).get("bid"); ask = (market or {}).get("ask") + ws = wave_snap or {} + atr15 = (ws.get("tf_atr") or {}).get("M15") + out: dict = {"atr_m15": round(atr15, 4) if atr15 else None} + + # ── 1) VETO statt BIAS ──────────────────────────────────────── + # ⚠⚠ KORREKTUR 2026-08-08, nach dem Tagestest an 07.08.: der erste + # Entwurf machte aus „M30+H1 einig" eine RICHTUNG („Bias LONG") und + # erzeugte damit 13 LONG-Episoden an einem Tag, der 1,02 $ FIEL — + # Trefferquote 0 %, ØR −0,753. Der Fehler ist strukturell, nicht + # Pech: gemessen ist **„gegen den M30-Trend halbiert den Edge"**, + # also eine VERBOTS-Aussage. Daraus einen Richtungsgeber zu machen + # ist exakt der Fehler, wegen dem die Gesamtempfehlung entfernt + # wurde (Richtung dort 48–51 % Treffer = Münzwurf). + # Die nachlaufende EMA zeigt im Trendtag stur in die alte Richtung — + # dokumentiert seit `analyze_signal_live.py` (06.08.). + # Jetzt liefert das Feld nur noch, welche Richtung VERBOTEN ist. + h30 = ws.get("htf_trend"); h1 = ws.get("h1_trend") + verboten = None + if h30: + verboten = "SHORT" if h30 == "LONG" or h30 == 1 else "LONG" + out["bias"] = {"m30": h30, "h1": h1, + "verboten": verboten, + "einig": bool(h30 and h1 and h30 == h1), + # ⚠ `richtung` bleibt None — bewusst KEINE + # Richtungsaussage mehr (s. o.). + "richtung": None} + + # ── 2) KOSTEN auf M15 — hier gewinnt M15 wirklich (gemessen 0,104 + # gegen M5 0,193). Schwellen wie beim M5-Chip (0,20 / 0,32). + kr = None + if bid and ask and atr15 and atr15 > 0: + kr = round((ask - bid) / atr15, 3) + out["kosten"] = {"ratio": kr, + "stufe": (None if kr is None else + "guenstig" if kr < 0.20 else + "normal" if kr < 0.32 else "teuer")} + + # ── 3) ZONE — nächstes STEHENDES Level (M30-Pivots, dieselbe Quelle + # wie Chart und S/R-Close) + P(break). ⚠ Die P(break)-Merkmale + # kommen aus M5, weil das Modell darauf trainiert ist (Fix + # 2026-07-13) — NICHT auf M15 umstellen. + dl = self._draw_levels() or {} + res = (dl.get("res") or [None])[0] + sup = (dl.get("sup") or [None])[0] + pf = ws.get("pb_feats") or {} + a5 = pf.get("atr") or 0.2 + zonen = [] + for lvl, seite in ((res, "res"), (sup, "sup")): + if not lvl or not bid or not atr15: + continue + d = 1 if seite == "res" else -1 + gap = abs(lvl - bid) + wt = 1.0 if (pf.get("ema_diff", 0.0) or 0.0) * d > 0 else 0.0 + p = _p_break(pf.get("mom6", 0.0) * d, pf.get("mom3", 0.0) * d, + wt, gap / max(a5, 0.06)) + zonen.append({"seite": seite, "level": round(lvl, 3), + "gap_atr15": round(gap / atr15, 2), + "p_break": round(100 * p)}) + out["zonen"] = zonen + + # ── 3b) MOTOR-ZUSTAND + RAUM ───────────────────────────────── + # ⚠⚠ ANLASS 2026-08-10 (Trade #49671601, −87 €): die Karte zeigte + # GRÜN, während der Signal-Motor zu 76 % mit `entry_room` blockte. + # Das ist kein Widerspruch im Motor, sondern eine LÜCKE in der + # Karte: dieselbe geometrische Tatsache — „ein Level ist sehr nah" + # — wird von beiden Seiten mit UMGEKEHRTEM Vorzeichen gelesen: + # · Gate ③ (Karte): Level in Reichweite → man kann eine ruhende + # Order dort platzieren → GUT + # · entry_room (Motor): das Ziel-Level ist zu nah → Ertrag + # gedeckelt, Kosten fressen ihn → SCHLECHT + # (gemessen ØR −0,119/−0,067 · PF 0,42/0,66) + # Beide sind gemessen und beide haben recht — über VERSCHIEDENE + # Trades (Markt-Einstieg mit dem Level als Hindernis gegen ruhende + # Order AM Level). Die Karte verschwieg nur die zweite Hälfte. + # ⚠ REINE ANZEIGE. Es wird nichts geblockt und keine Richtung + # ausgegeben — der Raum kommt für BEIDE Richtungen, weil der Mensch + # in jede handeln kann. Hartes Blockieren ist im Projekt verworfen + # (Übersteuerungen des Users liefen gemessen 68 % WR). + # ⚠ `room_info` ist DIESELBE Funktion, die das Gate entscheidet — + # kein Nachbau (die Divergenz-Falle, dreimal dokumentiert). + wsig = wave_signal or {} + try: + raum = self.wave.room_info(bid) + except Exception: + raum = None + out["motor"] = { + "signal": wsig.get("signal"), + "block": wsig.get("block"), + "grund": (wsig.get("reasons") or [None])[0], + "conf_pct": wsig.get("conf_pct"), + "raum": raum} + + # ── 4) REICHWEITE — der Kegel (kalibriert: 80 %-Band trifft 77 %) + lv = (cone or {}).get("levels") or [] + b80 = [x for x in lv if x.get("band") == 80] + out["reichweite"] = [{"minutes": x.get("minutes"), + "lo": x.get("lo"), "hi": x.get("hi")} + for x in b80] or None + + # ── 4b) ZIEL-ERREICHBARKEIT — reine GEOMETRIE auf dem Kegel. + # Gefragt wird nur: passt der Abstand noch in das 80-%-Band des + # jeweiligen Horizonts? ⚠ KEINE Bruch-Annahme — die im Vorschlag + # mitgedachte „je länger, desto eher bricht es"-Regel ist + # gemessen NICHT robust (Touch-Zahl: umgekehrtes U auf 4 + # Live-Tagen, im Backtest +0,010 AUC und nach dem Nachtrainieren + # gar nichts mehr). Hier steckt deshalb nur Arithmetik. + # + # ⚠⚠ FEHLER BEHOBEN 2026-08-10 (User-Fund „Kurs 79, Ziel 78,7 — bei + # LONG?"). Der erste Entwurf nahm als Ziel schlicht das ENTFERNTERE + # der beiden Level (`max` nach gap) — also **rein geometrisch, ohne + # die erlaubte Richtung zu prüfen**. Real zeigte die Karte damit + # `verboten=SHORT` UND ein Ziel 0,15 $ UNTER dem Kurs: die Arithmetik + # war korrekt, aber für die VERBOTENE Richtung. + # Strukturell steckte dahinter die stillschweigende Annahme eines + # **Fade-Trades** (am Level einsteigen, Gegenlevel als Ziel = Mean + # Reversion), während Gate ② ein **Trendfilter** ist. Zwei + # unvereinbare Handelskonzepte in einer Kette — und damit derselbe + # Fehler, wegen dem die Gesamtempfehlung entfernt wurde: aus einem + # VERBOT wird über die Hintertür wieder eine RICHTUNG. + # + # Jetzt gilt die gemessene Population aus `backtest_m15_gates.py`: + # Einstieg am **nächstgelegenen** Level, Richtung = die vom HTF + # erlaubte. Ein Ziel wird nur ausgewiesen, wenn es JENSEITS dieses + # Einstiegs in der erlaubten Richtung liegt. + # ⚠ Ohne Veto (M30 flach) gibt es KEIN Ziel — ein einzelnes Ziel + # zu zeigen wäre wieder eine Richtungsaussage. + # ⚠ `_draw_levels` liefert bewusst nur die NÄCHSTE Linie je Seite. + # Beim Ausbruchs-Einstieg (nächstes Level == Ziel-Seite) ist das + # nächste Ziel damit schlicht NICHT berechenbar — dann sagt die + # Karte das, statt eine Zahl zu erfinden. + out["ziel"] = None + d_ok = (1 if verboten == "SHORT" else + -1 if verboten == "LONG" else 0) + if len(zonen) == 2 and bid and b80 and d_ok: + nah = min(zonen, key=lambda x: x["gap_atr15"]) + kand = next((z for z in zonen + if z["seite"] == ("res" if d_ok > 0 else "sup")), None) + if kand is None: + grund = "kein Level in der erlaubten Richtung" + elif (kand["level"] - nah["level"]) * d_ok > 0: + strecke = abs(kand["level"] - bid) + out["ziel"] = { + "level": kand["level"], "strecke": round(strecke, 3), + "horizonte": [{"minutes": x.get("minutes"), + "drin": bool(x.get("lo") is not None + and x.get("hi") is not None + and x["lo"] <= kand["level"] <= x["hi"])} + for x in b80], + "von": nah["level"], + "richtung": "LONG" if d_ok > 0 else "SHORT"} + grund = None + else: + grund = ("Ausbruch — Einstieg am " + + ("Widerstand" if nah["seite"] == "res" else "Support") + + f" {nah['level']:.3f}, nächstes Ziel dahinter nicht berechenbar") + out["ziel_grund"] = grund + else: + out["ziel_grund"] = (None if d_ok else + "kein Veto — Richtung offen, kein Ziel bestimmbar") + + # ── 4d) STATUS + LESART mit HYSTERESE — EINMAL berechnet ──────── + # ⚠⚠ ANLASS (2026-08-12): die Karte wechselte den Zustand **alle 12 + # Sekunden** (~1.030 Wechsel/Tag). Gemessen an den Logzeilen war + # NICHT das Level die Ursache (nur 7,6 %), sondern **Schwellen- + # Flattern**: `gap ≤ 0,5` und das 45/55-Band von P(break) sind + # stetige Groessen, die um die Schwelle pendeln. + # Folge: die Selbstmessung war massiv geclustert — roh 60 %, + # entkoppelt 53 %. + # ✅ Das Projekt hat genau dieses Problem am 14.07. schon geloest + # (`analyze_pbreak_flicker.py`): Zeit-Glaettung + **Hysterese + # ±5 Pp**. Die Karte umging sie, weil sie P(break) selbst neu + # rechnet. Hier ist dieselbe Hysterese, auf beide Schwellen. + # ⚠⚠ UND: Status/Lesart wurden an DREI Stellen unabhaengig gebildet + # (Frontend, `_log_m15_state`, hier) — sie wichen bereits ab (der + # Logger kannte den „kein Veto"-Fall nicht). Jetzt EINE Quelle; + # Anzeige und Messung koennen nicht mehr auseinanderlaufen. + hy = getattr(self, "_m15_hyst", None) or {} + nah_z = min(zonen, key=lambda x: x["gap_atr15"]) if zonen else None + # (a) Status: gruen ab gap ≤ 0,50 — zurueck auf gelb erst > 0,60 + war_gruen = hy.get("status") == "gruen" + if kr is not None and kr >= 0.32: + status = "rot" + elif not nah_z: + status = "gelb" + else: + g = nah_z["gap_atr15"] + status = "gruen" if (g <= (0.60 if war_gruen else 0.50)) else "gelb" + # (b) Lesart: gerichtet erst ausserhalb 40/60, zurueck auf „unklar" + # erst innerhalb 45/55 (die dokumentierten ±5 Pp). + lesart = "unklar" + if nah_z and nah_z.get("p_break") is not None: + pb = nah_z["p_break"] + war_ger = hy.get("lesart") in ("auf", "ab") + lo_s, hi_s = (45, 55) if war_ger else (40, 60) + if pb < lo_s or pb > hi_s: + bricht = pb > hi_s if not war_ger else pb > 55 + res = nah_z["seite"] == "res" + lesart = "auf" if (res if bricht else not res) else "ab" + elif war_ger: + lesart = hy.get("lesart") # im Band: alten Zustand halten + self._m15_hyst = {"status": status, "lesart": lesart} + out["status"] = status + out["lesart"] = lesart + out["nah"] = nah_z + + # ── 4c) VOLA-REGIME als Slippage-HINWEIS (kein Gate, s. Docstring) + out["vola"] = self._vola_regime(atr15) + + # ── 5) VETO — die eigene Bilanz, je Lot (absolute Euro sind bei + # wechselnder Positionsgröße ungültig, Lehre vom 04.08.) + out["veto"] = alignment or None + return out + except Exception as e: + log.debug(f"m15_setup: {e}") + return None + + def _stop_approach_hint(self, market, position, wave_snap) -> dict | None: + """REINE ANZEIGE: P(Durchbruch) für das Level, dem sich der Kurs auf der + **Gegen-/Stop-Seite** der offenen Position nähert (LONG → Support darunter, + SHORT → Resistance darüber). Beantwortet die User-Frage „mein Trade läuft auf + ein Level zu — bricht es durch (Verlust läuft) oder prallt es ab (erholt sich)?". + NICHT der S/R-Auto-Close (der wirkt nur auf der Ziel-Seite im Plus) — hier wird + NICHTS geschlossen. Kalibrierung der Gegen-Seite gemessen + (`backtest_pbreak_calibration.py`, 2026-07-24): AUC 0,68/0,72 out-of-sample in + beiden Hälften, im niedrigen P-Bereich sauber kalibriert; im hohen (≥0,5) gegen + den Trend leicht KONSERVATIV (unterschätzt die Bruch-Rate ~10–13 Pp) → die Zahl + ist eine belastbare, eher vorsichtige Schätzung. Feature-/Glättungs-Logik + identisch zu `_sr_close_hint`, nur Level = Gegen-Level und Richtung = -d.""" + try: + if not position.get("ticket"): + self._stop_pb_state = None + return None + is_long = position.get("order_type") == 0 + bid = (market or {}).get("bid") + pf = (wave_snap or {}).get("pb_feats") or {} + atr = pf.get("atr"); mom6 = pf.get("mom6"); mom3 = pf.get("mom3") + ediff = pf.get("ema_diff") + entry = position.get("entry_price") or bid + dl = self._draw_levels() + # Gegen-Level: LONG → Support darunter, SHORT → Resistance darüber + if is_long: + level = (dl.get("sup") or [None])[0] + else: + level = (dl.get("res") or [None])[0] + if not (bid and atr and level) or mom6 is None or mom3 is None or ediff is None: + self._stop_pb_state = None + return None + dd = -1 if is_long else 1 # Break-Richtung GEGEN den Trade + gap = (level - bid) * dd # >0 solange Kurs noch vor dem Level + if gap <= 0 or gap > 0.6 * atr: # weit weg / schon durch → kein Hinweis + self._stop_pb_state = None + return None + wt = 1.0 if ediff * dd > 0 else 0.0 + dist = abs(level - entry) / atr + p_raw = _p_break(mom6 * dd, mom3 * dd, wt, dist) + import math as _m + now = time.time(); st = self._stop_pb_state + if not st or abs(st["level"] - level) > 1e-9: + st = {"level": level, "p": p_raw, "ts": now} + else: + a = 1.0 - _m.exp(-max(0.0, now - st["ts"]) / 60.0) + st["p"] += a * (p_raw - st["p"]); st["ts"] = now + self._stop_pb_state = st + p_s = st["p"] + confirm = round(level + dd * 0.5 * atr, 3) # Durchbruch bestätigt ab + reject = round(level - dd * 0.5 * atr, 3) # Abprall bestätigt ab + return {"level": round(level, 3), + "side": ("sup" if is_long else "res"), + "p_break": round(100 * p_s), + "at_level": gap <= 0.15 * atr, + "gap_atr": round(gap / atr, 2), + "confirm": confirm, "reject": reject} + except Exception as e: + log.debug(f"stop_approach_hint: {e}") + return None diff --git a/server.py b/server.py index 6e9479c..ffb3024 100644 --- a/server.py +++ b/server.py @@ -353,6 +353,16 @@ async def toggle_srclose(x_auth_token: str | None = Header(default=None)): return {"ok": True, "enabled": state} +@app.post("/api/autom15") +async def toggle_autom15(x_auth_token: str | None = Header(default=None)): + """M15-Karten-Auto-Entry an/aus (User-Test 2026-08-19, gemessen negativ).""" + _require_auth(x_auth_token) + eng = app.state.engine + state = eng.set_auto_m15(not eng._auto_m15) + log.info(f"[WEB] Auto-M15-Entry {'AN' if state else 'AUS'}") + return {"ok": True, "enabled": state} + + @app.post("/api/circuit") async def toggle_circuit(x_auth_token: str | None = Header(default=None)): """Tagesverlust-Stopp (Circuit Breaker) an/aus (User-Wunsch 2026-08-19).""" diff --git a/web/app.js b/web/app.js index d598ffa..933ea31 100644 --- a/web/app.js +++ b/web/app.js @@ -1155,6 +1155,14 @@ function render(d) { // Auto-Squeeze-ZUSTAND bleibt in der Breakout-Karte sichtbar -- nur sein // Schalter ist weg (jetzt POST-Endpunkt bzw. ini). const cb = d.circuit_breaker || {}; + const m15b = $streng("btn-m15"); + if (m15b) { + m15b.classList.toggle("on", !!d.auto_m15); + m15b.title = d.auto_m15 + ? "Auto-Entry auf die gruene M15-Karte AN (" + (d.m15_entry_count || 0) + + " Entries). ACHTUNG: gemessen negativ -- reiner Test." + : "Auto-Entry auf die gruene M15-Karte AUS (gemessen negativ)."; + } const srb = $streng("btn-sr"); if (srb) { srb.classList.toggle("on", !!d.auto_sr_close); @@ -1745,6 +1753,7 @@ _updateMuteBtn(); // Toggle-Buttons (AUTO/TRAIL/S-R-Close) — ohne Bestätigung $("btn-cb").onclick = () => toggle("api/circuit", "Circuit Breaker"); $("btn-sr").onclick = () => toggle("api/srclose", "S/R-Auto-Close"); +$("btn-m15").onclick = () => toggle("api/autom15", "M15-Auto-Entry (gemessen negativ!)"); // (🎯 SIG entfernt 2026-08-12 — s. renderOrderButtons oben) $("btn-trail").onclick = () => toggle("api/trail", "Trailing"); $("srclose-btn").onclick = () => toggle("api/srclose", "Auto-S/R-Close"); diff --git a/web/index.html b/web/index.html index d50ffa1..8ecb943 100644 --- a/web/index.html +++ b/web/index.html @@ -12,7 +12,7 @@ ⚠ LOKAL eingebunden, kein CDN: die PWA laeuft ueber WireGuard und muss offline funktionieren. --> - + @@ -355,6 +355,11 @@ `auto_sr_close` aus demselben Snapshot und koennen nicht auseinanderlaufen. --> + + @@ -376,6 +381,6 @@ - +