""" 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. # ⚠ User-Vorgabe 2026-08-19: ALLE Trade-Closes UND -Opens melden. # `close_notify_min_eur` steht dafür auf 0 (= jeder Close, keine Schwelle). _TG_ERLAUBT = ("Trade geschlossen", "Trade eroeffnet") 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) # ⚠⚠ ZWEITER, STRIKT TICKET-GEBUNDENER SLOT NUR FUER BRK # (User-Vorgabe 2026-08-19: "der brk trade soll komplett unabhaengig # von m15 oder manuellen trades laufen"). # Entwurf: docs/brk-eigener-slot.md # Der Trick ist, dass `TrailingManager` bereits SEINEN Trader nimmt und # nur ueber dessen `snapshot()` liest — es haengt also nicht an einer # globalen Position. Damit entfaellt der 29-Stellen-Umbau: BRK bekommt # einen eigenen Manager samt vollem Schutz-Stack, und der validierte # Pfad (manuell + M15) bleibt unangetastet. # ⚠ `nur_ticket=True` ist zwingend: ohne das wuerden sich BEIDE Manager # dieselbe Position greifen (der Default adoptiert per Symbol). self.trader_brk = TradeManager(nur_ticket=True) self.trail_brk = TrailingManager(self.trader_brk) # ⚠⚠ AUCH am BRK-Slot — ohne das kann er GAR NICHT in die DB schreiben. # ⚠ MUSS hier stehen, NACH der Erzeugung: der erste Versuch setzte es # oben neben `self.trader.history` und lief in einen AttributeError, # der den GESAMTEN Serverstart verhinderte. Zweiter Reihenfolge-Fehler # derselben Art in zwei Tagen (nach `_margin_brk` vor # `_load_runtime_state`). Regel: ein Attribut erst benutzen, wenn das # Objekt existiert — und den Start danach WIRKLICH pruefen. self.trader_brk.history = self.history # ⚠ Der adoptierende Manager darf den BRK-Slot und dessen noch offene # Pending-Tickets NICHT greifen — sonst verwalten beide dieselbe # Position (Trailing zweimal, Notfall-Stop am falschen Trade). # ⚠⚠ DER MERKER `_brk_restore` GEHOERT MIT IN DIE SPERRLISTE — sonst # reisst sie bei JEDEM Neustart auf. Reihenfolge im `_pos_loop`: # `trader.refresh()` laeuft, und erst DREI Zeilen spaeter # `_rebind_brk()`. Nach einem Neustart ist `trader_brk.ticket` noch # None und `_pending_tickets` leer (beide leben nur im Speicher) — # Slot 1 greift die BRK-Position also, BEVOR sie zurueckgebunden # werden kann. `_rebind_brk` erkennt das zwar und bricht ab, aber dann # ist es zu spaet: die BRK-Position haengt an Slot 1, ihr G/V steht # links statt rechts, und „G/V BRK" zeigt „—". # Mit Regressionsprobe belegt: `tests/test_slot_gv.py`. self.trader.fremde_tickets = lambda: ( ({self.trader_brk.ticket} if self.trader_brk.ticket else set()) | set(self._pending_tickets or {}) | ({int(self._brk_restore)} if getattr(self, "_brk_restore", None) else set()) ) 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" # ── 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 — # 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") # ⚠⚠ Einsatz je autonomem Pfad MUSS VOR `_load_runtime_state()` stehen. # Der erste Bau setzte die Defaults DANACH (Z. 631 gegen Z. 553) und # ueberschrieb damit jeden gespeicherten Wert mit 0 — die Eingabe im # Dashboard wirkte, war aber nach dem naechsten Neustart weg (real am # 19.08.: BRK 10 / M15 85 gesetzt, nach dem Deploy beide 0). # Reihenfolge ist immer: Default setzen -> runtime_state laedt darueber. self._margin_brk = 0.0 self._margin_m15 = 0.0 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"]) # ⚠ Ohne diese Zeile liefe der BRK-Trade in der gemessen # DURCHGEFALLENEN SL-only-Variante (backtest_brk_slonly.py: # alle zehn KI enthalten die Null). self.trail_brk.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) # BRK-Slot mitfuehren (strikt ticket-gebunden, adoptiert nichts) self.trader_brk.refresh(self.data.symbol) self._rebind_brk() # nach Neustart zurueckbinden self._check_brk_close_cooldown() # Sperre auch bei BRK-Close 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_open_notify() # Telegram bei jeder Eröffnung self._retry_tag_nachtrag() # gescheiterte setup-Nachtraege self._check_slot_reichweite() # meldet ungedeckte 2. Position self._check_adverse15() # 15-Min-Regel (nur Bot-Trades) self._check_flip_close() # Empfehlung dreht → schließen 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 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) 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 _retry_tag_nachtrag(self): """Gescheiterte `setup`-Nachträge erneut versuchen (2026-08-20). ⚠ Der Fill wird aus dem Positions-Snapshot erkannt, die DB-Zeile schreibt aber ein anderer Pfad. Ist der beim ersten Versuch noch nicht durch, trifft das UPDATE keine Zeile. Ohne Wiederholung bliebe der Trade dauerhaft `setup=NULL` — und damit unsichtbar für B4-Monitor, `squeeze_b5` und `squeeze_entry_gap`. ⚠ Nach 60 Versuchen (~1 min) aufgeben und LAUT melden: eine Zeile, die nach einer Minute nicht existiert, kommt nicht mehr — dann ist etwas anderes kaputt, und das soll nicht still bleiben. """ offen = getattr(self, "_tag_nachtrag", None) if not offen or not self.history: return if not hasattr(self, "_tag_versuche"): self._tag_versuche = {} for tk in list(offen): setup = offen[tk] try: if self.history.tag_bot_trade(int(tk), setup): offen.pop(tk, None) self._tag_versuche.pop(tk, None) log.info(f"✅ DB-Nachtrag erfolgreich: T={tk} → {setup}") continue except Exception as e: log.debug(f"_retry_tag_nachtrag: {e}") n = self._tag_versuche.get(tk, 0) + 1 self._tag_versuche[tk] = n if n >= 60: offen.pop(tk, None) self._tag_versuche.pop(tk, None) log.warning(f"⚠ DB-Nachtrag T={tk} nach {n} Versuchen aufgegeben " f"— der Trade bleibt setup=NULL und fehlt in B4/B5.") def _check_brk_close_cooldown(self): """Wiedereinstiegs-Sperre auch für BRK-Closes armieren (Fix 2026-08-20). ⚠⚠ `_check_auto_close` setzt `_pos_close_ts`/`_pos_close_dir` aus `self.trader.snapshot()` — es sieht den BRK-Slot also NICHT. Schloss eine BRK-Position, blieb die Sperre unarmiert, und der Squeeze durfte sofort in dieselbe Richtung neu eröffnen. Genau die Open→Instant-Close-Kaskade, gegen die `_SQUEEZE_REENTRY_COOLDOWN_S` am 27.07. gebaut wurde — damals killte ein zu enger Notfall-Stop den Entry nach 16 s, der Squeeze war noch `active`, und es ging sofort wieder los (doppelter Spread). ⚠ Dieselben Felder wie Slot 1, bewusst: die Sperre ist eine Aussage über den MARKT („in diese Richtung gerade nicht"), nicht über einen Slot. """ try: tk = getattr(self.trader_brk, "ticket", None) vorher = getattr(self, "_brk_letzte", None) if tk: self._brk_letzte = (tk, (self.trader_brk.snapshot() or {}).get("order_type")) return if vorher: self._brk_letzte = None self._pos_close_ts = time.time() self._pos_close_dir = "LONG" if vorher[1] == 0 else "SHORT" log.info("BRK-Slot geschlossen (T=%s) — Wiedereinstiegs-Sperre " "%s armiert.", vorher[0], self._pos_close_dir) except Exception as e: log.debug(f"_check_brk_close_cooldown: {e}") def _rebind_brk(self): """BRK-Position nach einem Neustart zurückbinden (Fix 2026-08-20). ⚠⚠ WARUM. `trader_brk.ticket` lebte nur im Speicher. Nach einem Neustart war es None, `fremde_tickets` lieferte damit eine LEERE Menge — und der adoptierende Haupt-Trader griff die BRK-Position per magic-match. Ab da verwaltete Slot 1 sie, inklusive Notfall-Stop und Trailing. Real am 2026-08-20: „Position erkannt (magic-match)" in derselben Sekunde wie „Engine gestartet". Die Trennung brach also NUR beim Neustart — und danach sah alles normal aus, was es besonders schwer sichtbar machte. ⚠ Erst ab hier greift die Sperre auch nach einem Neustart: sobald das Ticket wieder am BRK-Slot hängt, liefert `fremde_tickets` es aus. ⚠ Existiert die Position nicht mehr (zwischenzeitlich geschlossen), wird der Merker verworfen — kein ewiges Wiederholen. """ tk = getattr(self, "_brk_restore", None) if not tk: return if self.trader_brk.ticket: # schon gebunden self._brk_restore = None return # ⚠ Hat Slot 1 sie bereits adoptiert (Altzustand aus einem Neustart VOR # diesem Fix), NICHT dazwischenfunken — sonst verwalten beide dieselbe # Position. Dann lieber den Merker verwerfen und es melden. if (self.trader.snapshot() or {}).get("ticket") == tk: log.warning(f"⚠ BRK-Ticket {tk} haengt bereits an Slot 1 — " f"Rueckbindung uebersprungen (Altzustand).") self._brk_restore = None return try: if self.trader_brk.bind(int(tk), self.data.symbol): log.info(f"🧷 BRK-Slot nach Neustart zurueckgebunden: T={tk}") self._brk_restore = None if not self.trail_brk.enabled and self.data.symbol: self.trail_brk.toggle(self.data.symbol) log.info("Trailing des BRK-Slots wiederhergestellt") else: # Position existiert nicht mehr → Merker weg log.info(f"BRK-Ticket {tk} nicht mehr offen — Merker verworfen.") self._brk_restore = None except Exception as e: log.debug(f"_rebind_brk: {e}") def _check_open_notify(self): """Telegram bei JEDER Eröffnung — beide Slots (User-Vorgabe 2026-08-19). ⚠ Bewusst über BEIDE Trader: ein autonomer BRK-Entry soll nicht unbemerkt bleiben — genau der Fall, für den der zweite Slot gebaut wurde. Die Meldung nennt deshalb den Slot mit. ⚠ 1× je Ticket (`_open_notify_gemeldet`); ohne den Merker feuerte sie im 1-s-Takt weiter, solange die Position offen ist. ⚠ In der Startup-Schonfrist wird nur GEMERKT, nicht gemeldet: nach einem Neustart erkennt der Trader eine BESTEHENDE Position wieder, und das ist keine Eröffnung. """ if not hasattr(self, "_open_notify_gemeldet"): self._open_notify_gemeldet = set() for tr, quelle in ((self.trader, "Slot 1"), (self.trader_brk, "BRK")): try: ps = tr.snapshot() or {} tk = ps.get("ticket") if not tk or tk in self._open_notify_gemeldet: continue self._open_notify_gemeldet.add(tk) if self._in_startup_grace(): continue lg = ps.get("order_type") == 0 zeilen = [ f"📈 Trade eroeffnet ({quelle})", f"{self.data.symbol or '?'} " f"{'▲ LONG' if lg else '▼ SHORT'} " f"{ps.get('lots')} Lots @ {ps.get('entry_price')}", f"SL {ps.get('sl') or '—'} · TP {ps.get('tp') or '—'}", ] send_telegram("\n".join(zeilen)) except Exception as e: log.debug(f"_check_open_notify: {e}") if len(self._open_notify_gemeldet) > 200: self._open_notify_gemeldet = set(list(self._open_notify_gemeldet)[-50:]) # ⚠⚠ REICHWEITEN-WÄCHTER (2026-08-19). Vier Schutz-Checks kennen bislang NUR # den ersten Slot: `_check_auto_close` (Notfall-Stop, hängt an 4 # Ticket-Variablen), `_check_sr_close`, `_check_adverse15`, # `_check_flip_close`. Solange sie AUS sind, ist das folgenlos — der # BRK-Slot hat Broker-SL, `trail_brk` und den Circuit-Breaker. # ⚠ Wird einer davon wieder eingeschaltet, WÄHREND eine BRK-Position # läuft, gilt er stillschweigend nur für Slot 1: die zweite Position liefe # ohne ihn, und im Dashboard sähe alles normal aus. Dieselbe Klasse wie # Deployment-Drift Fall 8 — nicht die Strategie driftet, sondern ihre # REICHWEITE. Bis die vier slot-fähig sind, macht dieser Wächter das Loch # LAUT statt still (1× je Ticket, kein Dauerlärm). def _check_slot_reichweite(self): try: tk = self.trader_brk.ticket if not tk: self._reichweite_gemeldet = None return None offen = [] if ((getattr(self, "_emergency_margin_pct", 0) or 0) > 0 or (getattr(self, "_emergency_pct", 0) or 0) > 0 or (getattr(self, "_emergency_remember", 0) or 0) > 0): offen.append("Notfall-Stop") if getattr(self, "_auto_sr_close", False): offen.append("S/R-Close") if (getattr(self, "_adverse15_atr", 0) or 0) > 0: offen.append("15-Min-Regel") if getattr(self, "_flip_close", False): offen.append("Flip-Close") if not offen: self._reichweite_gemeldet = None return None if getattr(self, "_reichweite_gemeldet", None) != tk: self._reichweite_gemeldet = tk log.warning("⚠ REICHWEITE: %s aktiv, gilt aber NUR für Slot 1 — " "die BRK-Position T=%s läuft ohne (Broker-SL, " "Trailing und Circuit-Breaker greifen weiter).", ", ".join(offen), tk) return offen except Exception as e: log.debug(f"_check_slot_reichweite: {e}") return None 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() # ⚠ BEIDE Slots — s. Begruendung im Snapshot-Block. ps_brk = self.trader_brk.snapshot() or {} open_pnl = float(ps.get("pnl") or 0.0) + float(ps_brk.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") # ⚠ Der zweite Slot MUSS mit — eine Notbremse, die nur die Haelfte # schliesst, ist keine. if ps_brk.get("ticket"): try: e2 = self.trader_brk.close(reason="circuit_breaker") if e2: log.warning(f"Circuit-Breaker: BRK-Slot nicht geschlossen — {e2}") except Exception as ce: log.warning(f"Circuit-Breaker BRK-Close: {ce}") 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 # ⚠⚠ Der BRK-Slot wird hier NICHT erfasst: `ps` kommt aus # `self.trader.snapshot()`. Schliesst eine BRK-Position, armiert die # Wiedereinstiegs-Sperre also NICHT — und der Squeeze duerfte sofort # in dieselbe Richtung neu eroeffnen. Genau die Open->Instant-Close- # Kaskade, gegen die die Sperre am 27.07. gebaut wurde. # `_check_brk_close_cooldown` (im `_pos_loop`) schliesst das. 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. # ⚠ 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" 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 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 # ⚠⚠ RICHTUNG AUS DEM VETO, NICHT AUS DER LESART (Korrektur 19.08.). # Der erste Bau nahm `lesart` (= Backtest-Variante A) und eroeffnete # am 18:03 SHORT, waehrend die Karte LONG anzeigte -- also die # VERBOTENE Richtung. Die Anzeige (`app.js:504`) nimmt `erl`, die vom # Veto ERLAUBTE Richtung; das ist die einzige beidhaelftig robuste # Aussage der Karte (gegen das Signal -6,60 EUR/Lot ueber 1.216 # Trades). Ein Auto-Entry auf eine ANGEZEIGTE Empfehlung muss lesen, # was angezeigt wird -- sonst sind es zwei Quellen fuer eine Aussage. # ⚠ Damit entspricht der Pfad jetzt am ehesten der gemessenen # KONTROLLE C (HTF-Richtung, OeR -0,085/-0,030) -- der am wenigsten # schlechten der vier Varianten, aber weiterhin NEGATIV. verb = ((s.get("bias") or {}).get("verboten") or "").upper() if verb not in ("LONG", "SHORT"): # Kein Veto -> die Karte gibt ausdruecklich KEINE Richtung # ("kein Trend-Veto", app.js:495). Dann auch kein Entry. self._m15_last_entry_sig = None return nah = s.get("nah") or {} lvl = nah.get("level") if lvl is None: return d = -1 if verb == "LONG" else 1 # = die ERLAUBTE Richtung 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"(erlaubt, verboten={verb}, Lesart {les}, Level {lvl}) " f"— 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. ⚠ 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 # ⚠⚠ Der BRK-Slot ist STRIKT ticket-gebunden und adoptiert nichts — # er kann den Fill also nicht selbst finden. Deshalb hier direkt # am Broker nachsehen, welches der liegenden Tickets zu einer # Position geworden ist, und es dem Slot ZUWEISEN. # (Bei einem Pending-Fill ist die Positions-Nummer identisch mit # der Order-Nummer — an vier echten Datensaetzen verifiziert.) if self.trader_brk.ticket is None: try: with mt5_lock(timeout=5) as _got: offen = {int(p.ticket) for p in (mt5.positions_get() or ())} if _got else set() except Exception: offen = set() for _tk in list(self._pending_tickets): if int(_tk) in offen and self.trader_brk.bind(int(_tk), self.data.symbol): log.info(f"🧷 BRK-Slot uebernimmt Pending-Fill T={_tk}") break ps = self.trader_brk.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 # ⚠⚠ RENNEN, real am 2026-08-20: der Fill wird hier aus # `trader_brk.snapshot()` erkannt, die DB-ZEILE schreibt aber ein # anderer Pfad (`trader._refresh_locked` → `log_trade_open`). Ist # der noch nicht durch, trifft das UPDATE keine Zeile und der # Nachtrag scheitert STILL — der Trade bleibt `setup=NULL` und ist # von einem manuellen nicht unterscheidbar. Genau die # Buchfuehrungs-Luecke, gegen die `tag_bot_trade` gebaut wurde # (Deployment-Drift Fall 8), nur eine Ebene tiefer. # ⚠ Der Fall ist NICHT theoretisch: T=50371599 lag danach mit # `setup=NULL` in der DB, obwohl die Zeile Sekunden spaeter da war. # Deshalb nachreichen statt aufgeben — dasselbe Retry-Muster wie # beim Gewinn-Close (`_sr_close_min_gain_armed_ticket`, 23.07.). if not ok: if not hasattr(self, "_tag_nachtrag"): self._tag_nachtrag = {} self._tag_nachtrag[int(tk)] = setup # Dieselbe Buchfuehrung wie beim Market-Pfad, damit B4/B5 zaehlen self._bot_open_ticket = tk self._bot_open_source = "auto_squeeze" 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: # ⚠ NUR der eigene Slot entscheidet — vorher stornierte JEDE # offene Position die Pendings (real am 19.08.: 5 Fenster, # zusammen ~2 min am Markt, 3 davon vom M15-Trader). ps = self.trader_brk.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), # ⚠⚠ 7 von 8 BRK-Trades fuellen per RUHENDER Order, nicht # ueber `_open`. Ohne diese Zeile waere der Einsatz je # Pfad fuer BRK praktisch WIRKUNGSLOS — Fund der # Gesamtpruefung 2026-08-19. buffer_pct=(getattr(self, "_margin_brk", 0.0) or 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. # ⚠ EIGENER Slot: eine manuelle oder M15-Position blockt BRK # nicht mehr (User-Vorgabe 2026-08-19). ps = self.trader_brk.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") # ⚠ Der Reverse gehoert zum BRK-Pfad und muss dessen Slot # schliessen — vorher traf er Slot 1, also die MANUELLE Position. # Dormant (`auto_squeeze_reverse=false`), aber falsch verdrahtet. err = self.trader_brk.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 "auto_m15" in st: self._auto_m15 = bool(st["auto_m15"]) # ⚠ Nur MERKEN — `bind()` braucht MT5, das beim Laden noch nicht # zwingend bereit ist. Das Zurueckbinden macht `_rebind_brk()` im # `_pos_loop`, bis es klappt oder das Ticket weg ist. if st.get("brk_ticket"): self._brk_restore = int(st["brk_ticket"]) if "margin_brk" in st: self._margin_brk = max(0.0, min(99.0, float(st["margin_brk"] or 0))) if "margin_m15" in st: self._margin_m15 = max(0.0, min(99.0, float(st["margin_m15"] 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), # ⚠⚠ Das BRK-Ticket MUSS persistiert werden. Ohne # das ist `trader_brk.ticket` nach einem Neustart # None, die `fremde_tickets`-Sperre liefert eine # LEERE Menge, und der Haupt-Trader adoptiert die # BRK-Position per magic-match. Real am 20.08.: # 08:00:44 "Position erkannt (magic-match)" in # derselben Sekunde wie "Engine gestartet". "brk_ticket": getattr(self.trader_brk, "ticket", None), "margin_brk": getattr(self, "_margin_brk", 0.0), "margin_m15": getattr(self, "_margin_m15", 0.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 # 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 _slot(self, source: str): """Welcher Trader fuer welche Quelle? (Zwei-Slot-Umbau 2026-08-19) ⚠⚠ LUECKE, real am 2026-08-20 aufgefallen: der Market-Fallback des Squeeze lief weiter ueber `self.trader` und scheiterte an "Position bereits offen!", sobald ein manueller Trade lief — obwohl BRK seit gestern einen EIGENEN Slot hat. Der FLAT-Check und der Pending-Manager waren umgehaengt, der Bestellweg nicht. Genau die Fehlerklasse, die im Projekt als Deployment-Drift Fall 8 steht: ein Umbau, der den Bestellweg aendert, muss ALLE Bestellwege mitnehmen — sonst laeuft ein Teil weiter auf dem alten Pfad. """ return self.trader_brk if source == "auto_squeeze" else self.trader def open_long(self, source: str = "manuell") -> str | None: return self._open(self._slot(source).open_long, "LONG", source) def open_short(self, source: str = "manuell") -> str | None: return self._open(self._slot(source).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") # ⚠ An den Slot, der die Order sendet — sonst landen die 16 ctx_*-Felder # eines BRK-Trades am falschen Objekt (Fix 2026-08-20). self._slot(source).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 # ⚠⚠ EINSATZ JE PFAD (User-Wunsch 2026-08-19). Quellen-abhaengig # wie die SL-Zeitebene direkt darueber, und aus demselben Grund: BRK und # M15 sind verschiedene Populationen mit verschieden gemessener Guete # (autonom -4,71 EUR/Lot gegen Mensch +1,56). 0 = globaler Wert. # ⚠ Das ist KEINE gleichzeitige Aufteilung der Margin — der Bot haelt # EINE Position. Es legt fest, wie gross die Position wird, WENN der # jeweilige Pfad eroeffnet. Eine echte Reservierung braucht den # ticket-basierten Umbau (42 Einzel-Positions-Annahmen). _mg = {"auto_squeeze": getattr(self, "_margin_brk", 0.0), "auto_m15": getattr(self, "_margin_m15", 0.0)}.get(source, 0.0) if _mg > 0: log.info(f"[ORDER] Einsatz fuer {source}: {_mg:.0f}%") err = fn(sym, _atr_tf, _mg or None) 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: # ⚠ Ticket vom Slot holen, der die Order gesendet hat. self._bot_open_ticket = self._slot(source).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, slot: str | None = None) -> str | None: """Schliesst die offene Position — BEIDE Slots (Fix 2026-08-20). ⚠⚠ Bis heute lief das nur auf `self.trader`. Hielt der BRK-Slot eine Position und Slot 1 war leer, tat der CLOSE-Knopf im Dashboard NICHTS — der Nutzer konnte einen autonom eroeffneten Trade nicht von Hand schliessen. Das ist die gefaehrlichste Variante der Slot-Luecke: nicht eine Zahl stimmt nicht, sondern eine SCHUTZHANDLUNG greift ins Leere. ⚠ Reihenfolge bewusst Slot 1 zuerst: das ist die manuelle Position, und wer CLOSE drueckt, meint in aller Regel die, die er selbst geoeffnet hat. Sind beide offen, werden beide geschlossen und Fehler gesammelt gemeldet. """ fehler = [] # ⚠ `slot` waehlt gezielt: None/"" = BEIDE (Verhalten des CLOSE-Knopfs in # der Aktionsleiste), "main" = nur Slot 1, "brk" = nur der # Breakout-Autotrader. Die zwei kleinen Knoepfe an den G/V-Anzeigen # nutzen die gezielte Form (User-Wunsch 2026-08-20). paare = [(self.trader, "Slot 1"), (self.trader_brk, "BRK")] if slot == "main": paare = paare[:1] elif slot == "brk": paare = paare[1:] for tr, name in paare: try: if not (tr.snapshot() or {}).get("ticket"): continue e = tr.close(reason="manual") if e: fehler.append(f"{name}: {e}") except Exception as ex: fehler.append(f"{name}: {ex}") return "; ".join(fehler) if fehler else None def toggle_trail(self) -> bool: """TRAIL-Knopf — trifft den Slot, der tatsaechlich eine Position haelt. ⚠⚠ Bis 2026-08-20 schaltete er NUR `self.trail`. Hielt allein der BRK-Slot eine Position, liess sich dessen Trailing per Dashboard weder ein- noch ausschalten — und ohne `trail_brk` laeuft ein BRK-Trade in der gemessen DURCHGEFALLENEN SL-only-Variante (`backtest_brk_slonly.py`: alle zehn KI enthalten die Null, Trefferquote faellt 40 -> 21 %). ⚠ Haelt NUR der BRK-Slot etwas, wird dessen Trailing geschaltet; sonst (auch bei zwei offenen Positionen) das von Slot 1 — das ist die Position, die der Nutzer selbst geoeffnet hat. """ sym = self.data.symbol if not sym: return self.trail.enabled nur_brk = (bool(getattr(self.trader_brk, "ticket", None)) and not (self.trader.snapshot() or {}).get("ticket")) return (self.trail_brk if nur_brk else self.trail).toggle(sym) 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]] = [] # ⚠⚠ BEIDE Slots markieren (Fix 2026-08-20). Vorher nur # `self.trader` — eine offene BRK-Position blieb im # MT5-Chart UNMARKIERT. Dieselbe Klasse wie # Deployment-Drift Fall 8: nicht die Strategie driftet, # sondern ihre Beobachtbarkeit. Wer den Chart ansieht, # soll sehen, wo der Bot eingestiegen ist. for _tr in (self.trader, self.trader_brk): ps = _tr.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_auto_margin(self, brk: float, m15: float) -> dict: """Einsatz-Prozentsatz JE AUTONOMEM PFAD (User-Wunsch 2026-08-19). 0 = der globale Wert (`margin_buffer_pct`, live 40) gilt — also das bisherige Verhalten. > 0 = dieser Pfad eroeffnet mit genau diesem Anteil der freien Margin. ⚠⚠ DAS IST KEINE RESERVIERUNG. Der Bot haelt EINE Position; es kann also nie BRK und M15 gleichzeitig geben. Das Feld steuert die GROESSE der jeweiligen Position, nicht einen parallel vorgehaltenen Topf. Eine echte Reservierung verlangt den ticket-basierten Umbau (42 Einzel-Positions- Annahmen in `engine.py`, 25 in `trader.py`) — bis dahin wuerde der Schutz-Stack (Trailing, Notfall-Stop, S/R-Close, Time-Stop) an der FALSCHEN der beiden Positionen haengen. ⚠ Manuelle Trades bleiben unberuehrt — fuer die gilt weiter der globale Prozentsatz. Das ist Absicht: der Mensch ist die gemessen bessere Population (+1,56 gegen -4,71 EUR/Lot), seine Groesse gehoert ihm. Neustart-fest ueber `runtime_state.json`.""" self._margin_brk = max(0.0, min(99.0, float(brk or 0))) self._margin_m15 = max(0.0, min(99.0, float(m15 or 0))) log.info(f"Einsatz je Pfad: BRK {self._margin_brk:.0f}% · " f"M15 {self._margin_m15:.0f}% (0 = global)") self._save_runtime_state() return {"brk": self._margin_brk, "m15": self._margin_m15} 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 # ⚠⚠ BEIDE Slots summieren (Zwei-Slot-Umbau 2026-08-19). Wuerde hier # nur der erste stehen, saehe der Tages-P&L eine offene BRK-Position # nicht — und der Circuit-Breaker griffe zu spaet, also genau dann # nicht, wenn zwei Positionen gleichzeitig laufen und das Risiko # doppelt ist. open_pnl = float(position.get("pnl") or 0.0) try: open_pnl += float((self.trader_brk.snapshot() or {}).get("pnl") or 0.0) except Exception: pass 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._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) # 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 = _pnl_hl_brk = None try: if _mkt_closed and _hl_live: _mid = float(_hl_live["mid_mt5"]) _sp = float((market or {}).get("spread") or 0.0) _b, _a = _mid - _sp / 2.0, _mid + _sp / 2.0 if position and position.get("ticket"): _v = self.trader.live_pnl(_b, _a) if _v is not None: _pnl_hl = round(_v, 2) # ⚠⚠ AUCH fuer den BRK-Slot (2026-08-20). Ohne das zeigte bei # geschlossenem Markt links eine HL-SCHAETZUNG und rechts der # EINGEFRORENE Broker-Wert — zwei verschiedene Rechnungen # nebeneinander, ohne dass man es der Anzeige ansieht. # `live_pnl` liest ausschliesslich den Zustand SEINER Instanz, # ist also je Slot sauber; `_tick_size`/`_tick_value` sind am # BRK-Slot gesetzt, weil `_refresh_locked` sie bei gesetztem # Ticket jeden Tick nachzieht (geprueft, nicht angenommen). if getattr(self.trader_brk, "ticket", None): _vb = self.trader_brk.live_pnl(_b, _a) if _vb is not None: _pnl_hl_brk = round(_vb, 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(), "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. "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), # ⚠⚠ ZWEITER Slot sichtbar machen. Ohne dieses Feld liefe eine # offene BRK-Position unbeobachtet — genau Deployment-Drift Fall 8 # (dort driftete nicht die Strategie, sondern ihre Beobachtbarkeit). "position_brk": (lambda ps: { "ticket": ps.get("ticket"), "lots": ps.get("lots"), "entry": ps.get("entry_price"), "pnl": ps.get("pnl"), "sl": ps.get("sl"), "tp": ps.get("tp"), "trail": bool(getattr(self.trail_brk, "enabled", False)), } if ps.get("ticket") else None)(self.trader_brk.snapshot()), # Einsatz je autonomem Pfad; 0 = der globale `margin_pct` gilt. "margin_brk": getattr(self, "_margin_brk", 0.0), "margin_m15": getattr(self, "_margin_m15", 0.0), # 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, "pnl_hl_brk": _pnl_hl_brk, } # ── 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} # ── 3c) M5-KONTEXT — reine ANZEIGE, KEINE Richtung, KEIN Banner # (User-Entscheidung 2026-08-19 gegen eine eigene M5-Karte). # ⚠⚠ Warum KEINE zweite Setup-Karte: die entkoppelte Selbstmessung # dieser Karte steht bei n=342 auf 53,5 % [48,2 .. 58,8] (30 min) und # 49,3 % (60 min) — die am 12.08. VORAB fixierte Bedingung ("bei # >=350 Faellen ueber 50 % UND KI ohne die 50") ist damit bei # vorhandener Stichprobe NICHT erfuellt. Dazu faellt die Karte als # Handelsregel durch (`backtest_m15_auto.py`: OeR -0,162/-0,094, # beide KI ohne Null). Eine zweite Karte derselben Bauart wuerde ein # als Signal gemessen NEGATIVES Muster vervielfachen — und die # Veto->Richtung-Falle, die in EINER Woche dreimal zuschlug, auf eine # zweite Flaeche ausdehnen. # ⚠ Deshalb bewusst nur ZUSTAND, kein Urteil: Squeeze-Lage, Kosten, # ATR. Der M5-RAUM steht bereits in `motor.raum` (Gate 3b rechnet # ohnehin auf M5) und wird hier NICHT wiederholt. # ⚠ NUR ZUSAMMENGESETZT, nichts neu gerechnet — `wave.squeeze` und # `cost_ratio` stehen im Snapshot ohnehin. Ein zweiter Rechenweg war # der Fehler des Konsens-Pfeils (komplettes zweites `_verdict` im # 5-s-Takt) und die Quelle jeder Divergenz zwischen Karte und Chart. try: _sq = (wave_snap or {}).get("squeeze") or {} _atr5 = ((wave_snap or {}).get("pb_feats") or {}).get("atr") out["m5"] = { "squeeze": _sq.get("state"), "squeeze_dir": _sq.get("dir"), "box_atr": _sq.get("box_atr"), "atr": _atr5, # Spread/ATR_M5 — Schwellen aus `backtest_realcosts.py`: # Median 0,193 · ab 0,32 die dokumentierte Kostenfalle. "kosten": (round((ask - bid) / _atr5, 3) if (bid and ask and _atr5 and _atr5 > 0) else None)} except Exception: out["m5"] = None # ── 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