""" 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, parse_zones, ) 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 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.candle_logger import CandleLogger from core.wave_rec import (_atr as _wr_atr, _ema_last, _EMA_FAST, _EMA_SLOW, _TREND_DEADBAND, _STRETCH_MAX) 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 _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") _SQUEEZE_NIGHT = (0, 1, 2, 3, 4, 5, 6, 7) # Auto-Squeeze-Nacht-Sperre (Kostenfalle) _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) _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`. _PB_MU = (0.7482, 0.7036, 0.9112, 0.7072) _PB_SD = (1.1597, 0.9687, 0.2844, 0.6403) _PB_W = (-0.5853, 0.1192, 0.7820, 0.1204, -0.2994) # [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)))) 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(), ) # ── Markt / Trading / Trailing ──────────────────────────────────── self.data = MT5Data() self.trader = TradeManager() self.trader.history = self.history self.trail = TrailingManager(self.trader) self.trail.set_notify(self._on_trail_event) # (Dry-Run-Auto-Trader entfernt 2026-07-23 — war Verlierer im Backtest, # UI-Toggle längst weg. `min_conf`-Anzeigekonstante bleibt, s. unten.) # ── Analyse-Module ──────────────────────────────────────────────── self.tu = TradersUnionProvider() self.wave = WaveRecommender(self.tu, mt5.TIMEFRAME_M5) try: self.wave.set_breakout_k(float(self.cfg["trading"].get("breakout_k", "1.0"))) except (TypeError, ValueError): pass try: self.wave.set_entry_room(float(self.cfg["trading"].get("entry_room_atr", "0.6"))) except (TypeError, ValueError): pass # 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 # 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) # 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) # 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" self._last_rec: dict = {} self._agent_fail_cnt = 0 self._close_alert_ts = 0.0 # letzter CLOSE-Telegram-Push (5-min-Cooldown) self._close_alert_ticket = None # Ticket, auf das sich der Cooldown bezieht self._close_alert_count = 0 # wie oft der Flip-Close-Alarm gefeuert hat self._auto_sr_close = self.cfg["trading"].get("auto_sr_close", "true").lower() == "true" try: self._sr_close_pbreak = float(self.cfg["trading"].get("sr_close_pbreak", "0.60")) except (TypeError, ValueError): self._sr_close_pbreak = 0.60 self._sr_close_pbreak = max(0.30, min(0.90, self._sr_close_pbreak)) # sinnvolles Band self._sr_close_count = 0 # wie oft der Bot am S/R automatisch schloss self._sr_close_last_ticket = None # verhindert Doppel-Auslösung je Ticket # Autonomer Entry auf den gemessen-validierten Squeeze-Ausbruch (nur FLAT). # ⚠ ECHTE Order — Default AUS (Autonomie nie stillschweigend an). `[trading] # auto_squeeze=true` aktiviert. Dedup über (dir, level) des Ausbruchs. self._auto_squeeze = self.cfg["trading"].get("auto_squeeze", "false").lower() == "true" self._squeeze_skip_night = self.cfg["trading"].get("auto_squeeze_skip_night", "true").lower() == "true" # 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) self._squeeze_reverse_sig = None # Dedup Reverse-Close (Gegen-Trade) self._squeeze_entry_count = 0 # ── Autonomer Entry auf die EMPFEHLUNG (User-Wunsch 2026-07-30) ─────────── # ⚠ GEMESSEN NICHT tragfähig (H1 negativ, s. backtest_auto_signal.py) — auf # ausdrücklichen User-Wunsch gebaut, Default AUS, mit den besten Parametern. _t = self.cfg["trading"] self._auto_signal = _t.get("auto_signal", "false").lower() == "true" try: self._auto_signal_min_conf = int(float(_t.get("auto_signal_min_conf", "75"))) except (TypeError, ValueError): self._auto_signal_min_conf = 75 self._auto_signal_skip_night = _t.get("auto_signal_skip_night", "true").lower() == "true" self._signal_last_sig = None # Dedup: (dir, Signal-Episode) 1× handeln self._signal_entry_count = 0 # 15-Minuten-Regel für BOT-eröffnete Trades (gemessen kalibriert: 0,5×ATR) 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._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._pos_close_dir = None # Art) → Squeeze-Wiedereinstiegs-Sperre (gegen die # Open→Instant-Close-Kaskade bei zu engem Notfall-Stop) self._sr_close_min_gain_armed_ticket = None # Ticket, für das die %-Schwelle schon # aus echter Margin berechnet wurde (Retry-Schutz, # s. `_check_auto_close`) try: self._sr_close_min_gain = max(0.0, float( self.cfg["trading"].get("sr_close_min_gain", "0"))) except (TypeError, ValueError): self._sr_close_min_gain = 0.0 # Gewinn-Close automatisch auf X% des Einsatzes (Margin) DIESER Position setzen, # sobald sie eröffnet wird (User-Vorgabe 2026-07-23) — überschreibt den manuell # gemerkten/persistierten Wert bei jeder ECHT NEUEN Position (nicht beim # Wiedererkennen nach Neustart, s. `_check_auto_close`). 0 = aus (dann bleibt der # manuell gesetzte/gemerkte Wert wie bisher maßgeblich). try: self._sr_close_min_gain_pct = max(0.0, float( self.cfg["trading"].get("sr_close_min_gain_pct", "1.0"))) except (TypeError, ValueError): self._sr_close_min_gain_pct = 1.0 self._pb_state = None # geglättetes P(break) + Hysterese-Zone je Level self._stop_pb_state = None # dito für die Gegen-/Stop-Seiten-Anzeige # (gemessen `analyze_pbreak_flicker.py`: mom3 der # laufenden Kerze ließ P ~7–15 Pp je M5-Bar springen) # UI-Einstellungen neustart-fest (Fix 2026-07-19): Toggles/Werte, die über # die App gesetzt werden (Auto-Squeeze, S/R-Auto-Close, Mindestgewinn), # überschreiben nach dem Laden die ini-Defaults — vorher war z. B. ein am # Handy gesetzter Mindestgewinn nach jedem restart_server.bat wieder weg. import os as _os2 self._runtime_state_path = _os2.path.join( _os2.path.dirname(_os2.path.dirname(_os2.path.abspath(__file__))), "runtime_state.json") self._load_runtime_state() self._mql5_files_dir = None # \MQL5\Files (lazy, für den Indikator) self._export_mql5 = self.cfg["trading"].get("export_mql5_levels", "true").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)) 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}") return True def stop(self): self._running = False try: self.data.disconnect() except Exception: pass def _adopt_positions(self): """Beim Start vorhandene MT5-Positionen übernehmen (wie main() im Widget).""" try: all_pos = mt5.positions_get() or [] log.info(f"Sofort-Adoption: {len(all_pos)} offene Position(en)") if all_pos: own = [p for p in all_pos if getattr(p, "magic", 0) == MAGIC] pick = own[0] if own else all_pos[0] if pick.symbol != self.data.symbol: log.info(f"Symbol-Switch beim Start: {self.data.symbol} → " f"{pick.symbol} (offene Position T={pick.ticket})") ok, msg = self.data.switch_symbol(pick.symbol) if not ok: log.warning(f"Symbol-Switch fehlgeschlagen: {msg}") self.trader.refresh(self.data.symbol) except Exception as e: log.warning(f"Sofort-Adoption: {e}", exc_info=True) # ══════════════════════════════════════════════════════════════════════ # LOOPS (jeder MT5-Call sichert sich intern den globalen mt5_lock) # ══════════════════════════════════════════════════════════════════════ def _price_loop(self): cnt = 0 while self._running: cnt += 1 try: self.data.fetch_price() s = self.data.snapshot() if s.get("bid") and s.get("ask") and self.data.symbol: try: self.trail.on_price(self.data.symbol, s["bid"], s["ask"]) except Exception as te: log.warning(f"trail.on_price: {te}") except Exception as e: log.error(f"_price_loop#{cnt}: {e}", exc_info=True) time.sleep(_PRICE_S) def _trend_loop(self): while self._running: try: self.data.fetch_trend() if self.data.symbol: # S/R-Kontext an das Wellenmodul geben (für die Konfidenz) try: srl = self._sr_levels(self.data.snapshot()) self.wave.set_sr_context(srl.get("resistance"), srl.get("support")) except Exception as se: log.warning(f"set_sr_context: {se}") try: self.gaps.maybe_refresh(self.data.symbol) except Exception as ge: log.warning(f"gaps.maybe_refresh: {ge}") try: self.wave.refresh_market(self.data.symbol) except Exception as we: log.warning(f"wave.refresh_market: {we}") try: self.elliott.refresh_market(self.data.symbol) except Exception as ee: log.warning(f"elliott.refresh_market: {ee}") try: self.structure.refresh_market(self.data.symbol) except Exception as se2: log.warning(f"structure.refresh_market: {se2}") try: self.patterns.refresh_market(self.data.symbol) except Exception as pe2: log.warning(f"patterns.refresh_market: {pe2}") try: self.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}") self._run_analysis() except Exception as e: log.error(f"_trend_loop: {e}", exc_info=True) self._sleep(_TREND_S) def _pos_loop(self): while self._running: try: if self.data.symbol: self.trader.refresh(self.data.symbol) self._check_circuit_breaker() # Tagesverlust-Stopp (Überleben zuerst) self._check_auto_close() self._check_adverse15() # 15-Min-Regel (nur Bot-Trades) self._check_sr_close() self._check_auto_squeeze() # autonomer Entry auf Squeeze-Ausbruch self._check_auto_signal() # autonomer Entry auf die Empfehlung 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 get_bars(self, tf_label: str, n: int = 90) -> dict: """OHLC-Bars + EMA12/50 + Trend je Timeframe für den Charts-Tab. Läuft unter `mt5_lock`. Trend = EMA12 vs EMA50 mit Totband (_TREND_DEADBAND×ATR).""" import MetaTrader5 as mt5 from core.mt5_utils import mt5_lock from core.wave_rec import _ema_series, _atr, _EMA_FAST, _EMA_SLOW, _TREND_DEADBAND tf_map = {"M1": mt5.TIMEFRAME_M1, "M5": mt5.TIMEFRAME_M5, "M15": mt5.TIMEFRAME_M15, "M30": mt5.TIMEFRAME_M30, "H1": mt5.TIMEFRAME_H1} tf = tf_map.get(tf_label); sym = self.data.symbol if tf is None or not sym: return {"tf": tf_label, "bars": [], "error": "kein TF/Symbol"} n = max(30, min(int(n), 200)) try: with mt5_lock(timeout=5) as got: if not got: return {"tf": tf_label, "bars": [], "error": "busy"} rates = mt5.copy_rates_from_pos(sym, tf, 0, n + _EMA_SLOW) except Exception as e: return {"tf": tf_label, "bars": [], "error": str(e)} if rates is None or len(rates) == 0: return {"tf": tf_label, "bars": [], "error": "keine Daten"} closes = [float(b["close"]) for b in rates] highs = [float(b["high"]) for b in rates] lows = [float(b["low"]) for b in rates] e12 = _ema_series(closes, _EMA_FAST); e50 = _ema_series(closes, _EMA_SLOW) atr = _atr(highs, lows, closes) or 0.0 d = e12[-1] - e50[-1] dead = _TREND_DEADBAND * atr if atr else 0.0 trend = "flat" if abs(d) <= dead else ("up" if d > 0 else "down") k = min(n, len(rates)) bars = [{"t": int(b["time"]), "o": float(b["open"]), "h": float(b["high"]), "l": float(b["low"]), "c": float(b["close"])} for b in rates[-k:]] # S/R-Linien fürs Chart-Overlay: echte Pivot-Struktur (`_draw_levels`, # identisch zum Auto-Close-System) — NICHT mehr die stale [zones]-Config. try: dl = self._draw_levels() levels = {"res": dl.get("res", []), "sup": dl.get("sup", []), "zones": []} except Exception: levels = {"res": [], "sup": [], "zones": []} # Regressionskanal — nur M30 (dort ist die Marktstruktur berechnet), als # Linien-Arrays deckungsgleich mit den Bars (Overlay im Charts-Tab). channel = None patterns = [] if tf_label == "M30": try: from core.structure import channel_series channel = channel_series(closes, atr, k) except Exception: channel = None # Visuelle Chartmuster (M30) fürs Overlay — Trigger-/Ziel-Preislinien. try: patterns = (self.patterns.snapshot() or {}).get("patterns") or [] except Exception: patterns = [] return {"tf": tf_label, "symbol": sym, "trend": trend, "bars": bars, "ema12": [round(x, 3) for x in e12[-k:]], "ema50": [round(x, 3) for x in e50[-k:]], "levels": levels, "channel": channel, "patterns": patterns, "broker_utc_offset": 3 * 3600} def _in_startup_grace(self) -> bool: """True in den ersten `_startup_grace_s` s nach Bot-Start — dann schließt der Bot keinen laufenden Trade selbst (Daten noch nicht stabil nach Neustart).""" return time.time() < self._no_autoclose_until # ── Circuit Breaker (Tagesverlust-Stopp) ───────────────────────────────────── def _day_realized_cached(self) -> float: """Realisierter Tages-P&L (heute geschlossene Trades), gecacht ~30 s — damit die SQLite-Abfrage nicht jeden 1-s-Tick läuft.""" now = time.time() if now - self._cb_realized[0] < 30: return self._cb_realized[1] try: v = float((self.history.stats_overview("today") or {}).get("total_pnl") or 0.0) except Exception: v = self._cb_realized[1] self._cb_realized = (now, v) return v def _circuit_halted(self) -> bool: """True, wenn der Circuit Breaker HEUTE (Berlin) bereits ausgelöst hat → keine neuen Auto-Trades bis zum nächsten Tag.""" if self._cb_limit_pct <= 0 or not self._circuit_tripped_date: return False return self._circuit_tripped_date == datetime.now(_BERLIN).date() def _check_circuit_breaker(self): """Tagesverlust-Stopp (User-Vorgabe 2026-07-27): erreicht der Tages-P&L (realisiert heute + offene Position) −`daily_loss_limit_pct`% der Balance, schließt der Bot die Position und sperrt neue Auto-Trades bis zum nächsten Tag. Wirkt auch bei gesperrtem Handy. Fail-safe.""" if self._cb_limit_pct <= 0: return if self._in_startup_grace(): # nach Neustart nicht auf halb-init Daten feuern return today = datetime.now(_BERLIN).date() if self._circuit_tripped_date and self._circuit_tripped_date != today: self._circuit_tripped_date = None # neuer Tag → Reset log.info("Circuit Breaker zurückgesetzt (neuer Handelstag)") if self._circuit_tripped_date == today: return # heute schon ausgelöst bal = float(self.data.balance or 0.0) if bal <= 0: return ps = self.trader.snapshot() open_pnl = float(ps.get("pnl") or 0.0) day_total = self._day_realized_cached() + open_pnl limit_eur = self._cb_limit_pct / 100.0 * bal if day_total > -limit_eur: return self._circuit_tripped_date = today log.warning(f"🛑 CIRCUIT BREAKER: Tages-P&L {day_total:+.2f} ≤ -{limit_eur:.2f} € " f"({self._cb_limit_pct:.0f}% von {bal:.0f}) → Position schließen, " f"keine Auto-Trades mehr heute") sym = self.data.symbol or "" tg = self.cfg["telegram"] tg_on = (tg.get("enabled", "false").lower() == "true" and tg.get("bot_token")) if ps.get("ticket"): err = self.trader.close(reason="circuit_breaker") if err: log.error(f"Circuit-Breaker-Close fehlgeschlagen: {err}") if tg_on: try: send_telegram(f"🛑 CIRCUIT BREAKER\n{sym} Tagesverlust {day_total:+.2f} € " f"≤ -{limit_eur:.2f} € ({self._cb_limit_pct:.0f}% der Balance). " f"Position geschlossen, keine Auto-Trades mehr heute.", tg["bot_token"], tg["chat_id"]) except Exception as e: log.warning(f"Circuit-Breaker-Telegram: {e}") def _check_auto_close(self): """Server-seitiger Auto-Close (wirkt auch bei gesperrtem Handy): Notfall-Stop (P&L ≤ −Schwelle) UND Gewinn-mitnehmen (P&L ≥ +Ziel). Beide werden bei einer NEUEN Position automatisch auf den zuletzt genutzten Wert gesetzt; manuelles Übersteuern im laufenden Trade bleibt erhalten.""" ps = self.trader.snapshot() ticket = ps.get("ticket") if not ticket: # Flat-Übergang: hatten wir gerade noch eine Position, merke Zeit+Richtung # → Squeeze-Wiedereinstiegs-Sperre (gegen Open→Instant-Close-Kaskade). Gilt # für JEDE Close-Art (Notfall/SL/Time-Stop/manuell); der S/R-Close hat schon # seinen eigenen Marker, dieser deckt den Rest ab. if self._last_pos_ticket is not None and self._open_pos_dir: self._pos_close_ts = time.time() self._pos_close_dir = self._open_pos_dir self._open_pos_dir = None self._last_pos_ticket = None self._emergency_loss = None self._takeprofit = None return self._open_pos_dir = "LONG" if ps.get("order_type") == 0 else "SHORT" if ticket != self._last_pos_ticket: # neue/wiedererkannte Position self._last_pos_ticket = ticket if ticket == self._emergency_state_ticket: # Dieselbe (noch offene) Position nach Neustart/Wiedererkennung → # persistierten Wert WIEDERHERSTELLEN (inkl. manueller Übersteuerung), # NICHT auf die pct-Automatik zurücksetzen (Fix 2026-07-17: Neustart # bei offenem Trade kappte sonst die von Hand gesetzten −25 € auf −19,42). self._emergency_loss = self._emergency_state_loss self._takeprofit = self._emergency_state_tp self._sr_close_min_gain_armed_ticket = ticket # wiederhergestellter Wert # gilt als final — NICHT auf 1% zurücksetzen self._emergency_margin_armed_ticket = ticket # dito für den margin-%-Stop log.info(f"Auto-Close wiederhergestellt (Pos {ticket}): Stop " f"{('-'+format(self._emergency_loss,'.2f')) if self._emergency_loss else 'aus'} · TP " f"{('+'+format(self._takeprofit,'.2f')) if self._takeprofit else 'aus'}") # Trailing wiederherstellen: war es vor dem Neustart AN, wieder aktivieren # (Fix 2026-07-29: Trailing lebte nur im Speicher → nach jedem Neustart # aus, die Position lief nur mit Broker-SL, ohne Breakeven/Time-Stop; der # User musste es manuell einschalten). War es AUS (z. B. manuelles SL/TP # → deaktiviert), bleibt es aus — der persistierte Zustand entscheidet. if self._emergency_state_trail and not self.trail.enabled and self.data.symbol: self.trail.toggle(self.data.symbol) log.info(f"Trailing wiederhergestellt (Pos {ticket}) nach Neustart") else: # echt neue Position → armieren if self._emergency_margin_pct > 0: pass # wird unten margin-basiert (retry-sicher) gesetzt elif self._emergency_pct > 0 and self.data.balance: # % des Kontos (skaliert dynamisch, User-Vorgabe) self._emergency_loss = round(self._emergency_pct / 100.0 * float(self.data.balance), 2) elif self._emergency_remember and self._emergency_remember > 0: self._emergency_loss = float(self._emergency_remember) if self._takeprofit_remember and self._takeprofit_remember > 0: self._takeprofit = float(self._takeprofit_remember) log.info(f"Auto-Close armiert (Pos {ticket}): Stop " f"{('-'+format(self._emergency_loss,'.2f')) if self._emergency_loss else 'aus'} · TP " f"{('+'+format(self._takeprofit,'.2f')) if self._takeprofit else 'aus'}") self._save_emergency_state() # Gewinn-Close (Mindestgewinn für den S/R-Auto-Close) auf X% des EINSATZES # dieser Position setzen (Margin kommt frisch aus dem trader.refresh() # unmittelbar vor diesem Aufruf in _pos_loop). Läuft NICHT nur einmalig beim # Ticket-Wechsel, sondern jeden Tick erneut, bis es geklappt hat (Fix # 2026-07-23): war `order_calc_margin` genau im ersten Tick nach dem Öffnen # noch 0/nicht verfügbar, blieb die Schwelle sonst dauerhaft auf dem STALEN # Wert der vorherigen Position hängen (`_last_pos_ticket` war da schon # umgesetzt, der Ticket-Wechsel-Zweig feuert nur 1×) — konnte den Trade zu # früh (falscher, zu niedriger Alt-Wert) ODER dauerhaft mit falscher Schwelle # laufen lassen. if (self._sr_close_min_gain_pct > 0 and self._sr_close_min_gain_armed_ticket != ticket): margin = ps.get("margin") or 0.0 if margin > 0: self._sr_close_min_gain = round( self._sr_close_min_gain_pct / 100.0 * margin, 2) self._sr_close_min_gain_armed_ticket = ticket self._save_runtime_state() log.info(f"Gewinn-Close armiert (Pos {ticket}): " f"+{self._sr_close_min_gain:.2f} ({self._sr_close_min_gain_pct:.0f}% Margin {margin:.2f})") # Notfall-Stop als % der Margin — gleiches Retry-Muster (margin-Wert kann im # ersten Tick nach dem Öffnen noch 0 sein). Hat Vorrang vor Balance-%/Fixwert # (die im "echt neue Position"-Zweig oben bewusst übersprungen wurden, wenn # `_emergency_margin_pct>0`). if (self._emergency_margin_pct > 0 and self._emergency_margin_armed_ticket != ticket): margin = ps.get("margin") or 0.0 if margin > 0: self._emergency_loss = round( self._emergency_margin_pct / 100.0 * margin, 2) self._emergency_margin_armed_ticket = ticket self._save_emergency_state() log.info(f"Notfall-Stop armiert (Pos {ticket}): " f"-{self._emergency_loss:.2f} ({self._emergency_margin_pct:.0f}% Margin {margin:.2f})") # Trailing-Zustand per-Ticket neustart-fest halten: nur bei ÄNDERUNG schreiben # (kein Datei-I/O je Tick). Fängt auch die Selbst-Deaktivierung des Trailings # (manuelles SL/TP im Terminal, `_do_modify`) — sonst würde der Neustart ein vom # User abgeschaltetes Trailing wieder anwerfen. if bool(self.trail.enabled) != bool(self._emergency_state_trail): self._save_emergency_state() if self._in_startup_grace(): # Startup-Schonfrist: armieren ja, schließen nein return pnl = ps.get("pnl") if pnl is None: return tg = self.cfg["telegram"] tg_on = (tg.get("enabled", "false").lower() == "true" and tg.get("bot_token")) sym = self.data.symbol or "" def _do(reason, ok_icon, ok_word, level_txt): err = self.trader.close(reason=reason) if err: log.error(f"{ok_word} fehlgeschlagen: {err}") if tg_on: try: send_telegram(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 _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.""" if not self._auto_squeeze: return if self._circuit_halted(): # Tagesverlust-Stopp aktiv → heute keine Auto-Trades return try: 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)) # ── Dead-Hour-Guard (0–7 Uhr Berlin, 2026-07-17): nachts KOMPLETT inaktiv — # kein Entry UND kein Reverse (der Squeeze ist in dünner Nacht-Liquidität in # BEIDE Richtungen unzuverlässig; die 3 Nacht-Squeezes waren die ganze # Verlustquelle). Setzt keine Dedup-Marker → feuert nach 08:00, falls noch # aktiv. `auto_squeeze_skip_night=false` schaltet aus. if self._squeeze_skip_night and datetime.now(_BERLIN).hour in _SQUEEZE_NIGHT: return ps = self.trader.snapshot() ticket = ps.get("ticket") if ticket: pos_dir = "LONG" if ps.get("order_type") == 0 else "SHORT" if pos_dir == d: self._squeeze_last_sig = sig # gleiche Richtung → schon dabei return # ── Stop-&-Reverse (Default AUS, `auto_squeeze_reverse`): unbelegt + # dreht am Ausbruch-Extrem (real 2026-07-17 18:55). Ist er aus, bleibt # der Gegen-Trade unangetastet — Drehen ist dann MANUELLE Entscheidung. # `_squeeze_last_sig` NICHT setzen → schließt der Gegen-Trade (SL/manuell), # kann der flat-Entry auf denselben Ausbruch noch feuern. if not self._squeeze_reverse: return if self._in_startup_grace(): # Startup-Schonfrist: kein Reverse-Close nach Neustart return # Trade läuft GEGEN den Ausbruch → sofort schließen. Der nächste (flat) # Tick eröffnet dann in Ausbruchsrichtung über den Entry-Pfad. Dedup über # reverse_sig (kein Doppel-Close, während die Position noch schließt). if sig == self._squeeze_reverse_sig: return self._squeeze_reverse_sig = sig sym = self.data.symbol or "" pnl = ps.get("pnl") log.info(f"🔄 AUTO-SQUEEZE-REVERSE: {pos_dir}-Position gegen {d}-Ausbruch " f"@ {lvl} (P&L {pnl:+.2f} EUR) → sofort schließen, dann drehen") err = self.trader.close(reason="squeeze_reverse") tg = self.cfg["telegram"] tg_on = (tg.get("enabled", "false").lower() == "true" and tg.get("bot_token")) if err: log.error(f"Auto-Squeeze-Reverse-Close fehlgeschlagen: {err}") self._squeeze_reverse_sig = None # Fehlschlag → nächster Tick erneut elif tg_on: try: send_telegram(f"🔄 Auto-Squeeze-Reverse\n{sym} {pos_dir}-" f"Position (P&L {pnl:+.2f}) gegen {d}-Ausbruch @ {lvl} " f"geschlossen — dreht auf {d}.", tg["bot_token"], tg["chat_id"]) except Exception as e: log.warning(f"Squeeze-Reverse-Telegram: {e}") return # FLAT → Entry-Pfad if sig == self._squeeze_last_sig: return # diesen Ausbruch schon gehandelt # Cooldown: nicht in GLEICHER Richtung direkt nach einem S/R-Close wieder # eröffnen — der Close ging von „Level hält" aus, ein sofortiger Gleich- # richtungs-Entry wäre der widersprüchliche 1-s-Roundtrip (2026-07-17). # `_squeeze_last_sig` NICHT setzen → nach Ablauf feuert der Ausbruch, falls # noch aktiv. Gegenrichtung (Reverse) bleibt unberührt (läuft oben). if (d == self._sr_close_dir and time.time() - self._sr_close_ts < _SQUEEZE_SR_COOLDOWN_S): return # Wiedereinstiegs-Sperre nach JEDEM Close (Notfall/SL/Time-Stop/manuell): # nicht sofort in GLEICHER Richtung neu eröffnen — verhindert die # Open→Instant-Close-Kaskade bei zu engem Stop (2026-07-27). `_squeeze_last_sig` # NICHT setzen → nach Ablauf feuert der Ausbruch, falls noch aktiv. if (d == self._pos_close_dir and time.time() - self._pos_close_ts < _SQUEEZE_REENTRY_COOLDOWN_S): return 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 _check_auto_signal(self): """Autonomer Entry auf die EMPFEHLUNG (Wave-Signal), nur wenn FLAT. ⚠ GEMESSEN NICHT tragfähig (`backtest_auto_signal.py`: H1 ØR −0,05…−0,18 in BEIDEN Konfidenz-Stufen, H2 positiv = Regime-Kippen; Live-Dry-Run 2026-07-23 WR 29 %/PF 0,64). Auf ausdrücklichen User-Wunsch (2026-07-30) gebaut, Default AUS, mit den gemessen besten Parametern (Konf≥75 + Nacht-Sperre) und dem 15-Min-Adverse-Schutz. B4/B5: Live-Perf. gegen die Erwartung halten — driftet es weg, zurückbauen. Dedup: 1× je Signal-Episode (re-armt bei WARTEN/Flip).""" if not self._auto_signal: return if self._circuit_halted(): # Tagesverlust-Stopp → heute keine Auto-Trades return try: sig_snap = self.wave.signal() or {} d = sig_snap.get("signal") if d not in ("LONG", "SHORT"): self._signal_last_sig = None # WARTEN → Episode zu Ende, re-armen return conf = sig_snap.get("conf_pct") or 0 if conf < self._auto_signal_min_conf: return # zu schwach (Dedup NICHT setzen) # Nacht-Sperre (gemessene Kostenfalle 0–7 Uhr Berlin) — kein Dedup-Marker, # damit das Signal nach 08:00 noch feuern kann, falls es dann noch steht. if self._auto_signal_skip_night and datetime.now(_BERLIN).hour in _SQUEEZE_NIGHT: return ps = self.trader.snapshot() if ps.get("ticket"): # Position offen: läuft sie in Signalrichtung, gilt die Episode als # „schon dabei" (kein Nachkauf); Gegen-Position wird NICHT gedreht # (unbelegt — dieselbe Begründung wie beim Squeeze-Reverse). pos_dir = "LONG" if ps.get("order_type") == 0 else "SHORT" if pos_dir == d: self._signal_last_sig = d return if d == self._signal_last_sig: return # diese Episode schon gehandelt # Wiedereinstiegs-Sperren (wie beim Squeeze): nach S/R-Close bzw. nach # JEDEM Close nicht sofort gleichgerichtet neu eröffnen. now = time.time() if (d == self._sr_close_dir and now - self._sr_close_ts < _SQUEEZE_SR_COOLDOWN_S): return if (d == self._pos_close_dir and now - self._pos_close_ts < _SQUEEZE_REENTRY_COOLDOWN_S): return self._signal_last_sig = d if isinstance(self._last_rec, dict): self._last_rec["setup"] = f"AUTOSIG_{d}" log.info(f"🎯 AUTO-SIGNAL-ENTRY: {d} (Konfidenz {conf}% ≥ " f"{self._auto_signal_min_conf}%) — eröffne selbständig") err = (self.open_long(source="auto_signal") if d == "LONG" else self.open_short(source="auto_signal")) 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-Signal-Entry fehlgeschlagen: {err}") self._signal_last_sig = None # Fehlschlag → nächster Tick erneut if tg_on: try: send_telegram(f"⚠ Auto-Signal-Entry FEHLGESCHLAGEN\n" f"{sym} {d} (Konf {conf}%) — {err}", tg["bot_token"], tg["chat_id"]) except Exception as e: log.warning(f"Signal-Telegram (Fehler): {e}") return self._signal_entry_count += 1 if tg_on: try: send_telegram(f"🎯 Auto-Signal-Entry\n{sym} {d} selbständig " f"eröffnet (Empfehlung, Konfidenz {conf}%).", tg["bot_token"], tg["chat_id"]) except Exception as e: log.warning(f"Signal-Telegram: {e}") except Exception as e: log.error(f"_check_auto_signal: {e}", exc_info=True) def set_auto_signal(self, on: bool) -> bool: """Autonomen Signal-Entry an/aus (UI/Config). Rückgabe = neuer Zustand.""" self._auto_signal = bool(on) self._signal_last_sig = None log.info(f"Auto-Signal-Entry {'AN' if self._auto_signal else 'AUS'} " f"(Konf≥{self._auto_signal_min_conf}%" f"{', Nacht-Sperre' if self._auto_signal_skip_night else ''})") self._save_runtime_state() return self._auto_signal 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 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) except Exception as e: log.warning(f"log_recommendation: {e}") # 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()) 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) except Exception as e: log.debug(f"log_verdict_votes: {e}") # Kontext für Trade-Logging (set_open_context beim Öffnen) 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": None, } 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 # Hysterese: nur wechseln, wenn klar besser (kein Flattern) if best and best != cur and \ scores.get(best, 0) > scores.get(cur, 0) * 1.2: 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"]) if "auto_signal" in st: self._auto_signal = bool(st["auto_signal"]) 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)) 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}") 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_signal": self._auto_signal, "auto_sr_close": self._auto_sr_close, "sr_close_min_gain": self._sr_close_min_gain}, 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 '—'})" 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}") 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"

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

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

Oil Wochenreport (B4)

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

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

") sent = False tg = self.cfg["telegram"] if tg.get("enabled", "false").lower() == "true" and tg.get("bot_token"): try: send_telegram(f"📈 Wochenreport (B4) KW{kw}\n
{escape(text)}
", tg["bot_token"], tg["chat_id"]); sent = True except Exception as e: log.warning(f"weekly telegram: {e}") if self.cfg.has_section("graph"): report_to = (self.cfg["graph"].get("report_to") or "trading@hocks.eu, axel@hocks.eu") try: if send_graph_mail(self.cfg["graph"], subject, html, report_to): sent = True except Exception as e: log.warning(f"weekly mail: {e}") log.info(f"Wochenreport gesendet: {sent}") return sent # ── Telegram-Callbacks (UI-frei) ─────────────────────────────────────── def _on_trail_event(self, event: str, volume: float, price: float, profit: float): tg = self.cfg["telegram"] if not (tg.get("enabled", "false").lower() == "true" and tg.get("bot_token")): return try: if event == "partial": send_telegram( f"💰 Teil-Exit (Partial TP)\n" f"{self.data.symbol or ''} · {volume:.2f} L @ {price:.3f}\n" f"Gewinn-Distanz {profit:+.3f} — Rest läuft mit Trailing weiter", tg["bot_token"], tg["chat_id"]) elif event == "adverse": send_telegram( f"🛑 Früh-Ausstieg\n" f"{self.data.symbol or ''} · {volume:.2f} L @ {price:.3f}\n" f"Trade lief {abs(profit):.3f} gegen den Einstieg — geschlossen", tg["bot_token"], tg["chat_id"]) except Exception as e: log.warning(f"Trail-Event-Telegram: {e}") def _agent_telegram(self): snap = self.agent.snapshot() adv = snap.get("advisory") if not adv: return tg = self.cfg["telegram"] if tg.get("enabled", "false").lower() == "true" and tg.get("bot_token"): try: risks = "\n".join(f"• {r}" for r in adv.get("risks", [])) send_telegram( f"🧠 KI-Copilot: {adv['bias']} ({adv['confidence']}%)\n" f"{adv['headline']}\n\n{adv['reasoning']}" + (f"\n\nRisiken\n{risks}" if risks else ""), tg["bot_token"], tg["chat_id"]) except Exception as e: log.warning(f"Agent-Telegram: {e}") # ══════════════════════════════════════════════════════════════════════ # TRADE-AKTIONEN (für die Trade-Endpoints in server.py — Schritt 3) # ══════════════════════════════════════════════════════════════════════ def open_long(self, source: str = "manuell") -> str | None: return self._open(self.trader.open_long, "LONG", source) def open_short(self, source: str = "manuell") -> str | None: return self._open(self.trader.open_short, "SHORT", source) def _open(self, fn, want: str, source: str = "manuell") -> str | None: sym = self.data.symbol if not sym: return "Kein Symbol verbunden" # KEIN harter Block mehr: Handel ohne passende Empfehlung ist erlaubt — # das Frontend warnt + lässt bestätigen (User-Entscheidung). Hier nur # protokollieren, WER handelt und ob gegen die aktuelle Empfehlung. # ⚠ `source` korrekt attribuieren: autonome Auto-Squeeze-Orders laufen # durch denselben Pfad — vorher schrieb er fälschlich „vom User bestätigt". sig = (self.wave.signal() or {}).get("signal", "WARTEN") if source == "auto_squeeze": log.info(f"[ORDER] {want} — autonom vom Auto-Squeeze eröffnet " f"(Signal={sig})") elif source == "auto_signal": log.info(f"[ORDER] {want} — autonom auf die Empfehlung eröffnet " f"(Signal={sig})") elif sig != want: log.info(f"[ORDER] {want} ohne passende Empfehlung (Signal={sig}) " f"— vom User bestätigt, wird eröffnet") self.trader.set_open_context(**self._last_rec) err = fn(sym) if err: return err # Vom BOT eröffnete Trades markieren → nur für die greift die 15-Min-Regel # (manuelle Trades bleiben in der Diskretion des Users, User-Vorgabe). if source in ("auto_squeeze", "auto_signal"): try: self._bot_open_ticket = self.trader.snapshot().get("ticket") self._adverse15_done_ticket = None except Exception: pass # Trailing automatisch aktivieren (wie _after_trade im Widget) if not self.trail.enabled: self.trail.set_setup(self._last_rec.get("setup")) self.trail.toggle(sym) return None def close(self) -> str | None: return self.trader.close(reason="manual") def toggle_trail(self) -> bool: sym = self.data.symbol if sym: return self.trail.toggle(sym) return self.trail.enabled def _sr_levels(self, market: dict) -> dict: """Nächste Widerstandslinie ÜBER Preis (Short) und Unterstützung UNTER Preis (Long) — kombiniert dynamische S/R-Levels + die [zones]-Config.""" bid = market.get("bid") if not bid: return {"resistance": None, "support": None, "resistances": [], "supports": []} sr = market.get("sr") or {} above, below = [], [] for r in (sr.get("resistances") or []): p = r.get("price") if p: (above if p > bid else below).append(p) for s in (sr.get("supports") or []): p = s.get("price") if p: (above if p > bid else below).append(p) # Stehende Zonen aus der Config (beide Kanten als Linien) try: for z in parse_zones(self.cfg): for edge in (z["lo"], z["hi"]): (above if edge > bid else below).append(edge) except Exception: pass # 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): echte Kursstruktur = rohe M5-Pivots (`wave.pb_levels`, identisch zum P(break)-/Auto-Close-System), geclustert (~0,5×ATR). 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 {} lv = 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": []} # Cluster-Toleranz 0,5×ATR: nahe Pivot-Zwillinge (z. B. 80,50 + 80,59) # verschmelzen zu EINER Linie. def cluster(vals): out = [] for v in sorted(vals): if out and v - out[-1][-1] <= 0.5 * atr: out[-1].append(v) else: out.append([v]) return [round(sum(g) / len(g), 3) for g in out] # 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 = min((p for p in both if p > bid), default=None) s = max((p for p in both if p < bid), default=None) # 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). def hold(new, old, is_res): if old is None: return new m = 0.10 * atr broken = (bid > old + m) if is_res else (bid < old - m) if broken: return new # echter Durchbruch → nächste Linie (kann None sein) if (new is not None and abs(old - bid) > 1.5 * atr and abs(new - bid) < abs(old - bid) - 0.5 * atr): return new # alte Linie veraltet/weit weg → näheres Level return old # sonst: Linie HALTEN (kein Springen) r = hold(r, self._draw_last.get("r"), True) s = hold(s, self._draw_last.get("s"), 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 ''}"] for p in dl.get("res", []): lines.append(f"R;{p}") for p in dl.get("sup", []): lines.append(f"S;{p}") # 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). try: w = self.hl_walls.walls() if w: lines.append(f"LQ;B;{w['bid']};{w['bid_sz']:.0f}") lines.append(f"LQ;A;{w['ask']};{w['ask_sz']:.0f}") 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) -> 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): votes.append({"name": name, "vote": int(vote), "weight": weight, "detail": detail}) # 1) Welle — primär (validierter Edge) wsig = (wave_signal or {}).get("signal") wv = 1 if wsig == "LONG" else -1 if wsig == "SHORT" else 0 add("Welle", wv, 3.0, f"{wsig or '—'} · {(wave_signal or {}).get('conf_pct', 0)}%") # 2/3) M30 / H1 — die EMA-Signale, die die Welle tatsächlich nutzt 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, word(m30)) add("H1", h1, 1.5, 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 — OHNE Aussage (kein advisory/bias, z. B. Agent aus) → Gewicht 0 # (stimmt nicht mit ab, Chip bleibt sichtbar). Explizites "NEUTRAL" = echte # Neutral-Stimme mit Gewicht. 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 ab 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}" ew = 1.0 add("Elliott", ev, ew, edet) # 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 "")) # 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] agree = sum(1 for v in others if wv != 0 and v["vote"] == wv) 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), "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 _alignment_cached(self) -> dict | None: """Ausrichtungs-Split (mit/gegen/ohne Empfehlung, letzte 40 Trades) für den Order-Bestätigungsdialog — ehrliche eigene Zahlen statt generischem Hinweis (2026-07-24, Verhaltens-Hebel: Gegen-Signal-Trades sind die gemessene Kern-Leckage). 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(40) 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 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 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: import backtest_breakout_squeeze as BQ from core.wave_rec import _SQ_N, _SQ_MULT, _SQ_K BQ._N = _SQ_N; BQ._K = _SQ_K H, L, C, SP = m5["H"], m5["L"], m5["C"], m5["SP"] A = BQ._atr_series(H, L, C) sq, _ = BQ.scan(H, L, C, A, SP, 1.0, 0, len(C), _SQ_MULT) 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: last_trades = self.history.last_closed_trades(5) except Exception as e: log.warning(f"last_closed_trades: {e}") last_trades = [] market = self.data.snapshot() wave_signal = self.wave.signal() wave_snap = self.wave.snapshot() elliott = self.elliott.snapshot() tu = self.tu.snapshot() agent = self.agent.snapshot() position = self.trader.snapshot() # Tages-P/L: realisiert (heute geschlossene Trades) + offene Position try: day_realized = float((self.history.stats_overview("today") or {}) .get("total_pnl") or 0.0) except Exception as e: log.warning(f"day stats: {e}"); day_realized = 0.0 open_pnl = float(position.get("pnl") or 0.0) day_pl = {"realized": round(day_realized, 2), "open": round(open_pnl, 2), "total": round(day_realized + open_pnl, 2)} # Circuit Breaker — Status für die UI (Tagesverlust-Stopp) _bal = float(self.data.balance or 0.0) _cb_limit_eur = round(self._cb_limit_pct / 100.0 * _bal, 2) if self._cb_limit_pct > 0 else None circuit_breaker = { "enabled": self._cb_limit_pct > 0, "limit_pct": self._cb_limit_pct, "limit_eur": _cb_limit_eur, "day_total": day_pl["total"], "tripped": self._circuit_halted(), } sr_close_hint = self._sr_close_hint(market, position, wave_snap) stop_approach = self._stop_approach_hint(market, position, wave_snap) return { "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": self._verdict(market, wave_signal, wave_snap, tu, agent, elliott), "daily_levels": self.daily.snapshot(), "gaps": self.gaps.snapshot(), "structure": self.structure.snapshot(), "patterns": self.patterns.snapshot(), "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(), # 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")), "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, "auto_squeeze": self._auto_squeeze, "squeeze_entry_count": self._squeeze_entry_count, "auto_signal": self._auto_signal, "auto_signal_min_conf": self._auto_signal_min_conf, "signal_entry_count": self._signal_entry_count, "adverse15_atr": self._adverse15_atr, "adverse15_count": self._adverse15_count, "last_trades": last_trades, } 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 _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