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

⚙ Systemzustand

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

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

Oil Tagesreport

" + f"

Heute — Prognose (Web)

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

" + f"

Gestern ({yday})

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

" + f"{zust_html}" + f"

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

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

Oil Wochenreport (B4)

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

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

") + sent = False + tg = self.cfg["telegram"] + if tg.get("enabled", "false").lower() == "true" and tg.get("bot_token"): + try: + send_telegram(f"📈 Wochenreport (B4) KW{kw}\n
{escape(text)}
", + tg["bot_token"], tg["chat_id"]); sent = True + except Exception as e: + log.warning(f"weekly telegram: {e}") + if self.cfg.has_section("graph"): + report_to = (self.cfg["graph"].get("report_to") + or "trading@hocks.eu, axel@hocks.eu") + try: + if send_graph_mail(self.cfg["graph"], subject, html, report_to): + sent = True + except Exception as e: + log.warning(f"weekly mail: {e}") + log.info(f"Wochenreport gesendet: {sent}") + return sent + + # ── Telegram-Callbacks (UI-frei) ─────────────────────────────────────── + def _on_trail_event(self, event: str, volume: float, price: float, profit: float): + tg = self.cfg["telegram"] + if not (tg.get("enabled", "false").lower() == "true" and tg.get("bot_token")): + return + try: + if event == "partial": + send_telegram( + f"💰 Teil-Exit (Partial TP)\n" + f"{self.data.symbol or ''} · {volume:.2f} L @ {price:.3f}\n" + f"Gewinn-Distanz {profit:+.3f} — Rest läuft mit Trailing weiter", + tg["bot_token"], tg["chat_id"]) + elif event == "adverse": + send_telegram( + f"🛑 Früh-Ausstieg\n" + f"{self.data.symbol or ''} · {volume:.2f} L @ {price:.3f}\n" + f"Trade lief {abs(profit):.3f} gegen den Einstieg — geschlossen", + tg["bot_token"], tg["chat_id"]) + except Exception as e: + log.warning(f"Trail-Event-Telegram: {e}") + + def _agent_telegram(self): + snap = self.agent.snapshot() + adv = snap.get("advisory") + if not adv: + return + tg = self.cfg["telegram"] + if tg.get("enabled", "false").lower() == "true" and tg.get("bot_token"): + try: + risks = "\n".join(f"• {r}" for r in adv.get("risks", [])) + send_telegram( + f"🧠 KI-Copilot: {adv['bias']} ({adv['confidence']}%)\n" + f"{adv['headline']}\n\n{adv['reasoning']}" + + (f"\n\nRisiken\n{risks}" if risks else ""), + tg["bot_token"], tg["chat_id"]) + except Exception as e: + log.warning(f"Agent-Telegram: {e}") + + # ══════════════════════════════════════════════════════════════════════ + # TRADE-AKTIONEN (für die Trade-Endpoints in server.py — Schritt 3) + # ══════════════════════════════════════════════════════════════════════ + def open_long(self, source: str = "manuell") -> str | None: + return self._open(self.trader.open_long, "LONG", source) + + def open_short(self, source: str = "manuell") -> str | None: + return self._open(self.trader.open_short, "SHORT", source) + + def _open(self, fn, want: str, source: str = "manuell") -> str | None: + sym = self.data.symbol + if not sym: + return "Kein Symbol verbunden" + # KEIN harter Block mehr: Handel ohne passende Empfehlung ist erlaubt — + # das Frontend warnt + lässt bestätigen (User-Entscheidung). Hier nur + # protokollieren, WER handelt und ob gegen die aktuelle Empfehlung. + # ⚠ `source` korrekt attribuieren: autonome Auto-Squeeze-Orders laufen + # durch denselben Pfad — vorher schrieb er fälschlich „vom User bestätigt". + sig = (self.wave.signal() or {}).get("signal", "WARTEN") + if source == "auto_squeeze": + log.info(f"[ORDER] {want} — autonom vom Auto-Squeeze eröffnet " + f"(Signal={sig})") + elif source == "auto_signal": + log.info(f"[ORDER] {want} — autonom auf die Empfehlung eröffnet " + f"(Signal={sig})") + elif sig != want: + log.info(f"[ORDER] {want} ohne passende Empfehlung (Signal={sig}) " + f"— vom User bestätigt, wird eröffnet") + self.trader.set_open_context(**self._last_rec) + # ⚠ SL-ATR-Zeitebene quellen-abhaengig (2026-08-05, User-Entscheidung nach + # `backtest_sl_basis.py`): SQUEEZE bekommt **M5**, alles andere bleibt bei + # M15. Begruendung: auf Squeeze-Entries ist der Ertrag zwischen beiden + # Basen ein Wash (innerhalb eines Standardfehlers), der Worst-Case sinkt + # aber von −8,83 auf −2,44xATR_M5 = Faktor 3. Auf der WELLEN-Population + # ist der weite M15-SL dagegen in BEIDEN Halbjahren besser — deshalb + # bewusst KEINE globale Umstellung. Strukturell begruendet: Breakout- und + # Pullback-Einstiege haben verschiedene Nachlauf-Dynamik. + _atr_tf = mt5.TIMEFRAME_M5 if source == "auto_squeeze" else None + err = fn(sym, _atr_tf) + if err: + return err + # Vom BOT eröffnete Trades markieren → nur für die greift die 15-Min-Regel + # (manuelle Trades bleiben in der Diskretion des Users, User-Vorgabe). + if source in ("auto_squeeze", "auto_signal"): + try: + self._bot_open_ticket = self.trader.snapshot().get("ticket") + self._bot_open_source = source + self._adverse15_done_ticket = None + except Exception: + pass + # Trailing automatisch aktivieren (wie _after_trade im Widget) + if not self.trail.enabled: + self.trail.set_setup(self._last_rec.get("setup")) + self.trail.toggle(sym) + return None + + def close(self) -> str | None: + return self.trader.close(reason="manual") + + def toggle_trail(self) -> bool: + sym = self.data.symbol + if sym: + return self.trail.toggle(sym) + return self.trail.enabled + + def _sr_levels(self, market: dict) -> dict: + """Nächste Widerstandslinie ÜBER Preis (Short) und Unterstützung UNTER + Preis (Long) — kombiniert dynamische S/R-Levels + die [zones]-Config.""" + bid = market.get("bid") + if not bid: + return {"resistance": None, "support": None, + "resistances": [], "supports": []} + sr = market.get("sr") or {} + above, below = [], [] + for r in (sr.get("resistances") or []): + p = r.get("price") + if p: (above if p > bid else below).append(p) + for s in (sr.get("supports") or []): + p = s.get("price") + if p: (above if p > bid else below).append(p) + # ⚠ [zones]-CONFIG ENTFERNT (2026-07-31, Modul-Audit). Vorher flossen hier + # beide Kanten jeder `[zones]`-Zone als S/R-Linien ein — 16 Kanten aus einem + # ~10 $ TIEFEREN Kursregime (Zonen bei 72,90–77,00 bei einem Kurs von 85,3). + # Aus dem CHART wurden sie schon am 2026-07-14 entfernt, WEIL sie stale sind; + # in die Wellen-Konfidenz liefen sie aber weiter. + # Realer Fall beim Audit (Kurs 85,342): `market.sr` hatte GAR KEINE + # Widerstände (M15-Cluster sind in frischen Trends oft leer) → der nächste + # „Widerstand" kam aus der stale `fvg`-Zone bei **88,00** (2,66 $ weg), + # während der echte bei **85,315** lag (0,03 $ weg). Der Konfidenz-Abzug + # „dicht unter Widerstand" (−12) greift nur innerhalb 0,5×ATR und feuerte + # deshalb NIE, obwohl er hätte feuern müssen. + # Entfernen ist KEINE Strategieänderung, sondern das Streichen von Daten aus + # einem anderen Preisregime: fehlt ein Level, gibt es weder Bonus noch Abzug — + # das ist ehrlicher als ein falsches Level. + # ⚠ OFFEN (messpflichtig, NICHT hier gemacht): die Wellen-Konfidenz stattdessen + # aus `_draw_levels` (M30, dieselbe Quelle wie Chart + S/R-Close) speisen. Das + # würde den −12-Abzug tatsächlich auslösen → Konfidenz sinkt → das 55%-Gate + # verschiebt sich. Strategieänderung, gehört gemessen. + # Tages-Level aus der Morgen-Recherche (Kontext) + try: + for lv in self.daily.zone_lines(): + (above if lv > bid else below).append(lv) + except Exception: + pass + # Offene Kurslücken (D1-Gaps) als Fill-Magnete (Kontext/Konfidenz) + try: + for lv in self.gaps.zone_lines(): + (above if lv > bid else below).append(lv) + except Exception: + pass + above = sorted(set(round(p, 3) for p in above)) + below = sorted(set(round(p, 3) for p in below), reverse=True) + return { + "resistance": above[0] if above else None, # nächster Widerstand (Short) + "support": below[0] if below else None, # nächste Unterstützung (Long) + "resistances": above[:3], + "supports": below[:3], + } + + def _draw_levels(self) -> dict: + """S/R-Linien fürs ZEICHNEN (PWA-Charts + MQL5-CSV) UND für den + P(break)-Auto-Close: echte Kursstruktur = rohe **M30**-Pivots + (`wave.pb_levels30`), geclustert (~0,5×ATR). + + ⚠ **TF-Umstellung M5→M30 am 2026-07-30** (User: „die S/R-Linien liegen oft + sehr nahe beieinander"), gemessen `backtest_level_tf.py` über 80k M5-Bars in + 2 Halbjahren — M30 gewinnt in BEIDEN: Level-Abstand **1,95 statt 0,61×ATR** + (nur 28 % statt 71 % näher als 1×ATR), AUC out-of-sample 0,721 vs 0,710, + R-Ertrag der Close-Regel **rund doppelt** (+12.424/+13.047 vs +6.698/+7.749). + Das P(break)-Modell (`_PB_MU/_SD/_W`) wurde dafür auf M30-Leveln NACHTRAINIERT. + Fallback auf die alten M5-Level (`pb_levels`), falls M30 noch nicht da ist. + ⚠ NICHT betroffen: das **Entry-Raum-Gate** nutzt weiter `pb_levels` (M5) — + `entry_room_atr=0.6` ist darauf kalibriert. + + Zeichnet **nur die NÄCHSTE je Seite** (2 Linien total, + deckungsgleich mit den Dashboard-Werten `sr-res`/`sr-sup`) — sonst tauchten + beim Kurslauf die weiter entfernten Linien laufend auf/ab (User-Feedback + 2026-07-14). Bugfix davor: Linien kamen aus `_sr_levels` (stale [zones]-Config + + Tageslevel + Gaps) — Uralt-Level statt aktueller Struktur. Ergebnis wird + ~1 s gecacht (Konsistenz über alle Anzeigen + weniger Lock-Last).""" + now = time.time() + dc = self._draw_cache + if dc and (now - dc[0]) < 1.0: + return dc[1] + try: + ws = self.wave.snapshot() or {} + # M30-Level (gemessen besser); M5 nur als Fallback beim Kaltstart, bis + # der erste M30-Turn-Fetch durch ist. + lv = ws.get("pb_levels30") or ws.get("pb_levels") or {} + atr = (ws.get("pb_feats") or {}).get("atr") or 0.2 + bid = (self.data.snapshot() or {}).get("bid") + if not bid: + return {"res": [], "sup": []} + # ⚠ Clusterung/Auswahl/Hysterese liegen seit 2026-08-07 in + # `core/sr_levels.py` — die Backtests bauten sie vorher NACH und + # wichen dabei ab (gemischter Rohpool statt ph/pl getrennt; 4,5 % + # der Zeitpunkte hatten einen anderen Widerstand). Verhalten hier + # unverändert, bitgenau geprüft in `tests/test_sr_levels.py`. + from core.sr_levels import cluster as _cluster_lv + from core.sr_levels import halten as _halten_lv + from core.sr_levels import naechste as _naechste_lv + + def cluster(vals): + return _cluster_lv(vals, atr) + # Fix 2026-07-23 (User-Frage „Widerstand/Support-Retest von der falschen + # Seite"): Widerstand/Support werden NACH AKTUELLER KURSPOSITION gewählt + # (über/unter bid), NICHT mehr strikt nach Entstehungstyp (Swing-Hoch `ph` + # vs. Swing-Tief `pl`). Vorher blieb ein gebrochenes Level für IMMER im + # falschen Topf hängen: ein Swing-Tief, das der Kurs unterschritt und von + # UNTEN erneut ansteuerte, tauchte nie als Widerstand auf (kam nur aus `ph`); + # ein Swing-Hoch, das der Kurs überschritt und von OBEN erneut ansteuerte + # (Retest), tauchte nie als Support auf (kam nur aus `pl`) — 2 von 4 Retest- + # Varianten hatten dadurch nie eine Durchbruchwahrscheinlichkeit. + # ⚠ ph/pl bleiben GETRENNT geclustert (nicht vorher zusammenlegen!) — ein + # erster Versuch clusterte den kombinierten Rohpool direkt, was in dichten + # Zonen zu Ketten-Mega-Clustern führte (doppelte Punktdichte → die einfache + # Nachbar-Kette lief über >1×ATR durch). Erst NACH dem separaten Clustern + # (unveränderte Granularität wie vorher) werden die beiden fertigen + # Linien-Listen zusammengeführt und rein nach Position zu bid gewählt. + ph_c = cluster(lv.get("ph") or []) + pl_c = cluster(lv.get("pl") or []) + both = ph_c + pl_c + # NUR die nächste je Seite (2 Linien total = wie im Dashboard) + r, s = _naechste_lv(both, bid) + # Hysterese „klebrig" (Fix 2026-07-21): die gezeichnete Linie bleibt + # stehen, solange der Kurs sie nicht WIRKLICH durchbricht (bid > R+0,1×ATR + # bzw. bid < S−0,1×ATR). Ein neuer, näherer Pivot löst KEINEN Wechsel aus + # — genau das war die Zitter-Quelle (auf M5 bilden sich laufend neue Pivots). + # Ausnahme: ist die alte Linie weit weg (>1,5×ATR) UND ein deutlich näheres + # Level existiert, wird sie übernommen (sonst bleibt sie irrelevant hängen). + r = _halten_lv(r, self._draw_last.get("r"), bid, atr, True) + s = _halten_lv(s, self._draw_last.get("s"), bid, atr, False) + self._draw_last = {"r": r, "s": s} + out = {"res": [r] if r is not None else [], + "sup": [s] if s is not None else []} + self._draw_cache = (now, out) + return out + except Exception: + return {"res": [], "sup": []} + + def _dash_sr(self, market: dict) -> dict: + """S/R fürs Dashboard — GLEICHE Pivot-Quelle wie die Chart-Linien + (`_draw_levels`), damit Position-Kachel und Chart übereinstimmen.""" + dl = self._draw_levels() + res = dl.get("res") or []; sup = dl.get("sup") or [] + return {"resistance": (res[0] if res else None), + "support": (sup[0] if sup else None), + "resistances": res, "supports": sup} + + def _write_levels_file(self): + """Schreibt S/R als CSV nach `\\MQL5\\Files\\sr_levels.csv`, + damit ein MQL5-Indikator (mql5/SR_Levels.mq5) sie im Terminal zeichnet — das + Python-MT5-Paket kann keine Chart-Objekte erzeugen. Atomarer Write (.tmp→replace), + damit der Indikator nie eine halbe Datei liest.""" + import os + if self._mql5_files_dir is None: + try: + with mt5_lock(timeout=2) as got: + if got: + ti = mt5.terminal_info() + self._mql5_files_dir = os.path.join(ti.data_path, "MQL5", "Files") + except Exception: + self._mql5_files_dir = "" # einmal fehlgeschlagen → nicht endlos retryen + if not self._mql5_files_dir: + return + try: + dl = self._draw_levels() # echte Pivot-Struktur, nicht [zones]-Config + lines = [f"SYM;{self.data.symbol or ''}"] + # P(Durchbruch) je Linie mitgeben (User-Wunsch 2026-07-30) → der Indikator + # schreibt sie ins Label: „Widerstand 84.536 · Durchbruch 34%". + # Richtung: Widerstand = Bruch nach OBEN (+1), Support = nach UNTEN (−1); + # Merkmale identisch zu `_sr_close_hint`/`_stop_approach_hint` (M5-Momentum, + # `wt` = EMA-Richtung stimmt mit der Bruchrichtung, `dist` = Abstand/ATR). + # ⚠ Das Modell ist auf GENAU diese M30-Level trainiert (Nachtraining + # 2026-07-30) — Kalibrierung out-of-sample geprüft (AUC 0,72). + def _pb_for(level, dd): + try: + pf = (self.wave.snapshot() or {}).get("pb_feats") or {} + atr = pf.get("atr"); m6 = pf.get("mom6"); m3 = pf.get("mom3") + ed = pf.get("ema_diff") + b = (self.data.snapshot() or {}).get("bid") + if not (atr and b) or None in (m6, m3, ed): + return None + wt = 1.0 if ed * dd > 0 else 0.0 + return round(_p_break(m6 * dd, m3 * dd, wt, abs(level - b) / atr) * 100) + except Exception: + return None + for p in dl.get("res", []): + _pb = _pb_for(p, 1) + lines.append(f"R;{p}" + (f";{_pb}" if _pb is not None else "")) + for p in dl.get("sup", []): + _pb = _pb_for(p, -1) + lines.append(f"S;{p}" + (f";{_pb}" if _pb is not None else "")) + # Regressionskanal (M30) als Trendlinien-Anker → MQL5 zieht OBJ_TREND + # (Format CH;;t1;p1;t2;p2 mit Broker-Zeiten). + try: + cl = (self.structure.snapshot() or {}).get("channel_line") + if cl and cl.get("t1") and cl.get("t2"): + for kind, key in (("U", "upper"), ("M", "mid"), ("L", "lower")): + pts = cl.get(key) + if pts and len(pts) == 2: + lines.append(f"CH;{kind};{cl['t1']};{pts[0]};{cl['t2']};{pts[1]}") + except Exception: + pass + # Chartmuster-Linien (PT;…) 2026-07-30 auf User-Wunsch ENTFERNT — die + # Kachel/PWA-Anzeige bleibt, im MT5-Chart wurde es zu voll. Der Indikator + # (v1.12) hat den PT-Handler ebenfalls nicht mehr. + # ECHTE Liquiditäts-Wände von Hyperliquid (größtes L2-Level je Seite, + # basis-korrigiert auf CFD-Niveau) — Format LQ;;preis;size. + # Anders als R/S NICHT aus Pivots geschätzt, sondern tatsächlich ruhende + # Orders. REINE ANZEIGE (Übertragbarkeit auf den CFD wird gerade gemessen). + # Format: LQ;;preis;size;;<1|0> (Pfeil-Richtung = Wirkrichtung + # der Wand nach der Lesart „sie hält"; letztes Feld 1 = die GRÖSSERE der + # beiden Wände). Zusätzlich LT;t1;p1;t2;p2; = Liquiditäts-Trendlinie + # (Regression über den volumengewichteten Wand-Schwerpunkt) — Zeiten + # werden hier in BROKER-Zeit umgerechnet, weil MT5-Charts die brauchen. + try: + w = self.hl_walls.walls() + if w: + dom = w.get("dominant") + ab = "U" if (w.get("arrow_bid") == "up") else "D" + aa = "D" if (w.get("arrow_ask") == "down") else "U" + lines.append(f"LQ;B;{w['bid']};{w['bid_sz']:.0f};{ab};{1 if dom=='B' else 0}") + lines.append(f"LQ;A;{w['ask']};{w['ask_sz']:.0f};{aa};{1 if dom=='A' else 0}") + tr = w.get("trend") + if tr and tr.get("t1") and tr.get("t2"): + boff = self.trader._broker_offset_s() + lines.append(f"LT;{int(tr['t1'])+boff};{tr['p1']};" + f"{int(tr['t2'])+boff};{tr['p2']};" + f"{'U' if tr.get('dir')=='up' else 'D'}") + except Exception: + pass + # Liquiditätszonen (Equal Highs/Lows, M30): EQ;;lo;hi; + # → goldenes Band ab dem ersten Pivot, nach rechts verlängert. + # ⚠ REINE ANZEIGE (als Signal gemessen verworfen, s. backtest_liquidity_sweep). + try: + zs = (self.wave.snapshot() or {}).get("liq_zones") or {} + boff_z = self.trader._broker_offset_s() + for key, tag in (("eqh", "H"), ("eql", "L")): + z = zs.get(key) + if z and z.get("lo") and z.get("hi"): + # `t` ist ROHE Broker-Zeit aus copy_rates → NICHT nochmal + # umrechnen (anders als die HL-Trendlinie, die lokal tickt). + lines.append(f"EQ;{tag};{z['lo']};{z['hi']};{int(z['t'])}") + except Exception: + pass + # KONSOLIDIERUNGS-BOX (M5, dieselbe wie der validierte Squeeze): + # CO;lo;hi;<0|1> — letztes Feld 1 = komprimiert (≤2,5×ATR, Ausbruch steht + # bevor), 0 = nur die aktuelle 10-Bar-Spanne. ⚠ REINE ANZEIGE: die + # Konsolidierungs-SETUPS (False Breakout, Distribution, Accumulation, + # Retest) sind gemessen ALLE verworfen (`backtest_consolidation.py`); + # validiert ist nur der Squeeze-AUSBRUCH. Die Box zeigt also, WO gerade + # gestaut wird — nicht, wohin es geht. + try: + _sq = (self.wave.snapshot() or {}).get("squeeze") or {} + _lo, _hi = _sq.get("box_lo"), _sq.get("box_hi") + if _lo and _hi and _hi > _lo: + _tight = 1 if _sq.get("state") in ("armed", "active") else 0 + lines.append(f"CO;{_lo};{_hi};{_tight}") + except Exception: + pass + # Aktueller Kurs für die fette Anzeige oben rechts (User-Wunsch): PX;bid + try: + _bid = (self.data.snapshot() or {}).get("bid") + if _bid: + lines.append(f"PX;{_bid}") + except Exception: + pass + _atr5 = 0.0 + try: + _atr5 = float(((self.wave.snapshot() or {}).get("pb_feats") or {}) + .get("atr") or 0.0) + except Exception: + pass + # ── WAHRSCHEINLICHKEITS-KEGEL (CN) — im Chart per Default AUS + # (User 2026-07-31: „anstatt des Trichters hätte ich gerne einen Pfeil"). + # Die Dashboard-Kachel „Erwartete Spanne" bleibt; `[trading] export_cone=true` + # holt den Kegel in den Chart zurück. + if self._export_cone: + try: + _cn = _cone_build(_bid, _atr5, bands=(80,)) + for lv in (_cn or {}).get("levels", []): + lines.append(f"CN;{lv['minutes']};{lv['lo']};{lv['hi']}") + except Exception: + pass + # ── PFEILE: AR;;;; (art L | S | K) ────────────── + # ⚠ EINORDNUNG — die freie Frage „wohin läuft der Kurs?" ist im Projekt + # GEMESSEN ein Münzwurf (`analyze_reversal.py`: AUC 0,499–0,509 out-of- + # sample, in-sample nur 0,52). Deshalb zeigt KEINER dieser Pfeile eine + # freie Kursprognose, sondern jeweils eine Aussage mit eigener Beleglage: + # L = am nächsten LEVEL, aus dem kalibrierten P(break) (AUC 0,65 oos, + # live kalibriert 27 % vorhergesagt vs 28 % real) — BEDINGTE Aussage + # an einem realen Objekt, die einzige belegte Richtungsangabe. + # S = SQUEEZE-Ausbruch — das einzige 2-Stichproben-validierte + # Richtungssignal (ØR +0,14…+0,23). Erscheint nur bei `active`. + # K = KONSENS der Verdict-Module — ⚠ NICHT kalibriert + # (`analyze_verdict_calibration.py`: in H2 sogar invertiert), heißt + # deshalb „Konsens" und nicht „Prognose". + try: + _dl = dl + _r = (_dl.get("res") or [None])[0] + _s = (_dl.get("sup") or [None])[0] + if _bid and _atr5 > 0: + # (L) nächstes Level = das nähere von beiden + cand = [(abs(x - _bid), x, 1 if x > _bid else -1) + for x in (_r, _s) if x is not None] + if cand: + _d0, _lv0, _dd = min(cand) + _p = _pb_for(_lv0, _dd) # in Prozent (0–100) oder None + # ⚠ TOTBAND 45–55 %: dort ist P(break) ein Münzwurf. Einen + # Pfeil zu zeichnen würde daraus eine Richtungsaussage machen — + # genau der Fehler, den das Projekt heute mehrfach vermieden hat + # (freie Richtung = AUC 0,5). Stattdessen ein FLACHER Pfeil mit + # „unentschieden". + if _p is not None: + if 45 <= _p <= 55: + to = _bid # waagerecht = keine Aussage + txt = f"{_p}% — unentschieden" + elif _p > 55: + to = _lv0 + _dd * 0.5 * _atr5 + txt = f"{_p}% Durchbruch" + else: + to = _bid - _dd * 1.0 * _atr5 + txt = f"{100 - _p}% Abprall" + lines.append(f"AR;L;{round(_bid, 3)};{round(to, 3)};{txt}") + # (S) Squeeze — nur wenn aktiv + _sq = (self.wave.snapshot() or {}).get("squeeze") or {} + if _sq.get("state") == "active" and _sq.get("dir") and _sq.get("level"): + _sd = 1 if _sq["dir"] == "LONG" else -1 + lines.append(f"AR;S;{round(float(_sq['level']), 3)};" + f"{round(float(_sq['level']) + _sd * 2.0 * _atr5, 3)};" + f"Squeeze {_sq['dir']}") + # (K) Konsens — Länge proportional zum Bias-Betrag (max 2×ATR) + # ⚠ NUR berechnen, wenn der Pfeil auch gezeichnet wird + # (Review 2026-08-02): Der Indikator verwirft `AR;K` seit v1.33 + # per Default (`InpShowConsensus=false` → `continue`), der Bot + # rechnete dafür aber im ~5-s-Export-Takt ein KOMPLETTES zweites + # `_verdict()` — Arbeit für eine Linie, die niemand zeichnet, plus + # das Risiko, dass dieses zweite Verdict vom Verdict im Snapshot + # abweicht. Der Schalter spiegelt jetzt den Indikator-Default; + # wer `InpShowConsensus=true` setzt, setzt hier `export_consensus + # _arrow=true` dazu. + if self._export_consensus_arrow: + _vd = self._verdict(self.data.snapshot(), self.wave.signal(), + self.wave.snapshot(), None, + self.agent.snapshot(), self.elliott.snapshot(), + self.patterns.snapshot()) + _b = float(_vd.get("bias") or 0.0) + if abs(_b) >= 0.2: + lines.append(f"AR;K;{round(_bid, 3)};" + f"{round(_bid + (1 if _b > 0 else -1) * min(abs(_b), 1.0) * 2.0 * _atr5, 3)};" + f"Konsens {_b:+.2f}") + except Exception: + pass + # ── HYPERLIQUID-ERSATZKURS bei geschlossenem Markt (User 2026-08-01): + # HL;;;;; — der Indikator + # zeigt ihn UNTER dem (eingefrorenen) MT5-Kurs, sobald kein Tick mehr + # kommt. HL (`xyz:CL`) handelt 24/7, Pepperstone nicht; ohne das sieht man + # am Wochenende einen Freitags-Kurs und merkt es nicht. + try: + _w = self.hl_walls.walls() + if _w and _w.get("mid_mt5") is not None: + _tr = (_w.get("trend") or {}).get("dir") + lines.append(f"HL;{_w['mid_mt5']:.3f};{float(_w.get('age_s') or 0):.0f};" + f"{_w.get('bid')};{_w.get('ask')};" + f"{'U' if _tr == 'up' else 'D' if _tr == 'down' else '-'}") + except Exception: + pass + # G/V der offenen Position, direkt unter dem Kurs (User-Wunsch 2026-07-30): + # PL;wert;waehrung. **NETTO** wie im Dashboard — bei Gewinn abzüglich der + # WHT-Quellensteuer, die der Broker einbehält (`[trading] wht_pct`); bei + # Verlust unverändert (da fällt keine Steuer an). Ohne Position keine Zeile + # → der Indikator löscht das Label. + try: + _ps = self.trader.snapshot() + if _ps.get("ticket") and _ps.get("pnl") is not None: + _p = float(_ps["pnl"]) + if _p > 0 and self._wht_pct > 0: + _p *= (1.0 - self._wht_pct / 100.0) + _cur = (self.data.snapshot() or {}).get("currency") or "" + lines.append(f"PL;{_p:.2f};{_cur}") + except Exception: + pass + # ── ZONEN statt nackter Linien (User-Wunsch 2026-08-17, nach einem + # TradingView-Bild): RZ;lo;hi = Widerstands-Zone · SZ;lo;hi = + # Support-Zone. Der Indikator zeichnet sie als farbiges Band. + # ⚠⚠ DIE BREITE IST ECHT, NICHT ERFUNDEN: sie ist die Spanne der + # M30-Pivots, die den Cluster gebildet haben (`sr_levels.zonen`). + # Das Vorbild-Bild leitet seine Zone aus **Fibonacci** ab (0,618/ + # 0,658) — Fib ist hier gemessen und VERWORFEN: die Level halten + # nicht besser als eine Zufallslinie gleicher Distanz (04.08., + # Haltequote 54,3/54,5 % gegen 55,7/53,8 % Zufall). Eine + # Fib-Zone waere also eine huebsche Linie ohne Beleg. + # ⚠ Ein Cluster aus EINEM Pivot hat Breite 0 — dann bekommt er eine + # Mindestbreite von 0,10xATR, damit das Band sichtbar bleibt. + # ⚠ Die untere Zone heisst SUPPORT, nicht „Long Liquidation": echte + # Liquidations-Daten hat das Projekt nicht. Bricht sie, liegen dort + # die Long-Stops — das ist dieselbe Stelle, aber ehrlich benannt. + try: + _zl = self._draw_levels() or {} + _r0 = (_zl.get("res") or [None])[0] + _s0 = (_zl.get("sup") or [None])[0] + _ws3 = self.wave.snapshot() or {} + _a3 = (_ws3.get("pb_feats") or {}).get("atr") or 0.2 + _lv3 = _ws3.get("pb_levels30") or _ws3.get("pb_levels") or {} + from core.sr_levels import zonen as _zonen_lv + _mind = 0.10 * _a3 + for _px, _seite, _tag in ((_r0, "ph", "RZ"), (_s0, "pl", "SZ")): + if _px is None: + continue + _tr = None + for _m, _lo2, _hi2 in _zonen_lv(_lv3.get(_seite) or [], _a3): + if abs(_m - _px) < 0.0015: + _tr = (_lo2, _hi2); break + if _tr is None: + _tr = (_px, _px) + _lo2, _hi2 = _tr + if _hi2 - _lo2 < _mind: # Einzel-Pivot sichtbar machen + _mit = (_lo2 + _hi2) / 2.0 + _lo2, _hi2 = _mit - _mind / 2, _mit + _mind / 2 + lines.append(f"{_tag};{round(_lo2, 3)};{round(_hi2, 3)}") + except Exception: + pass + # ── MELDUNGEN oben links (MS;) — User-Wunsch 2026-08-17 ──── + # Dieselben Zeilen wie die Dashboard-Karte „Meldungen" (`#card-pos`). + # ⚠⚠ NUR ZUSAMMENSETZEN, NICHT NEU RECHNEN. Der Konsens-Pfeil hat im + # ~5-s-Export-Takt ein komplettes zweites `_verdict()` gezogen — + # doppelte Last UND die Gefahr, dass Chart und Dashboard + # auseinanderlaufen. Alles hier kommt aus bereits berechneten + # Snapshot-Teilen bzw. den `_last_*`-Feldern. + # ⚠ Abschaltbar: `[trading] export_meldungen=false`. + if self._export_meldungen: + try: + _ms: list[str] = [] + _ws2 = self.wave.snapshot() or {} + _sq2 = _ws2.get("squeeze") or {} + if _sq2.get("state") == "active" and _sq2.get("dir"): + _ms.append(f"🚀 Squeeze-Ausbruch {_sq2['dir']} " + f"@ {_sq2.get('level')}") + elif _sq2.get("state") == "armed": + _ms.append("🎯 Kompression — Ausbruch steht bevor") + _bo = _ws2.get("bounce") or {} + if _bo.get("state") in ("expected", "active") and _bo.get("dir"): + _pf = "aktiv" if _bo["state"] == "active" else "erwartet" + _ms.append(f"⚠ Ueberdehnung ({_pf}) — Reaktion " + f"{'nach oben' if _bo['dir'] == 'LONG' else 'nach unten'}" + f" moeglich [{_bo.get('tf') or ''}]") + _sh = self._last_sr_close_hint + if _sh and _sh.get("level"): + _pb2 = _sh.get("p_break") + _txt = ("unentschieden" if _pb2 is None or 45 <= _pb2 <= 55 + else f"{_pb2}% Bruch" if _pb2 > 55 + else f"{100 - _pb2}% Abprall") + _ms.append(f"S/R-Ziel {_sh['level']} · {_txt}" + + (" · Bot wuerde schliessen" if _sh.get("close") else "")) + _st2 = self._last_stop_approach + if _st2 and _st2.get("level"): + _pb3 = _st2.get("p_break") + _ms.append(f"Stop-Seite {_st2['level']} · " + + ("bricht wahrscheinlich" if (_pb3 or 0) >= 50 + else "haelt wahrscheinlich")) + # ⚠ `self.calendar` ist das Objekt (kein `_last_*`-Feld) — + # `blackout()` ist ein reiner Speicher-Zugriff, kein Netz. + _bl = self.calendar.blackout() + if _bl: + _ms.append(f"News-Blackout: {_bl.get('title') or ''}") + # ⚠⚠ ASCII-SICHER. Der Indikator liest die CSV NICHT als + # UTF-8 — Emoji und Sonderzeichen kommen als Leerzeichen an + # (real gesehen: „🎯 Kompression — …" wurde „ Kompression + # …"). Genau deshalb steht im Indikator auch + # „Unterstuetzung" statt „Unterstützung". Ersetzen statt + # hoffen. + _ers = {"—": "-", "–": "-", "·": "-", "→": "->", "≥": ">=", + "≤": "<=", "ü": "ue", "ö": "oe", "ä": "ae", + "Ü": "Ue", "Ö": "Oe", "Ä": "Ae", "ß": "ss"} + for _z in _ms[:5]: # Deckel: der Chart soll lesbar bleiben + _t = str(_z) + for _a, _b in _ers.items(): + _t = _t.replace(_a, _b) + _t = "".join(c for c in _t if 32 <= ord(c) < 127) + _t = " ".join(_t.split()) # Mehrfach-Leerzeichen weg + if _t: + lines.append("MS;" + _t.replace(";", ",")[:70]) + except Exception: + pass + # Trade-Marker: TR;; → der Indikator umrandet die Kerze, + # in der eröffnet (hellgrün) bzw. geschlossen (dunkelgrün) wurde + # (User-Vorgabe 2026-07-30). Offene Position + die letzten Trades des Tages. + # ⚠ Per Default AUS (User-Wunsch 2026-07-30: „entferne die Umrandungen + # für Trade open und close" — kurz nach dem Einbau wieder verworfen). + # Code bleibt reaktivierbar via `[trading] export_trade_marks=true`; + # spart im Aus-Zustand die DB-Abfrage im ~5-s-Export-Takt. + # ⚠ Kein `raise StopIteration` mehr als Sprung (2026-07-31, Audit): + # StopIteration IST eine Exception und wurde vom breiten `except` unten + # gefangen — funktionierte, wäre aber sofort fragil, sobald dort jemand + # Logging ergänzt (Phantom-Fehler bei JEDEM Export-Takt). + if self._export_trade_marks: + try: + boff = self.trader._broker_offset_s() + marks: list[tuple[str, int]] = [] + ps = self.trader.snapshot() + if ps.get("ticket") and ps.get("open_time"): + marks.append(("O", int(float(ps["open_time"]) + boff))) + for t in (self.history.last_closed_trades(_TRADE_MARKS) or []): + if t.get("entry_time"): + marks.append(("O", int(float(t["entry_time"]) + boff))) + if t.get("exit_time"): + marks.append(("C", int(float(t["exit_time"]) + boff))) + for kind, ts in marks[:2 * _TRADE_MARKS + 1]: + lines.append(f"TR;{kind};{ts}") + except Exception: + pass + # Zonen NICHT mehr aus der (statischen) [zones]-Config — die dynamische + # Handels-Range zeichnet der Indikator selbst aus den Chart-Kerzen (TF-genau). + path = os.path.join(self._mql5_files_dir, "sr_levels.csv") + with open(path + ".tmp", "w", encoding="ascii", errors="ignore") as f: + f.write("\n".join(lines) + "\n") + os.replace(path + ".tmp", path) + except Exception as e: + log.debug(f"write_levels: {e}") + + def _verdict(self, market, wave_signal, wave_snap, tu, agent, elliott, + snap_parts_patterns=None) -> dict: + """Gesamtempfehlung aus den Modulen für die Widget-Grafik. + + Headline = die **validierte Welle** (einziges Modul mit belegtem Edge); + die übrigen Module bilden einen gewichteten Konsens-Balken. REINE + ANZEIGE — ändert NICHT die Order-Logik (die hört nur auf die Welle). + Votes: +1 LONG, −1 SHORT, 0 neutral. + """ + def word(v): + return "auf" if v > 0 else "ab" if v < 0 else "flach" + + bid = (market or {}).get("bid") + votes = [] + + def add(name, vote, weight, detail, used=True): + """`used=False` = das Modul hat DAUERHAFT kein Stimmrecht (gemessen + ohne Inhalt) und wird im Dashboard NICHT mehr als Chip gezeigt. + ⚠ Es bleibt trotzdem in `votes` — `log_verdict_votes` speist daraus + die Telemetrie, und die 25.000-Verdict-Messung braucht die Reihe + weiter. Ausgeblendet wird nur die Anzeige, nicht die Messung. + Nicht zu verwechseln mit `weight=0` aus SITUATIVER Stille (Welle bei + WARTEN, Squeeze ohne Ausbruch): die sprechen zeitweise und bleiben + sichtbar.""" + votes.append({"name": name, "vote": int(vote), "weight": weight, + "detail": detail, "used": bool(used)}) + + # 1) Welle — primär (validierter Edge). ⚠ Bei WARTEN hat sie KEINE Aussage + # → Gewicht 0 (Fix 2026-07-30). Vorher stand w=3,0 permanent im Nenner und + # zog die Bias-Nadel dauerhaft zur Mitte — derselbe Logikfehler, der am + # 2026-07-15 beim Squeeze behoben wurde, hier übersehen. Das wog schwer: + # gemessen sind ~91 % der letzten 2000 Empfehlungen WARTEN, die Nadel war + # also fast immer künstlich gedämpft (real: bias 0,312 statt 0,500). + wsig = (wave_signal or {}).get("signal") + wv = 1 if wsig == "LONG" else -1 if wsig == "SHORT" else 0 + add("Welle", wv, 3.0 if wv != 0 else 0.0, + f"{wsig or '—'} · {(wave_signal or {}).get('conf_pct', 0)}%") + + # 2/3) M30 / H1 — die EMA-Signale, die die Welle tatsächlich nutzt. + # ⚠ Gewicht 0 OHNE Aussage (Fix 2026-07-31) — dieselbe Regel wie bei Welle, + # Squeeze, Elliott und KI; bei der Verdict-Überarbeitung am 30.07. hier + # übersehen. Eine 0-Stimme bedeutet „EMA-Abstand im Totband" = keine Aussage, + # nicht „neutral" — mit vollem Gewicht im Nenner zog sie die Bias-Nadel + # dauerhaft zur Mitte. + # GEMESSEN an 7.074 geloggten Verdicts (`verdict_votes`): **M30 ist in 57,9 % + # der Fälle ohne Aussage** (H1 nur in 1,8 %) → Ø-Bias-Betrag 0,371 → **0,441**, + # Median-Verstärkung **1,30×**, in **33,3 %** der Zeilen deutlich stärkere Nadel. + # ⚠ Anlass war die User-Frage „H1 aus der Gesamtempfehlung entfernen?". Die + # Antwort ist NEIN — die vermutete Doppelzählung ist gemessen schwach (bei + # gerichteter Welle stimmt M30 nur zu 14,9 % mit ihr überein, H1 zu 47 %; M30 + # und H1 untereinander nur zu 33,8 %). Der eigentliche Defekt war dieser hier. + # REINE ANZEIGE — die Order-Logik hängt an der Wellen-Headline, nicht am Bias. + m30 = int((wave_snap or {}).get("htf_trend", 0) or 0) + h1 = int((wave_snap or {}).get("h1_trend", 0) or 0) + add("M30", m30, 1.5 if m30 != 0 else 0.0, word(m30)) + add("H1", h1, 1.5 if h1 != 0 else 0.0, word(h1)) + + # 4) TradersUnion ENTFERNT aus dem Verdict-Konsens (User-Vorgabe 2026-07-06): + # lagt + nicht backtestbar + Standard-Indikator-Konfluenz = kein Edge. Bleibt + # nur noch reine Anzeige (Snapshot `tu`), zählt NICHT mehr in Bias/Agree. + + # 5) KI-Copilot — Gewicht NUR bei gerichteter Aussage (LONG/SHORT). + # ⚠ GEÄNDERT 2026-07-31 (Modul-Audit): vorher zählte ein explizites "NEUTRAL" + # als echte Stimme mit Gewicht 1,0. Gemessen an 7.084 Verdicts ist das aber + # **in 93,1 % der Fälle** der Zustand — also der Normalzustand des Copiloten, + # kein abgewogenes Urteil (Elliott zeigt dasselbe Muster nur in 1,3 %). + # Damit hatte ausgerechnet das Modul mit der SCHWÄCHSTEN Beleglage + # (`analyze_verdict_calibration.py`: nicht robust prädiktiv) den GRÖSSTEN + # dämpfenden Einfluss auf die Bias-Nadel. Wirkung der Angleichung: + # Ø|bias| 0,441 → 0,569 (Median-Verstärkung 1,25×, betrifft 79,5 % der Zeilen); + # kumuliert mit den Fixes von Welle/Squeeze/M30/H1: 0,370 → 0,569 = 1,54×. + # Der Chip bleibt sichtbar und zeigt „NEUTRAL", er stimmt nur nicht mehr mit ab. + adv = (agent or {}).get("advisory") or {} + ab = adv.get("bias") + av = 1 if ab == "LONG" else -1 if ab == "SHORT" else 0 + add("KI-Copilot", av, 1.0 if av != 0 else 0.0, + f"{ab or '—'} · {adv.get('confidence', 0)}%") + + # 6) Elliott/FVG — nur stimmberechtigt, wenn ein Ziel existiert (sonst w=0) + ev, edet, ew = 0, "—", 0.0 + tgt = (elliott or {}).get("target") + if tgt is not None and bid: + ev = -1 if tgt < bid else 1 if tgt > bid else 0 + edet = f"{(elliott or {}).get('wave', '—')} → {tgt:.3f}" + # ⚠⚠ GEWICHT AUF 0 GESETZT (2026-08-06, gemessen + # `analyze_verdict_modules.py` über 15.544 Zeilen). Elliott hatte mit + # **1.384 Episoden die grösste Stichprobe** der ganzen Tabelle und ist + # darin **flach**: Überschuss −0,075 / +0,032 ×ATR, **50 % Treffer**. + # Gleichzeitig war es mit **25,9 % die zweitlauteste Stimme** der + # Bias-Nadel — weil es praktisch nie schweigt (stumm in 1 % der Zeilen). + # Zusammen mit H1 (38,1 %) bildete es einen Dauerhintergrund von ~64 %, + # gegen den die episodischen Module erst anschieben mussten. + # Ein Modul mit der grössten Stichprobe und ohne messbaren Inhalt darf + # den Konsens nicht dominieren. Der CHIP bleibt (reine Anzeige, das Ziel + # ist als Kontext nützlich) — nur die Stimme entfällt. + # ⚠ Reine ANZEIGE-Änderung: die Order steuert die Welle über die + # Headline, nicht der Konsens. Zurück: `ew = 1.0 if ev != 0 else 0.0`. + ew = 0.0 + add("Elliott", ev, ew, edet, used=False) + + # 7) Squeeze-Breakout — Event-Signal: stimmt NUR bei `active` mit (dann 2,0, + # gemessen 2-Stichproben-positiv, unter der Welle=3,0). armed/keine → Gewicht 0 + # — sonst dämpft das fast immer neutrale Modul die Bias-Nadel dauerhaft + # (Logikfehler bis 2026-07-15: w=2,0 stand permanent im Nenner → Nadel ~×0,8). + sq = (wave_snap or {}).get("squeeze") or {} + sq_active = sq.get("state") == "active" + sqv = (1 if sq.get("dir") == "LONG" else -1) if sq_active else 0 + add("Squeeze", sqv, 2.0 if sq_active else 0.0, + f"{sq.get('state') or '—'}" + (f" {sq.get('dir')}" if sq.get("dir") else "")) + + # 8) Chartmuster — User-Vorgabe 2026-07-30 („berücksichtige die Chartmuster + # in der Gesamtempfehlung, sobald eins erkannt wird"). Gewicht bewusst KLEIN: + # der fällige KONTROLLTEST (2026-07-30) zeigt, dass die Muster einen zwar in + # BEIDEN Hälften positiven, aber kleinen Zusatz über einen GENERISCHEN + # Breakout hinaus liefern — Muster ØR +0,206/+0,328 vs. beliebiger Swing-Bruch + # +0,184/+0,220 (Delta nur +0,022 in H1, +0,108 in H2). Der Löwenanteil des + # Ertrags kommt also vom Breakout+Trailing, nicht vom Muster. `forming` zählt + # nur halb, weil das Muster dort noch nicht bestätigt ist. Ziel-Trefferquote + # bleibt niedrig (23–26 %), die Stimme sagt also „Richtungstendenz", nicht + # „Kursziel". + # ⚠ ABGESENKT 1,0 → `_PAT_W`=0,25 (User-Vorgabe 2026-08-01 „geben wir dem + # Muster doch 5 %"). Anlass war die Nachmessung der 2026-07-31 ergänzten + # Handbuch-Muster: von den 8 gemessenen Alt-Typen schlägt nur `cup` die + # Kontrolle, und unter 6 neu getesteten Typen war `wedge_rise` der einzige + # Treffer — bei 6 Vergleichen ist das gut mit Zufall vereinbar. Empfehlung war + # Gewicht 0 (reine Anzeige); der User wollte die Stimme behalten, aber klein. + # 0,25 ist EMPIRISCH gesetzt, nicht geraten: über die letzten 4.000 + # `verdict_votes` liegt die Gewichtssumme der übrigen Module im Median bei + # 4,50 → Anteil 0,25/(4,50+0,25) = **5,3 %** (die alten 1,0 waren 18,2 %). + # Ändert sich das Modul-Gefüge, mit derselben Rechnung nachziehen. + # REINE ANZEIGE — der Bias steuert keine Order (die hängt an der Wellen- + # Headline); die Stimme wird in `verdict_votes` weiter mitgeloggt. + pv, pdet, pw = 0, "—", 0.0 + try: + _pats = (snap_parts_patterns or {}).get("patterns") or [] + # ⚠ NUR GEMESSENE Muster-Typen stimmen mit (Fix 2026-07-31): das Gewicht + # 1,0 stammt aus dem Kontrolltest (`backtest_patterns.py` gegen einen + # generischen Swing-Bruch), der nur Doppeltop/-boden, Tasse/inv. Tasse, + # SKS/inv. SKS und auf-/absteigendes Dreieck abdeckte. Die am selben Tag + # aus dem Handbuch ergänzten Typen (Flagge, Wimpel, Rechteck, Keil, + # Dreifach) sind REINE ANZEIGE — sonst würde ein validiertes Gewicht + # still mit unvalidierten Eingaben gefüttert (genau der Deployment-Drift- + # Fehler, der heute fünfmal aufgefallen ist). `measured` kommt aus + # `core/patterns.py`; fehlt das Feld (Alt-Snapshot), gilt es als gemessen. + _p0 = next((m for m in _pats + if m.get("dir") in ("bull", "bear") and m.get("measured", True)), + None) + if _p0: + pv = 1 if _p0["dir"] == "bull" else -1 + _conf = _p0.get("status") == "confirmed" + pw = _PAT_W if _conf else _PAT_W / 2 + pdet = f"{_p0.get('name', '—')} · {'bestätigt' if _conf else 'bildet sich'}" + except Exception: + pass + add("Muster", pv, pw, pdet) + + # 9/10) ORDERBUCH-Imbalance + LIQUIDITÄTS-TREND (User-Vorgabe 2026-07-30). + # ⚠ BEIDE sind UNGEMESSEN — anders als alle anderen Stimmen. Die Übertragbar- + # keit von Hyperliquid-Orderbuchdaten auf den Pepperstone-CFD wird gerade + # erst erhoben (`analyze_bookflow.py`, ab ~50k Snapshots ≈ 3 Tage erster Blick). + # ⚠ Der Lead-Lag-Test (2026-07-30, 72 h) zeigt sogar, dass **Pepperstone + # FÜHRT** (Peak bei k=−1, r=0,525) und HL folgt — HL ist ein Oracle-basierter + # HIP-3-Perp, also strukturell Follower. Das dämpft die Erwartung erheblich. + # Deshalb bewusst das KLEINSTE Gewicht im ganzen Verdict (0,5 je Stimme; + # zum Vergleich: Welle 3,0 · Squeeze 2,0 · Muster/Elliott/KI 1,0). + # Beide werden in `verdict_votes` mitgeloggt → in ein paar Wochen ist + # datenbasiert entscheidbar, ob sie bleiben, mehr Gewicht bekommen oder + # rausfliegen (wie TU 2026-07-06). + ov, odet, ow = 0, "—", 0.0 + lv, ldet, lw = 0, "—", 0.0 + try: + w = self.hl_walls.walls() + if w: + imb = w.get("imb5") + if isinstance(imb, (int, float)) and abs(imb) >= _OB_IMB_MIN: + ov = 1 if imb > 0 else -1 + # ⚠⚠ GEWICHT AUF 0 (2026-08-06). Das Orderbuch-Modul ist aus + # ZWEI unabhängigen Richtungen als nicht tragfähig gemessen: + # · `analyze_verdict_modules.py` (15.544 Zeilen): über **2.688 + # Episoden beidhälftig NEGATIV** (−0,054/−0,066 ×ATR, 49 % + # Treffer) — der einzige konsistent falsche Beitrag im Feld. + # · Das HL-Projekt selbst (`bookflow_report.txt`): Bid/Ask-Druck + # auf den CFD übertragen −0,61/−0,52 bp und KIPPT; die eigene + # Schwelle dort lautet „< ~3 bp ist von den Kosten gefressen". + # Dazu der Lead-Lag-Befund: **Pepperstone führt** (Peak k=−1 ≈ 6 s), + # HL-Richtung trifft nach 6 s zu 51 % (n=545) — alles + # Preis-Abgeleitete von HL läuft hinterher. + # Der CHIP bleibt (Imbalance ist als Kontext lesbar), nur die + # Stimme entfällt. Zurück: `ow = 0.5`. + ow = 0.0 + odet = f"Imbalance {imb:+.2f}" + elif isinstance(imb, (int, float)): + odet = f"Imbalance {imb:+.2f} (zu schwach)" + tr = w.get("trend") or {} + if tr.get("dir") in ("up", "down"): + lv = 1 if tr["dir"] == "up" else -1 + lw = 0.5 + ldet = f"{tr['dir']} {tr.get('slope_per_h', 0):+.2f}$/h" + except Exception: + pass + add("Orderbuch", ov, ow, odet, used=False) + add("Liq-Trend", lv, lw, ldet) + + # Bias nur über stimmberechtigte Module (weight>0); agree/total = wie viele + # der ANDEREN stimmberechtigten Module die Wellen-Richtung teilen (die Welle + # zählte sich vorher selbst mit — „3/6 für LONG" war geschönt). + wsum = sum(v["weight"] for v in votes) or 1.0 + bias = sum(v["weight"] * v["vote"] for v in votes) / wsum + others = [v for v in votes if v["name"] != "Welle" and v["weight"] > 0] + # Referenzrichtung für „agree": normal die Welle. Sagt sie WARTEN (91 % der + # Zeit), gibt es keine Wellen-Richtung — dann zählt die Übereinstimmung mit + # der BIAS-Richtung (Fix 2026-07-30). Vorher stand dort immer „0/N", obwohl + # sich die anderen Module durchaus einig waren. + ref = wv if wv != 0 else (1 if bias > 0 else -1 if bias < 0 else 0) + agree = sum(1 for v in others if ref != 0 and v["vote"] == ref) + return { + "headline": wsig, "conf": (wave_signal or {}).get("conf_pct", 0), + "tf": (wave_snap or {}).get("tf"), # Zeitebene, für die die Empfehlung gilt + "bias": round(bias, 3), "agree": agree, "total": len(others), + # ⚠ `ref_dir` = WORAUF sich `agree` bezieht (Fix 2026-07-31). Bei WARTEN + # ist das NICHT die Headline, sondern die Bias-Richtung — das Frontend + # beschriftete es vorher mit der Headline und schrieb dadurch + # „stark LONG · 4/4 Module für WARTEN". Die Richtung kommt jetzt VON HIER, + # statt im Frontend aus `bias` nachgebaut zu werden (dort galt ein + # ±0,05-Totband, hier `bias > 0` — die Nachbildung wäre am Rand + # auseinandergelaufen; genau die Sorte Divergenz, die heute mehrfach + # aufgefallen ist). + "ref_dir": "LONG" if ref > 0 else "SHORT" if ref < 0 else None, + # Wie viele Module HABEN gerade keine Aussage (Gewicht 0)? Ohne diese Zahl + # las sich „4/4" widersprüchlich zu den 9 sichtbaren Chips. + "silent": sum(1 for v in votes if not v["weight"]), + "votes": votes, + } + + # ══════════════════════════════════════════════════════════════════════ + # SNAPSHOT (ein JSON-fähiger Zustand für /api/snapshot + WebSocket) + # ══════════════════════════════════════════════════════════════════════ + _SNAP_TTL_S = 0.8 # geteilter Snapshot gilt so lange als frisch + + def set_manual_margin(self, eur: float) -> float: + """Feste Einsatz-Margin in Kontowährung (User-Wunsch 2026-08-04). + 0 = automatisch (wie bisher `margin_buffer_pct` % der freien Margin). + + ⚠ Wirkt auf ALLE neuen Positionen — manuelle wie autonome (Squeeze/Signal). + Die freie Margin bleibt Obergrenze; `calc_lots` deckelt und loggt das. + Neustart-fest über `runtime_state.json`.""" + from core.config import set_manual_margin as _set + v = _set(eur) + self._manual_margin = v + self._save_runtime_state() + return v + + def set_margin_pct(self, pct: float) -> float: + """Einsatz als PROZENT der freien Margin (User-Wunsch 2026-08-05). + + Das ist die zweite Hälfte des Einsatz-Felds: `set_manual_margin` gibt einen + FESTEN Betrag vor, das hier den ANTEIL. Beides existierte bisher nur in der + `ini` (`margin_buffer_pct`, live 95) — die Prozent-Schraube hatte keine UI. + + ⚠ VORRANG: ein fester Betrag (`manual_margin > 0`) schlägt den Prozentsatz — + `calc_lots` prüft ihn zuerst. Der Prozentsatz bleibt dabei die Obergrenze + (freie Margin × pct), ein zu hoher Fixbetrag wird darauf gedeckelt. + ⚠ Wirkt auf ALLE neuen Positionen, auch die autonomen. Neustart-fest. + ⚠ `set_margin_buffer` klemmt auf 1–99 % — 100 % wäre ohnehin unhaltbar + (kein Puffer für Spread/Swap → Margin-Call beim Öffnen).""" + from core.config import set_margin_buffer as _set, get_margin_buffer + _set(pct) + self._margin_pct = round(get_margin_buffer() * 100.0, 1) + self._save_runtime_state() + return self._margin_pct + + def _manual_stats_cached(self) -> dict | None: + """Kennzahlen der manuellen Trades für die Karte „Manuelle Trades" + (2026-08-04). Gleiches Cache-Muster wie `_alignment_cached` — die + Aggregation läuft über die ganze `trades`-Tabelle und hat im 1-s-Snapshot + nichts zu suchen.""" + now = time.time() + if now - getattr(self, "_manual_ts", 0.0) > 120: + try: + self._manual_cache = self.history.manual_stats(30) + except Exception as e: + log.debug(f"manual_stats: {e}") + self._manual_cache = None + self._manual_ts = now + return getattr(self, "_manual_cache", None) + + def _alignment_cached(self) -> dict | None: + """Ausrichtungs-Split (mit/gegen/ohne Empfehlung) für den Order-Dialog — + ehrliche eigene Zahlen statt generischem Hinweis (2026-07-24; Gegen-Signal- + Trades sind die gemessene Kern-Leckage). + ⚠ Seit 2026-08-06 über die **volle Historie** (1.216 Trades) statt der + letzten 40 — der Befund ist dort beidhälftig robust, über 40 war er eine + Momentaufnahme. Zusätzlich je Lot normiert (`pnl_lot`), weil absolute + Euro-Vergleiche bei wechselnder Positionsgröße ungültig sind. + DB-Query gecacht (~120 s), Snapshot bleibt billig.""" + now = time.time() + if now - getattr(self, "_align_ts", 0.0) > 120: + try: + self._align_cache = self.history.alignment_stats(0) + except Exception as e: + log.debug(f"alignment_stats: {e}") + self._align_cache = None + self._align_ts = now + return getattr(self, "_align_cache", None) + + def _hl_ctx_refresh(self): + """Holt den HL-Kontext im Hintergrund (s. Aufrufstelle im Trend-Loop).""" + try: + d = self.hl_walls.market() + if d: + self._hl_ctx = d + except Exception as e: + log.debug(f"hl market: {e}") + finally: + self._hl_ctx_busy = False + + def _block_mix_cached(self) -> dict | None: + """WELCHE Gates blocken heute, und wie oft? (2026-08-06) + + WARUM SICHTBAR: auf die Frage „warum gab es heute so wenige Empfehlungen?" + liess sich das nur per DB-Abfrage beantworten. Real am 06.08.: von 646 + Zeilen waren **6 ungeblockt** — entry_room 274 · min_conf 225 · + breakout_pending 88 · htf_counter 50. Die Karte sagt das jetzt selbst. + Reine Anzeige, ~120 s gecacht.""" + now = time.time() + if now - getattr(self, "_blockmix_ts", 0.0) > 120: + try: + import sqlite3 + con = sqlite3.connect(HISTORY_DB_FILE, timeout=5.0) + rows = con.execute( + "SELECT COALESCE(block_reason,'') b, COUNT(*) n FROM recommendations " + "WHERE date(timestamp,'unixepoch','localtime') = date('now','localtime') " + "GROUP BY b ORDER BY n DESC").fetchall() + con.close() + ges = sum(r[1] for r in rows) + self._blockmix_cache = { + "total": ges, + "frei": next((r[1] for r in rows if not r[0]), 0), + "top": [{"grund": r[0], "n": r[1]} for r in rows if r[0]][:4], + } if ges else None + except Exception as e: + log.debug(f"block_mix: {e}") + self._blockmix_cache = None + self._blockmix_ts = now + return getattr(self, "_blockmix_cache", None) + + def _m15_track_cached(self) -> dict | None: + """Selbstmessung der M15-Karte — Trefferquote ihrer eigenen Lesart. + + ⚠ Gegen 50 % zu lesen. Gemessen wird NUR die bedingte Lesart am Level + („Abprall → eher abwaerts"), nicht eine freie Richtungsprognose — eine + solche ist im Projekt gemessen unmoeglich (AUC 0,50 oos / 0,52 + in-sample). ~120 s gecacht.""" + now = time.time() + if now - getattr(self, "_m15track_ts", 0.0) > 120: + try: + self._m15track_cache = (self.history.m15_accuracy(200) + if self.history else None) + except Exception as e: + log.debug(f"m15_track: {e}") + self._m15track_cache = None + self._m15track_ts = now + return getattr(self, "_m15track_cache", None) + + def _entry_gap_cached(self) -> dict | None: + """AUSFUEHRUNGS-QUALITAET: kommt der Bot-Einstieg AM Level an? + + ⚠⚠ WARUM DAS DIE WICHTIGSTE UNBEOBACHTETE ZAHL WAR. Der Umbau auf + ruhende Stop-Orders (05.08.) hatte als einziges Erfolgskriterium: der + Abstand Einstieg↔Level muss gegen 0 gehen (vorher Median +0,275×ATR). + Das ist der groesste je gemessene Hebel des Projekts (+0,32 R gegenueber + Market) — und niemand hat je hineingesehen. + + ⚠ DIE ERFOLGSMELDUNG IST PARADOX: eine per PENDING gefuellte Order + erzeugt **keine** `AUTO-SQUEEZE-ENTRY`-Logzeile (die entsteht nur im + Market-Fallback). Bot-Trades OHNE Log-Treffer sind also der ERFOLG. + Hier wird deshalb das gezaehlt, was ohne Logdatei bestimmbar ist: + wie viele Bot-Trades es seit dem Umbau gab und wie viele davon ueber + den Market-Fallback liefen (erkennbar am Zaehler `squeeze_entry_count` + bzw. am Setup-Tag). ~300 s gecacht — DB-Abfrage gehoert nicht in den + 1-s-Snapshot.""" + now = time.time() + if now - getattr(self, "_entrygap_ts", 0.0) > 300: + try: + import sqlite3 + con = sqlite3.connect(HISTORY_DB_FILE, timeout=5.0) + # Stichtag = Umbau auf ruhende Stop-Orders (05.08. 08:55) + seit = 1786000500 + n, = con.execute( + "SELECT COUNT(*) FROM trades WHERE entry_time >= ? " + "AND setup LIKE 'SQUEEZE%'", (seit,)).fetchone() + con.close() + self._entrygap_cache = {"n_seit_umbau": n, "soll": 12} + except Exception as e: + log.debug(f"entry_gap: {e}") + self._entrygap_cache = None + self._entrygap_ts = now + return getattr(self, "_entrygap_cache", None) + + def _rec_track_cached(self) -> dict | None: + """Selbst-Kalibrierung: Trefferquote der letzten Empfehlungs-Episoden. + + ⚠ Gegen 50 % zu lesen, nicht gegen 0 — es ist eine Richtungsaussage. + Gemessener Ausgangsstand (`analyze_signal_live.py`, 1.071 Episoden): + 48–51 %. Diese Zeile macht laufend sichtbar, ob sich das aendert, statt + dass es erst auf Nachfrage rueckwirkend gemessen wird. ~120 s gecacht.""" + now = time.time() + if now - getattr(self, "_rectrack_ts", 0.0) > 120: + try: + self._rectrack_cache = (self.history.rec_accuracy(200) + if self.history else None) + except Exception as e: + log.debug(f"rec_accuracy: {e}") + self._rectrack_cache = None + self._rectrack_ts = now + return getattr(self, "_rectrack_cache", None) + + def _loss_streak_today(self) -> tuple[int, int]: + """(Verlust-Trades heute, Trades heute) — für den Mental-Check der Entry- + Checkliste („nach 2 Losses Session beenden"). DB-Query ~120 s gecacht.""" + now = time.time() + if now - getattr(self, "_streak_ts", 0.0) > 120: + try: + import datetime as _dt + t0 = _dt.datetime.now(_BERLIN).replace( + hour=0, minute=0, second=0, microsecond=0).timestamp() + with self.history._connect() as conn: + rows = conn.execute( + "SELECT pnl FROM trades WHERE exit_time>=? AND pnl IS NOT NULL", + (t0,)).fetchall() + self._streak_cache = (sum(1 for r in rows if (r[0] or 0) < 0), len(rows)) + except Exception as e: + log.debug(f"loss_streak: {e}") + self._streak_cache = (0, 0) + self._streak_ts = now + return getattr(self, "_streak_cache", (0, 0)) + + def _entry_checklist(self, side: str, snap_parts: dict) -> dict: + """Prüft die Trade-Setup-Checkliste (User-Vorgabe 2026-07-30) für eine + geplante Eröffnung und liefert die VERLETZTEN Punkte zurück. + + Warum server-seitig: die Daten liegen alle hier (TF-Ampel, Struktur, Level, + News, Tages-P&L, Trade-Historie) — das Frontend soll nur anzeigen. + + Jeder Punkt: `state` = "fail" (klar dagegen) | "warn" (Vorsicht) | "ok". + Reine ENTSCHEIDUNGSHILFE — sie blockt nichts (wie der bisherige Dialog), + macht aber sichtbar, was gegen den Trade spricht. Die Gewichtung ist NICHT + aus einem Backtest, sondern aus (a) den im Projekt GEMESSENEN Leckagen + (Handeln ohne Signal, Nacht/teure Stunden, Verlust-Serien) und (b) der + vom User vorgegebenen Checkliste.""" + want = "LONG" if side == "long" else "SHORT" + d = 1 if want == "LONG" else -1 + items: list[dict] = [] + add = lambda k, st, lab, det: items.append( + {"key": k, "state": st, "label": lab, "detail": det}) + + wave = snap_parts.get("wave") or {} + market = snap_parts.get("market") or {} + # ── 1. Signal-Deckung (die gemessene Kern-Leckage) ─────────────────── + sig = (wave.get("signal") or "WARTEN") if isinstance(wave.get("signal"), str) \ + else ((wave.get("signal") or {}).get("signal") or "WARTEN") + if sig == want: + add("signal", "ok", "Empfehlung deckt den Trade", f"Signal {sig}") + elif sig in ("WARTEN", "—", None): + add("signal", "fail", "Keine Empfehlung (WARTEN)", + "gemessen: Trades ohne Signal sind die größte Verlustquelle") + else: + add("signal", "fail", f"Empfehlung steht GEGEN dich ({sig})", + "gemessen: 40 % Trefferquote gegen das Signal") + # ── 2. HTF-Trend klar? (Checklisten-Punkt 1) ───────────────────────── + turns = wave.get("tf_turns") or {} + htf = [turns.get(t, {}).get("dir") if isinstance(turns.get(t), dict) + else turns.get(t) for t in ("M30", "H1")] + htf = [x for x in htf if x in (1, -1, 0)] + if htf and all(x == d for x in htf): + add("htf", "ok", "HTF-Trend (M30+H1) trägt die Richtung", "") + elif htf and any(x == -d for x in htf): + add("htf", "fail", "HTF-Trend steht dagegen (M30/H1)", + "deine Regel: nur MIT dem HTF-Trend, niemals dagegen") + else: + add("htf", "warn", "HTF-Trend unklar/seitwärts", "kein klarer M30/H1-Trend") + # ── 3. Entry-Raum bis zum Gegenlevel (gemessen monoton) ────────────── + try: + dl = self._draw_levels() + atr = float((wave.get("pb_feats") or {}).get("atr") or 0.0) or 0.2 + bid = market.get("bid") + tgt = (dl.get("res") or [None])[0] if d > 0 else (dl.get("sup") or [None])[0] + if bid and tgt: + room = abs(tgt - bid) / atr + if room < 0.6: + add("room", "fail", f"Kein Raum: Level nur {room:.1f}×ATR entfernt", + "gemessen: <0,6×ATR Raum = negativer Erwartungswert") + elif room < 1.0: + add("room", "warn", f"Wenig Raum ({room:.1f}×ATR bis zum Level)", "") + else: + add("room", "ok", f"Raum bis zum Level: {room:.1f}×ATR", "") + except Exception: + pass + # ── 4. News-Konflikt (Checklisten-Punkt 3) ─────────────────────────── + ns = snap_parts.get("news_sentiment") + if isinstance(ns, (int, float)) and abs(ns) >= 0.5: + if (ns > 0) != (d > 0): + add("news", "fail", + f"News-Flow steht dagegen ({ns:+.2f})", + "stark bullische/bärische Schlagzeilen gegen deine Richtung") + else: + add("news", "ok", f"News-Flow stützt ({ns:+.2f})", "") + # ── 5. Kosten (Spread/ATR — gemessen Ø 0,265) ──────────────────────── + cr = snap_parts.get("cost_ratio") + if isinstance(cr, (int, float)) and cr > 0: + if cr >= 0.32: + add("cost", "fail", f"Kosten hoch ({cr:.2f}×ATR)", + "gemessen: ab ~0,32×ATR frisst der Spread den Edge") + elif cr >= 0.20: + add("cost", "warn", f"Kosten erhöht ({cr:.2f}×ATR)", "") + else: + add("cost", "ok", f"Kosten günstig ({cr:.2f}×ATR)", "") + # ── 6. Mental-Check: Verlust-Serie heute (Checklisten-Punkt 5) ─────── + losses, total = self._loss_streak_today() + day_total = float((snap_parts.get("day_pl") or {}).get("total") or 0.0) + if losses >= 2: + bal = float(self.data.balance or 0.0) + pct = (abs(day_total) / bal * 100.0) if (bal and day_total < 0) else 0.0 + add("streak", "fail", + f"Heute schon {losses} Verluste ({total} Trades, {day_total:+.0f} €" + + (f" = {pct:.0f}% des Kontos)" if pct else ")"), + "deine Regel: nach 2 Losses Session beenden") + elif day_total < 0: + add("streak", "warn", f"Tag im Minus ({day_total:+.0f} €)", "") + else: + add("streak", "ok", f"Tagesbilanz {day_total:+.0f} €", "") + # ── 7. Nacht-/Kostenfalle (gemessen: 0–7 Uhr Berlin) ───────────────── + try: + hb = datetime.now(_BERLIN).hour + if hb in _SQUEEZE_NIGHT: + add("hour", "fail", f"Nacht-Stunde ({hb}:00 Berlin)", + "gemessen: 0–7 Uhr = Kostenfalle (Spread ÷ dünner ATR)") + except Exception: + pass + # ── 8. Wirtschaftskalender (Handbuch: „keine Trades vor High-Impact-News") + # ⚠ NICHT gemessen (zu wenige Events für eine 2-Stichproben-Aussage) — + # deshalb hier nur „warn", nicht „fail": es soll den Verdict nicht allein + # auf `stop` kippen. Der Bot selbst pausiert im Fenster (autonome Entries). + try: + bl = self.calendar.blackout() + if bl: + m = bl["minutes"] + when = (f"in {m:.0f} min" if m > 0 else f"vor {abs(m):.0f} min") + add("event", "warn", f"News-Fenster: {bl['title']} ({when})", + "Handbuch-Regel: 15 min um High-Impact-Events nicht handeln " + "(im Projekt nicht gegengemessen)") + else: + nx = self.calendar.next_event() + if nx and nx["minutes"] <= 90: + add("event", "ok", + f"Nächstes Event: {nx['title']} in {nx['minutes']} min", "") + except Exception: + pass + fails = sum(1 for x in items if x["state"] == "fail") + warns = sum(1 for x in items if x["state"] == "warn") + return {"side": want, "items": items, "fails": fails, "warns": warns, + "verdict": ("stop" if fails >= 2 else "warn" if (fails or warns) else "ok")} + + def snapshot_cached(self) -> dict: + """Teilt EINEN frisch berechneten Snapshot über alle WS-Clients/HTTP- + Aufrufe (TTL ~0,8 s). Spart SQLite/Verdict-Last bei mehreren Geräten — + unabhängig von der Client-Anzahl wird nur ~1×/TTL gerechnet.""" + now = time.time() + with self._snap_lock: + if self._snap_cache and (now - self._snap_cache_ts) < self._SNAP_TTL_S: + return self._snap_cache + snap = self.snapshot() # teuer → außerhalb des Locks rechnen + with self._snap_lock: + self._snap_cache = snap + self._snap_cache_ts = time.time() + return snap + + def _fetch_m5_monitor(self, days: int = 30): + """M5-Bars für den Squeeze-Monitor (unter mt5_lock, aus der Server-Instanz).""" + import MetaTrader5 as mt5 + from core.mt5_utils import mt5_lock + sym = self.data.symbol + if not sym: + return None + with mt5_lock(timeout=5) as got: + if not got: + return None + bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, days * 288 + 30) + si = mt5.symbol_info(sym) + point = si.point if si else 0.01 + if bars is None or len(bars) == 0: + return None + return {"H": [float(b["high"]) for b in bars], "L": [float(b["low"]) for b in bars], + "C": [float(b["close"]) for b in bars], + "SP": [float(b["spread"]) * point for b in bars]} + + def _evaluate_pbreak_predictions(self, window_min: int = 60): + """Wertet offene Abprall/Durchbruch-Vorhersagen anhand von `candles_m1` aus — + DIESELBE Definition wie beim Modell-Training (`_BRK_ATR=0,5`/`_BRK_W=12` M5- + Bars = 60 min in `backtest_srclose_prob.py`): erreicht der Kurs zuerst + `confirm_price` → 'break', zuerst `reject_price` → 'bounce', keins von beiden + binnen 60 min → 'bounce' (Timeout, wie im Training). Self-throttled (~5 min), + aus `_trend_loop`. `candles_m1.time` ist rohe Broker-Zeit — Offset nötig.""" + now = time.time() + if now - getattr(self, "_pbreak_eval_ts", 0.0) < 300: + return + self._pbreak_eval_ts = now + try: + import sqlite3 + off = self.trader._broker_offset_s() + con = sqlite3.connect(HISTORY_DB_FILE, timeout=5.0) + con.row_factory = sqlite3.Row + pend = con.execute( + "SELECT id, ts, direction, predicted, confirm_price, reject_price " + "FROM pbreak_predictions WHERE outcome IS NULL" + ).fetchall() + for r in pend: + d = 1 if r["direction"] == "LONG" else -1 + t0_b = r["ts"] + off # Vorhersage-Zeit in Broker-Zeit + t1_b = t0_b + window_min * 60 + bars = con.execute( + "SELECT h, l, time FROM candles_m1 WHERE time > ? AND time <= ? " + "ORDER BY time ASC", (t0_b, t1_b)).fetchall() + outcome = None + for b in bars: + hit_confirm = (b["h"] >= r["confirm_price"]) if d > 0 else (b["l"] <= r["confirm_price"]) + hit_reject = (b["l"] <= r["reject_price"]) if d > 0 else (b["h"] >= r["reject_price"]) + if hit_confirm: + outcome = "break"; break + if hit_reject: + outcome = "bounce"; break + if outcome is None and time.time() >= (r["ts"] + window_min * 60): + outcome = "bounce" # Timeout, wie im Training (brk=False) + if outcome: + correct = 1 if r["predicted"] == outcome else 0 + con.execute( + "UPDATE pbreak_predictions SET outcome=?, outcome_ts=?, correct=? WHERE id=?", + (outcome, int(time.time()), correct, r["id"])) + con.commit(); con.close() + except Exception as e: + log.debug(f"pbreak_predictions auswerten: {e}") + + def _log_m15_state(self, s: dict | None, market): + """Loggt einen Zustandswechsel der M15-Karte — die Rueckkopplung, die + der Karte bisher fehlte. + + ⚠⚠ NUR BEI WECHSEL. Wer je Tick loggt, bekommt dieselbe Cluster- + Verzerrung, die am 07.08. elf widerlegte Hypothesen ausgeloest hat + (roh 922 Zeilen 52,6 %, entkoppelt 93 Zeilen 45,2 %) — und die mein + erster Tagestest reproduzierte (46 fortlaufende Bars als 46 Trades). + Der Zustand wird aus Status UND Lesart gebildet: wechselt nur das + Level, ist es ein neuer Zustand; bleibt alles gleich, passiert nichts. + ⚠ Fail-safe: ein Fehler hier darf den Snapshot nie stoeren. + """ + try: + if not s: + return + zonen = s.get("zonen") or [] + kosten = (s.get("kosten") or {}).get("ratio") + atr = s.get("atr_m15") + bid = (market or {}).get("bid") + if not atr or not bid: + return + # ⚠⚠ Status und Lesart werden NICHT mehr hier nachgerechnet + # (2026-08-12). Sie kommen aus `_m15_setup` — EINE Quelle fuer + # Anzeige UND Messung, mit Hysterese gegen das Schwellen-Flattern. + # Vorher gab es drei unabhaengige Fassungen, und sie wichen bereits + # ab: dieser Logger kannte den „kein Veto"-Fall des Frontends nicht. + nah = s.get("nah") + status = s.get("status") + lesart = s.get("lesart") + if not status: + return + # ⚠ Das Level bleibt im Schluessel: ein anderes Level IST ein anderes + # Setup. Gemessen ist es ohnehin nicht der Treiber (nur 7,6 % der + # Wechsel gingen allein darauf zurueck). + schluessel = (status, lesart, + None if not nah else round(nah["level"], 3)) + if schluessel == getattr(self, "_m15_last_key", None): + return # kein Wechsel → nichts loggen + self._m15_last_key = schluessel + self.history.log_m15_state( + status=status, veto=(s.get("bias") or {}).get("verboten"), + kosten=kosten, level=None if not nah else nah["level"], + gap_atr=None if not nah else nah["gap_atr15"], + p_break=None if not nah else nah.get("p_break"), + lesart=lesart, price=bid, atr=atr) + except Exception as e: + log.debug(f"m15_state log: {e}") + + def _check_m15_alert(self, s: dict | None): + """Telegram, sobald die M15-Karte auf GRÜN mit gerichteter Lesart springt. + + User-Wunsch 2026-08-14: „wenn es eine Empfehlung gibt (grün), schicke + eine Telegram-Nachricht." + + ⚠⚠ ENTPRELLUNG IST HIER DER GANZE BAU, nicht ein Detail. Roh gemessen + (`m15_states`, 3 Tage nach der Hysterese vom 12.08.) springt die Karte + **85× am Tag** auf grün — das waere Spam, kein Signal. Zwei Stufen, + beide an den echten Daten kalibriert statt geraten: + · **Level + Richtung** als Schluessel: gruen→gelb→gruen am SELBEN + Level ist dasselbe Setup, das flackert, keine neue Gelegenheit. + 85 → 23,7/Tag. + · **Mindestabstand** `m15_alert_cooldown_min` (30) → 13,3/Tag, + und das **Stundenfenster** `m15_alert_hours` (8–20) → **7,7/Tag**. + Zum Vergleich: die Close-Benachrichtigung liefert 2,4–6,1/Tag. + ⚠ Nach `_ALERT_NEU_S` (4 h) darf dasselbe Level erneut melden — sonst + verschwiegen wir ein Setup, das den ganzen Tag wiederkommt. + + ⚠⚠ EHRLICH IM TEXT: die Karte ist gemessen KEIN Richtungssignal. Sie + automatisch zu handeln faellt durch (`backtest_m15_auto.py` 13.08.: + ØR −0,162/−0,094, KI ohne Null, schlechter als HTF-Richtung UND als das + eigene Gegenteil). Die Meldung ist deshalb ein **Hinweis zum Hinsehen** + fuer den Menschen — dessen Auswahl-Vorteil unabhaengig belegt ist + (+3,20 €/Lot ueber 146 Trades) — und nennt das im Text. + `m15_alert=false` schaltet ab. + """ + if not self._m15_alert or not s: + return + try: + if s.get("status") != "gruen" or s.get("lesart") not in ("auf", "ab"): + self._m15_alert_war = False + return + if self._m15_alert_war: # nur die FLANKE, nicht der Zustand + return + self._m15_alert_war = True + now = time.time() + h = datetime.now(_BERLIN).hour + lo, hi = self._m15_alert_hours + if not (lo <= h < hi): + return + nah = s.get("nah") or {} + key = (round(nah.get("level") or 0, 2), s.get("lesart")) + alt_ts = self._m15_alert_ts + if (now - alt_ts) < self._m15_alert_cd: + return + if key == self._m15_alert_key and (now - alt_ts) < _ALERT_NEU_S: + return + tg = self.cfg["telegram"] + if tg.get("enabled", "false").strip().lower() != "true": + return + self._m15_alert_ts, self._m15_alert_key = now, key + pb = nah.get("p_break") + lese = ("unentschieden" if pb is None or 45 <= pb <= 55 else + f"{pb} % Bruch" if pb > 55 else f"{100 - pb} % Abprall") + seite = "Widerstand" if nah.get("seite") == "res" else "Support" + erl = {"SHORT": "LONG", "LONG": "SHORT"}.get( + (s.get("bias") or {}).get("verboten")) + k = (s.get("kosten") or {}).get("ratio") + send_telegram( + f"🟢 M15 · Setup bereit\n" + f"{seite} M30 {nah.get('level')} · {nah.get('gap_atr15')}×ATR_M15\n" + f"Level {lese}" + + (f" · erlaubt wäre {erl}" if erl else " · kein Trend-Veto") + + (f" · Kosten {k}×ATR" if k is not None else "") + + "\nBedingung erfüllt — keine Richtungsprognose " + "(gemessen 48–51 % Treffer).", + tg.get("bot_token", "").strip(), tg.get("chat_id", "").strip()) + log.info(f"📨 M15-Alert {seite} {nah.get('level')} ({lese})") + except Exception as e: + log.debug(f"m15_alert: {e}") + + def _log_cone_check(self, cone: dict | None): + """Legt je Horizont EINEN nicht-ueberlappenden Abdeckungs-Check an. + + ⚠⚠ NICHT-UEBERLAPPEND ist hier kein Detail: ein 120-min-Fenster alle + 5 min waere 24-fach ueberlappend. Genau daran ist `analyze_hl_funding.py` + (06.08.) aufgelaufen, und die Faelligkeitsbedingung `pbreak_accuracy_v2` + ueberschaetzte damit die Beweismenge um das Zehnfache (07.08.). Ein neuer + Check startet je Horizont erst, wenn der vorige abgelaufen ist.""" + try: + if not cone or not self.history: + return + now = time.time() + letzte = getattr(self, "_cone_check_ts", None) + if letzte is None: + letzte = self._cone_check_ts = {} + for lv in cone.get("levels") or []: + if lv.get("band") != 80: + continue + b = lv.get("bars") + fenster = (lv.get("minutes") or 0) * 60 + if not b or not fenster: + continue + if now - letzte.get(b, 0.0) < fenster: + continue # voriger Check laeuft noch + letzte[b] = now + self.history.log_cone_check( + bars=b, band=80, price=cone.get("price") or 0.0, + lo=lv["lo"], hi=lv["hi"], skala=lv.get("skala") or 1.0) + except Exception as e: + log.debug(f"cone_check log: {e}") + + def _evaluate_cone_checks(self): + """Loest faellige Kegel-Checks auf und fuehrt den ACI-Schritt aus. + + ⚠ Der Kegel war dokumentiert **3–5 Pp zu eng** (80 % nominal → real + 76,7 / 76,6 / 75,3 %) und blieb es seit dem 31.07., weil Nachfitten das + Problem nur verschiebt. ACI justiert stattdessen laufend nach. + ⚠ `candles_m1.time` ist ROHE Broker-Zeit — Offset noetig.""" + now = time.time() + if now - getattr(self, "_cone_eval_ts", 0.0) < 300: + return + self._cone_eval_ts = now + try: + import sqlite3 + from core.cone import MINUTES, aci_update + off = self.trader._broker_offset_s() + con = sqlite3.connect(HISTORY_DB_FILE, timeout=5.0) + con.row_factory = sqlite3.Row + offen = con.execute( + "SELECT id, ts, bars, band, lo, hi, skala FROM cone_checks " + "WHERE done IS NULL OR done = 0").fetchall() + skalen = dict(getattr(self, "_cone_skalen", {}) or {}) + for r in offen: + ziel = r["ts"] + MINUTES.get(r["bars"], 30) * 60 + if now < ziel: + continue + row = con.execute( + "SELECT c FROM candles_m1 WHERE time <= ? AND time > ? " + "ORDER BY time DESC LIMIT 1", + (ziel + off, ziel + off - 900)).fetchone() + if not row: + if now >= r["ts"] + 6 * 3600: # keine Bars mehr zu erwarten + con.execute("UPDATE cone_checks SET done=1 WHERE id=?", (r["id"],)) + continue + drin = 1 if r["lo"] <= row["c"] <= r["hi"] else 0 + con.execute("UPDATE cone_checks SET drin=?, done=1 WHERE id=?", + (drin, r["id"])) + s = float(skalen.get(r["bars"]) or 1.0) + skalen[r["bars"]] = aci_update(s, verfehlt=(drin == 0), + band=r["band"] or 80) + con.commit(); con.close() + if skalen != getattr(self, "_cone_skalen", None): + self._cone_skalen = skalen + self._save_runtime_state() # Neustart-fest + log.info("Kegel-ACI: Skalen " + ", ".join( + f"{k}→{v:.3f}" for k, v in sorted(skalen.items()))) + except Exception as e: + log.debug(f"cone_checks auswerten: {e}") + + def _evaluate_m15_states(self): + """Traegt den Forward-Return offener M15-Zustaende nach. + + ⚠ Gewertet wird die LESART, nicht der Status: `hit=1`, wenn der Kurs in + die Richtung lief, die die Karte am Level angezeigt hat. Zustaende ohne + gerichtete Lesart (Muenzwurf-Zone 45–55 %) bekommen keine Richtung und + zaehlen in der Quote nicht mit. + ⚠ `candles_m1.time` ist ROHE Broker-Zeit — Offset noetig (die im Projekt + mehrfach dokumentierte Falle).""" + now = time.time() + if now - getattr(self, "_m15_eval_ts", 0.0) < 300: + return + self._m15_eval_ts = now + try: + import sqlite3 + off = self.trader._broker_offset_s() + con = sqlite3.connect(HISTORY_DB_FILE, timeout=5.0) + con.row_factory = sqlite3.Row + offen = con.execute( + "SELECT id, ts, lesart, price, atr, ret30, ret60 FROM m15_states " + "WHERE done IS NULL OR done = 0").fetchall() + for r in offen: + if r["lesart"] not in ("auf", "ab") or not r["atr"] or r["atr"] <= 0: + con.execute("UPDATE m15_states SET done=1 WHERE id=?", (r["id"],)) + continue + d = 1 if r["lesart"] == "auf" else -1 + sets, args = [], [] + for feld, minuten in (("ret30", 30), ("ret60", 60)): + if r[feld] is not None: + continue + ziel = r["ts"] + minuten * 60 + if now < ziel: + continue + row = con.execute( + "SELECT c FROM candles_m1 WHERE time <= ? AND time > ? " + "ORDER BY time DESC LIMIT 1", + (ziel + off, ziel + off - 900)).fetchone() + if row: + v = (row["c"] - r["price"]) * d / r["atr"] + sets.append(f"{feld}=?"); args.append(round(v, 4)) + sets.append(f"hit{feld[3:]}=?"); args.append(1 if v > 0 else 0) + if now >= r["ts"] + 60 * 60 and (r["ret60"] is not None + or any("ret60" in x for x in sets)): + sets.append("done=?"); args.append(1) + elif now >= r["ts"] + 6 * 3600: + sets.append("done=?"); args.append(1) # keine Bars mehr zu erwarten + if sets: + args.append(r["id"]) + con.execute(f"UPDATE m15_states SET {', '.join(sets)} WHERE id=?", args) + con.commit(); con.close() + except Exception as e: + log.debug(f"m15_states auswerten: {e}") + + def _evaluate_rec_outcomes(self): + """Traegt fuer offene Empfehlungs-Episoden den Forward-Return nach. + + ⚠ `candles_m1.time` ist ROHE Broker-Zeit (UTC+3), `rec_outcomes.ts` ist + lokale Epoch — der Offset muss drauf (dieselbe Falle wie bei + `_evaluate_pbreak_predictions`). Genommen wird der letzte M1-Close bis + zum Zielzeitpunkt; fehlen Bars (Wochenende, Broker-Ausfall), bleibt die + Zeile offen statt falsch bewertet zu werden. + + `hit` = 1, wenn der Kurs in Signalrichtung lief. Die Quote ist gegen 50 % + zu lesen, nicht gegen 0.""" + now = time.time() + if now - getattr(self, "_rec_eval_ts", 0.0) < 300: + return + self._rec_eval_ts = now + try: + import sqlite3 + off = self.trader._broker_offset_s() + con = sqlite3.connect(HISTORY_DB_FILE, timeout=5.0) + con.row_factory = sqlite3.Row + offen = con.execute( + "SELECT id, ts, signal, price, atr, ret30, ret60 FROM rec_outcomes " + "WHERE done IS NULL OR done = 0").fetchall() + for r in offen: + d = 1 if r["signal"] == "LONG" else -1 + if not r["atr"] or r["atr"] <= 0: + con.execute("UPDATE rec_outcomes SET done=1 WHERE id=?", (r["id"],)) + continue + werte = {} + for feld, minuten in (("ret30", 30), ("ret60", 60)): + if r[feld] is not None: + werte[feld] = r[feld] + continue + ziel = r["ts"] + minuten * 60 + if now < ziel: + continue # noch nicht faellig + row = con.execute( + "SELECT c FROM candles_m1 WHERE time <= ? AND time > ? " + "ORDER BY time DESC LIMIT 1", + (ziel + off, ziel + off - 900)).fetchone() + if row: + werte[feld] = (row["c"] - r["price"]) * d / r["atr"] + sets, args = [], [] + for feld in ("ret30", "ret60"): + if feld in werte and r[feld] is None: + sets.append(f"{feld}=?"); args.append(round(werte[feld], 4)) + sets.append(f"hit{feld[3:]}=?"); args.append(1 if werte[feld] > 0 else 0) + faellig60 = now >= r["ts"] + 60 * 60 + if faellig60 and ("ret60" in werte or r["ret60"] is not None): + sets.append("done=?"); args.append(1) + elif now >= r["ts"] + 6 * 3600: + # Nach 6 h keine Bars mehr zu erwarten (Wochenende/Ausfall) → + # schliessen, damit die Zeile die Auswertung nicht ewig belegt. + sets.append("done=?"); args.append(1) + if sets: + args.append(r["id"]) + con.execute(f"UPDATE rec_outcomes SET {', '.join(sets)} WHERE id=?", + args) + con.commit(); con.close() + except Exception as e: + log.debug(f"rec_outcomes auswerten: {e}") + + def squeeze_monitor(self) -> dict: + """Auto-Squeeze-Monitor (B4) für das Dashboard: (1) LIVE-Trades (echt, DB — + das Gesamtsystem inkl. realer Exits) und (2) die MECHANISCHE Neuberechnung + der Squeeze-Regel auf ~30 Tagen M5 (Entry-Edge ISOLIERT, Backtest-Exit) gegen + die Erwartung (ØR +0,14…+0,23 · PF>1). Cache TTL 60 s (M5-Fetch+Scan).""" + import sqlite3 + now = time.time() + if self._sqmon_cache and now - self._sqmon_cache_ts < 60: + return self._sqmon_cache + exp = {"or_lo": 0.14, "or_hi": 0.23} + + # ── 1) Live-Trades (echt) — SQUEEZE_* aus der DB ────────────────────── + live = {"n": 0} + try: + con = sqlite3.connect(HISTORY_DB_FILE); con.row_factory = sqlite3.Row + rows = [r for r in con.execute( + "SELECT direction, entry_price, exit_price, sl_at_entry, pnl, entry_time " + "FROM trades WHERE setup LIKE 'SQUEEZE%' AND exit_time IS NOT NULL")] + con.close() + pnls = [r["pnl"] for r in rows if r["pnl"] is not None] + if pnls: + n = len(pnls); wins = [p for p in pnls if p > 0]; losses = [p for p in pnls if p < 0] + Rs = [] + for r in rows: # ØR-Näherung via SL-Distanz (SL≈2×ATR) + d = 1 if r["direction"] in ("BUY", "LONG") else -1 + sld = abs((r["entry_price"] or 0) - (r["sl_at_entry"] or 0)) + if sld > 0 and r["exit_price"] and r["entry_price"]: + Rs.append(2.0 * (r["exit_price"] - r["entry_price"]) * d / sld) + day, night = [], [] + for r in rows: + if r["pnl"] is None: continue + h = (datetime.fromtimestamp(r["entry_time"], _BERLIN).hour + if r["entry_time"] else 12) + (night if h in _SQUEEZE_NIGHT else day).append(r["pnl"]) + live = { + "n": n, "wr": round(100 * len(wins) / n), + "avg_win": round(sum(wins) / len(wins), 2) if wins else 0.0, + "avg_loss": round(sum(losses) / len(losses), 2) if losses else 0.0, + "ratio": (round((sum(wins) / len(wins)) / abs(sum(losses) / len(losses)), 2) + if wins and losses else None), + "sum_eur": round(sum(pnls), 2), + "oR": round(sum(Rs) / len(Rs), 3) if Rs else None, + "day": {"n": len(day), "sum": round(sum(day), 2)}, + "night": {"n": len(night), "sum": round(sum(night), 2)}, + } + except Exception as e: + live = {"n": 0, "error": str(e)[:100]} + + # ── 2) Mechanik (B4) — Squeeze-Regel neu gerechnet, Entry-Edge isoliert ── + mech = {"error": "keine M5-Daten"} + try: + m5 = self._fetch_m5_monitor() + if m5 and len(m5["C"]) >= 300: + # ⚠ Frueher: `import backtest_breakout_squeeze as BQ` PLUS + # `BQ._N = ...; BQ._K = ...`. Damit haing LIVE-Code an einem + # Backtest-Skript, und die Live-Parameter wurden ueber MUTIERTE + # MODUL-GLOBALE eingeschleust (prozessweit wirksam). Jetzt liegt + # die Mechanik in `core/` und die Parameter stehen in der + # Signatur. Verhalten unveraendert (bitgenau geprueft, + # `tests/test_squeeze_scan.py`). + from core.squeeze_scan import atr_series, scan + from core.wave_rec import _SQ_N, _SQ_MULT, _SQ_K + H, L, C, SP = m5["H"], m5["L"], m5["C"], m5["SP"] + A = atr_series(H, L, C) + sq, _ = scan(H, L, C, A, SP, 1.0, 0, len(C), _SQ_MULT, + box_n=_SQ_N, k=_SQ_K) + n = len(sq) + if n: + w = sum(1 for x in sq if x > 0); s = sum(sq) + up = sum(x for x in sq if x > 0); dn = -sum(x for x in sq if x < 0) + mech = {"n": n, "wr": round(100 * w / n), "oR": round(s / n, 3), + "pf": round(up / dn, 2) if dn > 0 else 99.9, "sum": round(s)} + else: + mech = {"n": 0} + except Exception as e: + mech = {"error": str(e)[:100]} + + # ── 3) Verdict aus der Mechanik (isoliert = apples-to-apples zum Backtest) ── + verdict = "watch" + if mech.get("n", 0) >= 6 and mech.get("oR") is not None: + if mech["oR"] > 0 and mech.get("pf", 0) > 1: + verdict = "ontrack" if mech["oR"] >= exp["or_lo"] else "weak" + else: + verdict = "drift" + + res = {"live": live, "mech": mech, "expect": exp, "verdict": verdict, + "params": {"box": None}} + try: + from core.wave_rec import _SQ_N, _SQ_MULT, _SQ_K + res["params"] = {"box": _SQ_N, "k": _SQ_K, "mult": _SQ_MULT} + except Exception: + pass + self._sqmon_cache = res; self._sqmon_cache_ts = now + return res + + def _compact_stats(self, period: str) -> dict: + """Kompakte Kennzahlen für die Live-Statistik-Kachel (WR, PF, Ø-Gewinn/ + -Verlust, Verhältnis, Trades, PnL) — aus `stats_overview`.""" + try: + ov = self.history.stats_overview(period) or {} + except Exception: + return {} + if ov.get("error"): + return {} + aw = ov.get("avg_win", 0.0) or 0.0 + al = ov.get("avg_loss", 0.0) or 0.0 + pf = ov.get("profit_factor") + return { + "n": ov.get("n_trades", 0) or 0, + "wr": round(ov.get("winrate", 0.0) or 0.0), + "pf": round(pf, 2) if pf is not None else None, + "avg_win": round(aw, 2), + "avg_loss": round(al, 2), + "ratio": round(aw / abs(al), 2) if al < 0 else None, + "pnl": round(ov.get("total_pnl", 0.0) or 0.0, 2), + } + + def snapshot(self) -> dict: + try: + # 5 → 10 (User-Wunsch 2026-08-04). ⚠ Auch das `slice(0, 10)` in + # `app.js` mitziehen — es begrenzt unabhängig davon nochmal. + last_trades = self.history.last_closed_trades(10) + except Exception as e: + log.warning(f"last_closed_trades: {e}") + last_trades = [] + market = self.data.snapshot() + wave_signal = self.wave.signal() + wave_snap = self.wave.snapshot() + elliott = self.elliott.snapshot() + tu = self.tu.snapshot() + agent = self.agent.snapshot() + position = self.trader.snapshot() + # Tages-P/L: realisiert (heute geschlossene Trades) + offene Position + try: + day_realized = float((self.history.stats_overview("today") or {}) + .get("total_pnl") or 0.0) + except Exception as e: + log.warning(f"day stats: {e}"); day_realized = 0.0 + open_pnl = float(position.get("pnl") or 0.0) + day_pl = {"realized": round(day_realized, 2), "open": round(open_pnl, 2), + "total": round(day_realized + open_pnl, 2)} + # Circuit Breaker — Status für die UI (Tagesverlust-Stopp) + _bal = float(self.data.balance or 0.0) + _cb_limit_eur = round(self._cb_limit_pct / 100.0 * _bal, 2) if self._cb_limit_pct > 0 else None + circuit_breaker = { + "enabled": self._cb_limit_pct > 0, "limit_pct": self._cb_limit_pct, + "limit_eur": _cb_limit_eur, "day_total": day_pl["total"], + "tripped": self._circuit_halted(), + } + sr_close_hint = self._sr_close_hint(market, position, wave_snap) + stop_approach = self._stop_approach_hint(market, position, wave_snap) + _cone = _cone_build( + (market or {}).get("bid"), + float(((wave_snap or {}).get("pb_feats") or {}).get("atr") or 0.0), + skalen=getattr(self, "_cone_skalen", None)) + # M15-Setup EINMAL rechnen (nicht im Dict), damit die Selbstmessung + # daran haengen kann — ein zweiter Aufruf waere Arbeit fuer dieselben + # Zahlen (der Konsens-Pfeil-Fehler). + # ⚠ MUSS hier stehen, nicht in `_run_analysis`: dort gibt es weder + # `market` noch `wave_snap`. Der erste Entwurf tat genau das und + # erzeugte einen NameError im 1-Sekunden-Pfad. `py_compile` sieht das + # NICHT, `ruff F821` schon (4 Befunde, darunter `_m15` selbst) — genau + # die Fehlerklasse, fuer die der Linter am 07.08. eingebaut wurde. + _m15 = self._m15_setup(market, wave_snap, _cone, + self._alignment_cached(), wave_signal) + self._log_m15_state(_m15, market) + self._check_m15_alert(_m15) # Telegram bei Flanke auf GRUEN + self._log_cone_check(_cone) + # Für den KI-Copiloten festhalten (2026-08-02) — er bekam bis dahin nur + # das Wellen-Signal und sah ausgerechnet die beiden kalibrierten Größen + # des Projekts nicht. Hier nur ZUWEISEN, nicht neu rechnen. + self._last_sr_close_hint = sr_close_hint + self._last_stop_approach = stop_approach + self._last_cone = _cone + # einmal holen — Verdict-Stimme UND Snapshot-Feld nutzen dasselbe Objekt + _pat_snap = self.patterns.snapshot() + # ── Marktstatus + Hyperliquid-Kurs (User 2026-08-01) ───────────────── + # ⚠ ZWEI Sackgassen bei der Markt-zu-Erkennung, beide verworfen: + # (1) `tick_local_ts` ist die lokale ABHOLZEIT — erneuert sich bei jedem + # Abruf, auch wenn der Broker denselben alten Tick liefert. + # (2) `tick_server_ts` minus `trader._broker_offset_s()` ist ZIRKULÄR: die + # Offset-Funktion leitet den Zeitzonen-Versatz selbst aus dem letzten + # Tick ab (`off = tick.time - now`). Bei geschlossenem Markt misst sie + # damit die Tick-Veraltung statt der Zeitzone (real −7,41 h statt +3 h) + # und das Alter hebt sich rechnerisch auf (Ergebnis: −347 s). + # Verwendet wird daher der direkte Beleg: **wann hat sich der Kurs zuletzt + # BEWEGT?** Kein Zeitzonen-, Kalender- oder Feiertagswissen nötig. + # ⚠ NACHTRAG 2026-08-01: Punkt (2) galt nur, solange `_broker_offset_s()` + # den Offset aus einem MÖGLICHERWEISE STALEN Tick ableitete. Seit dem Fix + # dort (Offset nur aus frischem Tick, sonst letzter gültiger Wert) ist die + # Rechnung nicht mehr zirkulär — deshalb dient das echte Tick-Alter jetzt + # als STARTWERT der Stillstands-Uhr. Ohne ihn begann die Uhr bei jedem + # Neustart bei null und der Bot brauchte 180 s, um ein längst geschlossenes + # Wochenende zu bemerken (real: Reload direkt nach `restart_server.bat` + # zeigte „KURS" statt „KURS · ZU" und den eingefrorenen Broker-G/V). + # Laufender Betrieb bleibt bewusst bewegungs-basiert: das erkennt auch + # einen eingefrorenen Feed, dessen Zeitstempel weiterlaufen. + _now = time.time() + try: + _b = (market or {}).get("bid") + if _b is not None: + if self._last_bid is None: + self._last_bid = float(_b) + _srv = (market or {}).get("tick_server_ts") + _age0 = 0.0 + if _srv: + _a = _now + self.trader._broker_offset_s() - float(_srv) + if 0.0 <= _a <= 14 * 86400: # plausibel: bis ~2 Wochen Stillstand + _age0 = _a + self._last_bid_move_ts = _now - _age0 + elif abs(float(_b) - self._last_bid) > 1e-9: + self._last_bid = float(_b) + self._last_bid_move_ts = _now + except Exception: + pass + _tick_age = (round(_now - self._last_bid_move_ts) + if self._last_bid_move_ts else None) + _mkt_closed = bool(_tick_age is not None and _tick_age > _TICK_STALE_S) + + # HL-Kurs IMMER mitliefern (User: „blende den Hyperliquid-Kurs NEBEN den + # Pepperstone-Kurs ein") — nicht nur bei geschlossenem Markt. So sieht man + # laufend die Abweichung; am Wochenende ist es der einzige lebende Kurs. + _hl_live = None + try: + _w = self.hl_walls.walls() + if _w and _w.get("mid_mt5") is not None: + _hl_live = { + "mid_mt5": _w["mid_mt5"], "hl_mid": _w.get("hl_mid"), + "basis": _w.get("basis"), "age_s": _w.get("age_s"), + # Basis auf dem letzten Stand mit lebendem Broker eingefroren + # (Fix 2026-08-02, s. `core/hl_walls.py`) — ohne das war die + # Umrechnung bei geschlossenem Markt zirkulär. + "basis_stale": _w.get("basis_stale"), + "bid": _w.get("bid"), "ask": _w.get("ask"), + "bid_sz": _w.get("bid_sz"), "ask_sz": _w.get("ask_sz"), + "imb5": _w.get("imb5"), + "trend": (_w.get("trend") or {}).get("dir"), + "slope_per_h": (_w.get("trend") or {}).get("slope_per_h"), + "vs_mt5": (round(_w["mid_mt5"] - float(market["bid"]), 3) + if (market or {}).get("bid") else None), + } + except Exception: + pass + + # ── G/V mit HL-Kurs, wenn der Broker eingefroren ist (User 2026-08-01) ── + # Bei geschlossenem Markt steht `position.pnl` still. Die Schätzung setzt den + # HL-Kurs (basis-korrigiert, also MT5-Niveau) in `trader.live_pnl()` ein — die + # BEREITS VORHANDENE, korrekte Umrechnung über `tick_size`/`tick_value` inkl. + # Swap und Währungsumrechnung. + # ⚠ Erster Versuch leitete den EUR-je-Punkt-Faktor aus `pnl / Kursbewegung` + # ab — das ergab 155 statt ~86 EUR je 1,00 und Lot, weil `pnl` den über das + # Wochenende aufgelaufenen SWAP enthält. Eine Kennzahl aus einer anderen + # Kennzahl zurückzurechnen ist hier schlicht falsch, wenn zusätzliche Terme + # drinstecken. + # ⚠ Bleibt eine SCHÄTZUNG: HL ist ein anderer Kontrakt (Basis herausgerechnet, + # aber nicht exakt), und der Spread am Montags-Open ist unbekannt. + _pnl_hl = None + try: + if _mkt_closed and _hl_live and position and position.get("ticket"): + _mid = float(_hl_live["mid_mt5"]) + _sp = float((market or {}).get("spread") or 0.0) + _v = self.trader.live_pnl(_mid - _sp / 2.0, _mid + _sp / 2.0) + if _v is not None: + _pnl_hl = round(_v, 2) + except Exception: + pass + + snap = { + "ts": time.time(), + "connected": self._connected, + "running": self._running, + "error": self._error, + "symbol": self.data.symbol, + "wave_tf": self._wave_tf_label, + "min_conf": _MIN_CONF_DISPLAY, + "auth_required": self.require_token, + "market": market, + # Dashboard-S/R = GLEICHE Quelle wie die Chart-Linien (`_draw_levels` = + # rohe M5-Pivots), damit Kachel und Chart nicht mehr auseinanderlaufen. + # `_sr_levels` (Misch-Set) bleibt intern für Strategie/Konfidenz. + "sr_levels": self._dash_sr(market), + "session": session_state(), + "position": position, + "day_pl": day_pl, + "circuit_breaker": circuit_breaker, + "emergency_loss": self._emergency_loss, + "emergency_remember": self._emergency_remember, + "emergency_pct": self._emergency_pct, + "emergency_margin_pct": self._emergency_margin_pct, + "wht_pct": self._wht_pct, + "takeprofit": self._takeprofit, + "takeprofit_remember": self._takeprofit_remember, + "trail": self.trail.snapshot(), + "wave_signal": wave_signal, + "wave": wave_snap, + "elliott": elliott, + "tu": tu, + "agent": agent, + # ⚠ `verdict` ist am 2026-08-08 aus dem Snapshot GEFLOGEN (die + # Gesamtempfehlung wurde entfernt). `_verdict()` selbst LEBT WEITER — + # es speist 1×/min die Telemetrie `verdict_votes` (User-Entscheidung: + # weiter sammeln) und den optionalen MQL5-Konsens-Pfeil. Es hier im + # 1-s-Pfad zu rechnen waere Arbeit fuer eine Anzeige, die es nicht + # mehr gibt — genau der Fehler, der beim Konsens-Pfeil schon einmal + # dokumentiert wurde. + "daily_levels": self.daily.snapshot(), + "gaps": self.gaps.snapshot(), + "structure": self.structure.snapshot(), + "patterns": _pat_snap, + # Kerzen-Anatomie (M5) — reine Anzeige, KEIN Verdict-Gewicht (gemessen: + # Lehrbuch-Deutung invertiert, Handelssignal durchgefallen). + "candle_anat": self.candle_anat.snapshot(), + # Wahrscheinlichkeits-Kegel (Streuung, NICHT Richtung) — out-of-sample + # kalibriert (`analyze_cone.py`). Reine Anzeige. + "cone": _cone, + "stats_compact": {"week": self._compact_stats("week"), + "all": self._compact_stats("all")}, + "close_alert_count": self._close_alert_count, + "news_storm": self.news.storm_state(), + # News-Sentiment-Score (−1..+1) für den Konflikt-Chip (2026-07-24): + # steht der Headline-Flow stark gegen Signal/Position, zeigt das + # Frontend eine Warnung (reiner Kontext — Prädiktivität unbelegt, + # wird via recommendations.news_score gesammelt und später gemessen). + "news_sentiment": (lambda sc: round(float(sc), 2) if sc is not None else None)( + (self.news.sentiment or {}).get("score")), + "alignment": self._alignment_cached(), + # `block_mix` entfallen (2026-08-08): die Zeile `#vd-blocks` gehoerte + # zur Gesamtempfehlung. `_block_mix_cached()` bleibt als Methode + # stehen — die Abfrage ist fuer eine kuenftige Anzeige nuetzlich und + # kostet ungenutzt nichts (sie laeuft nur beim Aufruf). + "hl_ctx": getattr(self, "_hl_ctx", None), + "rec_track": self._rec_track_cached(), + # Kennzahlen der MANUELLEN Trades für die gleichnamige Karte + # (2026-08-04). Rein deskriptiv; ~120 s gecacht, damit der heiße + # Snapshot-Pfad nicht je Abruf die DB aggregiert. + "manual_stats": self._manual_stats_cached(), + # Manuell vorgegebene Einsatz-Margin (0 = automatisch, s. set_manual_margin) + "manual_margin": getattr(self, "_manual_margin", 0.0), + # Einsatz als % der freien Margin (`margin_buffer_pct`). Greift, solange + # kein fester Betrag gesetzt ist — der hat Vorrang (s. set_margin_pct). + "margin_pct": round(_cfg_margin_buffer() * 100.0, 1), + # Live-Handelskosten relativ zur Vola (2026-07-24): Spread/ATR(M5). + # Gemessen (`backtest_realcosts.py`): Ø 0,265×ATR, Nacht 0,32–0,50 = + # Kostenfalle, 15–17 Uhr ~0,13 = günstig. Reine Anzeige (Chip) — die + # informierende Alternative zum abgeschalteten Dead-Hours-Gate. + "cost_ratio": (lambda b, a, atr: round((a - b) / max(atr, 0.06), 2) + if (b and a and atr) else None)( + (market or {}).get("bid"), (market or {}).get("ask"), + ((wave_snap or {}).get("pb_feats") or {}).get("atr")), + # M15-SETUP-BEREITSCHAFT (2026-08-08) — Ersatz fuer die entfernte + # Gesamtempfehlung. ⚠ KEINE Richtungsaussage: auf M15 traegt gemessen + # keine (Squeeze n=33/Nachbarn kippen, VWAP-Baender ohne Edge). + # Zeigt Bedingungen, nicht Prognosen. + "m15_setup": _m15, + # Selbstmessung der Karte + Ausfuehrungsqualitaet (2026-08-08). + "m15_track": self._m15_track_cached(), + "entry_gap": self._entry_gap_cached(), + "sr_close_hint": sr_close_hint, + "stop_approach": stop_approach, + "auto_sr_close": self._auto_sr_close, + "sr_close_min_gain": self._sr_close_min_gain, + "sr_close_pbreak": self._sr_close_pbreak, + "sr_close_count": self._sr_close_count, + # Schwelle der Close-Benachrichtigung — sichtbar, damit belegbar ist, + # dass sie im LAUFENDEN Prozess angekommen ist. „Erfolg melden, wo + # nichts geprueft wurde" ist im Projekt bereits viermal passiert. + "close_notify_min": self._close_notify_min, + "auto_squeeze": self._auto_squeeze, + # Nacht-Fenster mitliefern: nach `restart_server.bat` ist ein NEUES + # Snapshot-Feld der einzige verlässliche Beleg, dass wirklich der neue + # Python-Code läuft (der Batch hat schon 2× still nicht neu gestartet). + "squeeze_night_hours": list(self._squeeze_night_hours) + if self._squeeze_skip_night else [], + # Einstieg über ruhende Stop-Orders (2026-08-05) + die aktuell gesetzten + # Trigger — macht im Snapshot prüfbar, ob wirklich Orders liegen. + "squeeze_pending": self._squeeze_pending, + "pending_levels": ( + sorted(round(v, 3) for v in self._pending_levels.values()) + if self._pending_levels else []), + "pending_quelle": self._pending_quelle, + "squeeze_max_chase_atr": self._squeeze_max_chase, + "squeeze_entry_count": self._squeeze_entry_count, + "adverse15_atr": self._adverse15_atr, + "adverse15_count": self._adverse15_count, + # Auto-Flip-Close (gemessen negativ, auf User-Wunsch gebaut) — für den + # B4-Monitor sichtbar machen, wie oft er zuschlägt. + "flip_close": self._flip_close, + "flip_close_min_r": self._flip_close_min_r, + "flip_close_count": self._flip_close_count, + "last_trades": last_trades, + # Wirtschaftskalender: {blackout, next, enabled, n} — Anzeige + Grund, + # warum der Bot gerade keine autonome Order eröffnet. + "calendar": self.calendar.snapshot(), + # ── MARKT ZU + HYPERLIQUID-ERSATZKURS (User-Wunsch 2026-08-01) ────── + # Pepperstone handelt WTI nicht am Wochenende; der Snapshot zeigt dann + # den EINGEFRORENEN Freitags-Kurs, ohne dass man es sieht (der grüne + # Punkt im Header ist die VERBINDUNG, nicht der Marktstatus). Hyperliquid + # (`xyz:CL`) läuft dagegen 24/7 weiter. + # `market_closed` wird NICHT aus dem Kalender abgeleitet, sondern aus dem + # ALTER DES LETZTEN TICKS — das deckt Wochenende, Feiertage und + # Broker-Ausfälle mit derselben Regel ab, ohne Kalenderpflege. + # `hl_live.mid_mt5` = HL-Mid + gemessene Basis (HL notiert ~0,45 $ unter + # dem CFD, anderer Kontrakt) → direkt mit dem MT5-Kurs vergleichbar. + "market_closed": _mkt_closed, + "tick_age_s": _tick_age, + "hl_live": _hl_live, + # G/V-Schaetzung auf HL-Basis, nur bei geschlossenem Markt (sonst None) + "pnl_hl": _pnl_hl, + } + # ── Entry-Checkliste je Richtung (User-Vorgabe 2026-07-30: „blende einen + # Hinweis ein, wenn die Checkliste gegen meine Eröffnung ist"). Wird im + # Order-Dialog angezeigt; blockt NICHT. Nutzt nur bereits berechnete Teile + # → kein zusätzlicher MT5-/DB-Zugriff im Snapshot-Pfad. + try: + _parts = {"wave": wave_snap, "market": market, + "news_sentiment": snap.get("news_sentiment"), + "cost_ratio": snap.get("cost_ratio"), "day_pl": day_pl} + snap["checklist"] = {"long": self._entry_checklist("long", _parts), + "short": self._entry_checklist("short", _parts)} + except Exception as e: + log.debug(f"entry_checklist: {e}") + snap["checklist"] = None + return snap + + def _sr_close_hint(self, market, position, wave_snap) -> dict | None: + """S/R-Close-Empfehlung mit kalibriertem P(Durchbruch)-Modell (gemessen + `backtest_srclose_prob.py`, Echtkosten, 2 Hälften): Kurs AM gegenüberliegenden + Level → P(break) aus `_p_break` schätzen; ist P < `sr_close_pbreak` (Default + 0,60 = User-Regel „laufen lassen wenn >60 %"), prallt das Level wahrscheinlich + ab → Close schlägt Laufenlassen deutlich (+6000/+7600 R vs. reines Trailing). + Liefert IMMER die P(break)-Zahl (auch wenn ≥Schwelle → `close=False`) für die + Live-Anzeige.""" + try: + if not position.get("ticket"): + self._pb_state = None + return None + is_long = position.get("order_type") == 0 + bid = (market or {}).get("bid") + # Features FEST aus M5 (`wave.pb_feats`) — Modell wurde auf M5 trainiert; + # die Wave-TF-Werte (bis M30) verschoben die Skala → P falsch (Bugfix). + pf = (wave_snap or {}).get("pb_feats") or {} + atr = pf.get("atr") + mom6 = pf.get("mom6") + mom3 = pf.get("mom3") + ediff = pf.get("ema_diff") + entry = position.get("entry_price") or bid + # Level = EXAKT die gezeichnete Linie (`_draw_levels`, geclustert + + # Hysterese) — für LONG die nächste Resistance, für SHORT die nächste + # Support. So referenziert die Close-Meldung dasselbe Level, das im + # Chart/Dashboard steht (vorher rohe pb_levels → Meldung ≠ Linie, + # User-Feedback 2026-07-15). P(break) auf M5-Features unverändert. + dl = self._draw_levels() + if is_long: + level = (dl.get("res") or [None])[0] + else: + level = (dl.get("sup") or [None])[0] + if not (bid and atr and level) or mom6 is None or mom3 is None or ediff is None: + self._pb_state = None + return None + d = 1 if is_long else -1 + gap = (level - bid) * d # Abstand zum Level (in Preis) + if gap > 0.6 * atr: # weit weg → gar kein Hinweis + self._pb_state = None + return None + wt = 1.0 if ediff * d > 0 else 0.0 + dist = abs(level - entry) / atr + p_raw = _p_break(mom6 * d, mom3 * d, wt, dist) + # ── Anti-Flattern (gemessen `analyze_pbreak_flicker.py`) ───────────── + # (1) P über ~60 s glätten (Zeit-EMA) — mom3 der laufenden M5-Kerze ließ + # P sonst 7–15 Pp je Bar springen. Reset bei Level-Wechsel. + # (2) Hysterese ±5 Pp um die Schwelle: close↔laufen wechselt erst, wenn + # das geglättete P die Schwelle klar durchstößt (kein Flip-Flop). + import math as _m + now = time.time() + st = self._pb_state + if not st or abs(st["level"] - level) > 1e-9: + st = {"level": level, "p": p_raw, "ts": now, "zone": None} + else: + a = 1.0 - _m.exp(-max(0.0, now - st["ts"]) / 60.0) + st["p"] += a * (p_raw - st["p"]) + st["ts"] = now + p_s = st["p"] + thr = self._sr_close_pbreak + zone = st.get("zone") + if zone is None: + zone = "close" if p_s < thr else "run" + elif zone == "close" and p_s >= thr + 0.05: + zone = "run" + elif zone == "run" and p_s < thr - 0.05: + zone = "close" + st["zone"] = zone + at_level = gap <= 0.15 * atr # Auslöse-Fenster (wie gemessen: Touch) + # Bruch-BESTÄTIGUNGS-Preis (Fix 2026-07-22, User-Frage „genauer berechnen"): + # dieselbe Definition wie beim TRAINING des Modells (`_BRK_ATR=0,5`, + # `backtest_srclose_prob.py`/`analyze_srwaves.py`) — ein Kurs, der die reine + # Levelzahl nur um wenige Cent überschreitet (Docht/Rauschen), zählt NICHT + # als „Durchbruch"; erst level±0,5×ATR gilt als bestätigt. Reale Analyse + # 2026-07-22: Trade bei Level 85,943 geschlossen, Kurs wischte kurz auf + # 85,994 (< Bestätigung 86,017), fiel danach zurück → laut Modell-eigener + # Definition eine ABLEHNUNG, kein Bruch — die Vorhersage war korrekt. + confirm = round(level + d * 0.5 * atr, 3) # Richtung Durchbruch + reject = round(level - d * 0.5 * atr, 3) # Richtung Abprall (Gegenbestätigung) + # Prognose-Logging (User-Wunsch 2026-07-23): EINMAL je frischem Touch + # (at_level False→True) die aktuelle Abprall/Durchbruch-Vorhersage + # protokollieren. Auswertung separat gegen `candles_m1` + # (`_evaluate_pbreak_predictions`, periodisch im Trend-Loop) — dieselbe + # confirm/reject-Definition wie beim Modell-Training, also fair verglichen. + if at_level and not st.get("was_at_level"): + try: + self.history.log_pbreak_prediction( + symbol=self.data.symbol or "", + direction=("LONG" if is_long else "SHORT"), + level=level, p_break=round(100 * p_s, 1), + predicted=("bounce" if zone == "close" else "break"), + price_at_pred=bid, confirm_price=confirm, + reject_price=reject, atr=atr) + except Exception as le: + log.debug(f"pbreak_prediction log: {le}") + st["was_at_level"] = at_level + self._pb_state = st + return {"level": round(level, 3), "p_break": round(100 * p_s), + "mom": round(mom3 * d, 2), "at_level": at_level, + "gap_atr": round(gap / atr, 2), "confirm": confirm, + # Auto-Close NUR exakt am Level; die Anzeige (0,15–0,6×ATR davor) + # ist reine Vorwarnung mit geglättetem P(break) + "close": at_level and zone == "close"} + except Exception as e: + log.debug(f"sr_close_hint: {e}") + return None + + def _vola_regime(self, atr15) -> dict | None: + """ΔATR = aktueller ATR_M15 / Median der letzten ~5 h. + + ⚠ KEIN zweiter MT5-Fetch: der ATR wird aus dem ohnehin laufenden + Turn-Fetch gesammelt (`wave.tf_atr`), hier nur gepuffert. Ein extra + Abruf fuer Zahlen, die schon da sind, war beim Konsens-Pfeil der Fehler. + + ⚠⚠ BELEGLAGE, ehrlich: gemessen (`analyze_slippage_vola.py`, 337 + SL-Closes) steigt die Exit-Slippage MONOTON ueber alle vier + ΔATR-Quartile — 0,0200 → 0,0390 → 0,0645 → 0,0839 $, Faktor 4,19×. + Die Slip-RATE bleibt dabei flach (20–23 %), die GROESSE waechst + (0,094 → 0,396 $): es rutscht nicht oefter, sondern weiter. + ABER: der Bootstrap auf die DIFFERENZ oben−unten ergibt + [−0,012 … +0,183] — **enthaelt die Null**. Bei n≈85 je Quartil ist der + Unterschied NICHT statistisch belegt. Deshalb reine ANZEIGE, kein Gate, + und die Beschriftung sagt das auch. + """ + try: + if not atr15 or atr15 <= 0: + return None + buf = getattr(self, "_atr15_buf", None) + if buf is None: + from collections import deque + buf = self._atr15_buf = deque(maxlen=1200) # ~5 h bei 15-s-Takt + buf.append(float(atr15)) + if len(buf) < 200: + return {"datr": None, "stufe": None} + import statistics as _st + med = _st.median(buf) + if med <= 0: + return {"datr": None, "stufe": None} + d = atr15 / med + return {"datr": round(d, 2), + "stufe": "hoch" if d >= 1.26 else "normal" if d >= 0.91 else "ruhig"} + except Exception: + return None + + def _system_status(self) -> list[str]: + """Klartext-Zeilen zum Systemzustand für den Tagesreport. + + ⚠⚠ WARUM ES DAS GIBT (2026-08-11). Das Projekt hat sieben Wächter, und + sie funktionieren — was fehlte, war der letzte Meter. Belegt an den + Marker-Dateien: `cfg_auto_sr_close` trägt den Zeitstempel **06.08. 18:10** + und wurde danach NIE wieder gemeldet. Fünf Tage lang stand ein Exit aus, + der am 11.08. mit **+350,81 € über 43 Trades** gemessen wurde. + `cfg_auto_squeeze` trägt sogar 02.08. — als der Schalter am 11.08. umgelegt + wurde, kam deshalb GAR KEINE Meldung. + ⚠ Die Ursache ist ein Kategorienfehler: die Marker-Logik behandelt einen + **Dauerzustand** wie ein **Einmal-Ereignis**. Deshalb die saubere Trennung + — **Popup = „etwas Neues ist passiert", Tagesreport = „so steht das System + gerade"**. Der Report kommt nachweislich jeden Tag an (Telegram-Log + lückenlos `ok`), der Popup verstummt nach dem ersten Mal. + ⚠ Reine Anzeige: nichts wird hier geschaltet oder entschieden. + """ + z: list[str] = [] + try: + an = lambda b: "AN" if b else "aus" # noqa: E731 + z.append(f"Automatik: BRK {an(self._auto_squeeze)} · " + f"S/R-Close {an(self._auto_sr_close)} · " + f"Flip {an(getattr(self, '_flip_close', False))}") + except Exception as e: + z.append(f"Automatik: nicht lesbar ({e})") + try: + puls = self.history.telemetrie_puls() if self.history else [] + z.extend(f"⚠ Puls: {p}" for p in puls) + if not puls: + z.append("Puls: alle Telemetrie-Tabellen schreiben") + except Exception as e: + z.append(f"⚠ Puls: Prüfung fehlgeschlagen ({e})") + # ── Config-Drift + fällige Messungen aus dem Reminder ──────────────── + # ⚠ LAZY und GEKAPSELT: `measurement_reminder` ist ein Auswertungs-Skript + # und gehört NICHT in den Live-Abhängigkeitsgraphen (derselbe Fehler wie + # der `backtest_breakout_squeeze`-Import). Der Import passiert deshalb + # erst hier, 1×/Tag, und ein Fehlschlag wird SICHTBAR gemeldet statt + # still verschluckt — ein fehlender Block sähe sonst aus wie „alles gut". + try: + import measurement_reminder as _mr + for d, cur, val in _mr.config_drift(): + z.append(f"⚠ Config: {d['key']} steht auf '{cur}' " + f"(validiert: '{val}')") + faellig = [] + for c in _mr.CHECKS: + try: + have = c["have"]() + if have >= c["need"]: + faellig.append(f"{c['key']} FÄLLIG") + elif have > 0: + faellig.append(f"{c['key']} {have:.0f}/{c['need']}") + except Exception: + continue + if faellig: + z.append("Messungen: " + " · ".join(faellig[:6])) + except Exception as e: + z.append(f"⚠ Config-/Messungs-Prüfung nicht verfügbar ({e})") + return z + + def _check_close_notify(self): + """Telegram bei jedem geschlossenen Trade ab `close_notify_min_eur`. + + ⚠⚠ WARUM ES DAS GIBT (2026-08-11): der Kanal war nachweislich intakt + (Testnachricht zugestellt), aber ALLE NEUN sendenden Auslöser waren aus — + Auto-Squeeze, Auto-Signal, S/R-Close, Flip-Close, 15-Minuten-Regel, + Circuit-Breaker und die drei Notfall-Stop-Modi. Jede Abschaltung war für + sich begründet; zusammen ergaben sie eine Stille, die niemand beschlossen + hatte. Diese Meldung hängt deshalb bewusst an **keiner** Automatik: sie + feuert auch bei manuellem Close und bei Broker-SL. + ⚠ Der Trade-Abschluss-Push war früher in `history.log_trade_close` und wurde + als Spam entfernt (jeder Close, ohne Schwelle). Der Unterschied ist die + **Schwelle** — und sie steckt hier in der Engine, nicht in der DB-Schicht. + """ + p = self._close_notify_pending + if not p: + return + ticket, versuche = p + tr = self.history.trade_by_ticket(ticket) + # ⚠ `exit_time IS NULL` heisst „DB-Schreiber noch nicht durch", nicht + # „kein Trade". Der Flat-Zustand kommt aus `trader.snapshot()` und ist + # dem Schreiber gelegentlich einen Tick voraus. + if not tr or tr.get("exit_time") is None or tr.get("pnl") is None: + p[1] = versuche + 1 + if versuche >= 30: # ~30 s — dann war es kein Bot-Trade + self._close_notify_pending = None + return + self._close_notify_pending = None + pnl = float(tr["pnl"] or 0.0) + if abs(pnl) < self._close_notify_min: + return + tg = self.cfg["telegram"] + if tg.get("enabled", "false").strip().lower() != "true": + return + try: + dauer = "" + if tr.get("entry_time"): + mins = (int(tr["exit_time"]) - int(tr["entry_time"])) / 60.0 + dauer = (f"{mins/60:.1f} h" if mins >= 90 else f"{mins:.0f} min") + grund = {"sl": "Broker-SL", "manual": "manuell", + "sr_close": "S/R-Auto-Close", "emergency": "Notfall-Stop", + "timestop": "Time-Stop", "adverse15": "15-Minuten-Regel", + "flip_close": "Signal-Flip", + "circuit_breaker": "Circuit Breaker", + "squeeze_reverse": "Squeeze-Reverse"}.get( + tr.get("closed_by"), tr.get("closed_by") or "?") + icon = "🟢" if pnl > 0 else "🔴" + send_telegram( + f"{icon} Trade geschlossen {pnl:+.2f} €\n" + f"{tr.get('symbol') or ''} {tr.get('direction') or ''} " + f"{tr.get('lots')} Lots · {tr.get('entry_price')} → " + f"{tr.get('exit_price')}\n" + f"{grund} · {dauer} · Setup {tr.get('setup') or '—'}", + tg.get("bot_token", "").strip(), tg.get("chat_id", "").strip()) + log.info(f"📨 Close-Push #{ticket} {pnl:+.2f} € ({grund})") + except Exception as e: + log.debug(f"close_notify: {e}") + + def _m15_setup(self, market, wave_snap, cone, alignment, + wave_signal=None) -> dict | None: + """SETUP-BEREITSCHAFT auf M15 — der Ersatz für die entfernte Gesamtempfehlung. + + ⚠⚠ BEWUSST KEINE RICHTUNGS-EMPFEHLUNG. Gemessen (2026-08-08) trägt auf + M15 KEINE Richtungsquelle: der Squeeze fällt dort durch (n=33 in H1, + Nachbarn kippen), VWAP-Bänder ebenso (Zufallslinie hält gleich gut, alle + Konfidenzintervalle enthalten die Null), und 24 Signal-Eingriffe sind + insgesamt gescheitert. Was BELEGT ist, sind Bedingungen — nicht Prognosen. + Die Karte zeigt deshalb das Schema des Users (Bias → Zone → Reichweite → + Veto) und überlässt den Auslöser dem Menschen, dessen Auswahl-Vorteil + unabhängig belegt ist. + + Zusammengesetzt aus bereits berechneten Teilen — NEU gerechnet wird nur + das Kosten-Verhältnis auf M15 und P(break) für das nächste Level. Der + Konsens-Pfeil hat einmal ein zweites `_verdict()` gezogen; das ist die + Lehre daraus. + """ + try: + bid = (market or {}).get("bid"); ask = (market or {}).get("ask") + ws = wave_snap or {} + atr15 = (ws.get("tf_atr") or {}).get("M15") + out: dict = {"atr_m15": round(atr15, 4) if atr15 else None} + + # ── 1) VETO statt BIAS ──────────────────────────────────────── + # ⚠⚠ KORREKTUR 2026-08-08, nach dem Tagestest an 07.08.: der erste + # Entwurf machte aus „M30+H1 einig" eine RICHTUNG („Bias LONG") und + # erzeugte damit 13 LONG-Episoden an einem Tag, der 1,02 $ FIEL — + # Trefferquote 0 %, ØR −0,753. Der Fehler ist strukturell, nicht + # Pech: gemessen ist **„gegen den M30-Trend halbiert den Edge"**, + # also eine VERBOTS-Aussage. Daraus einen Richtungsgeber zu machen + # ist exakt der Fehler, wegen dem die Gesamtempfehlung entfernt + # wurde (Richtung dort 48–51 % Treffer = Münzwurf). + # Die nachlaufende EMA zeigt im Trendtag stur in die alte Richtung — + # dokumentiert seit `analyze_signal_live.py` (06.08.). + # Jetzt liefert das Feld nur noch, welche Richtung VERBOTEN ist. + h30 = ws.get("htf_trend"); h1 = ws.get("h1_trend") + verboten = None + if h30: + verboten = "SHORT" if h30 == "LONG" or h30 == 1 else "LONG" + out["bias"] = {"m30": h30, "h1": h1, + "verboten": verboten, + "einig": bool(h30 and h1 and h30 == h1), + # ⚠ `richtung` bleibt None — bewusst KEINE + # Richtungsaussage mehr (s. o.). + "richtung": None} + + # ── 2) KOSTEN auf M15 — hier gewinnt M15 wirklich (gemessen 0,104 + # gegen M5 0,193). Schwellen wie beim M5-Chip (0,20 / 0,32). + kr = None + if bid and ask and atr15 and atr15 > 0: + kr = round((ask - bid) / atr15, 3) + out["kosten"] = {"ratio": kr, + "stufe": (None if kr is None else + "guenstig" if kr < 0.20 else + "normal" if kr < 0.32 else "teuer")} + + # ── 3) ZONE — nächstes STEHENDES Level (M30-Pivots, dieselbe Quelle + # wie Chart und S/R-Close) + P(break). ⚠ Die P(break)-Merkmale + # kommen aus M5, weil das Modell darauf trainiert ist (Fix + # 2026-07-13) — NICHT auf M15 umstellen. + dl = self._draw_levels() or {} + res = (dl.get("res") or [None])[0] + sup = (dl.get("sup") or [None])[0] + pf = ws.get("pb_feats") or {} + a5 = pf.get("atr") or 0.2 + zonen = [] + for lvl, seite in ((res, "res"), (sup, "sup")): + if not lvl or not bid or not atr15: + continue + d = 1 if seite == "res" else -1 + gap = abs(lvl - bid) + wt = 1.0 if (pf.get("ema_diff", 0.0) or 0.0) * d > 0 else 0.0 + p = _p_break(pf.get("mom6", 0.0) * d, pf.get("mom3", 0.0) * d, + wt, gap / max(a5, 0.06)) + zonen.append({"seite": seite, "level": round(lvl, 3), + "gap_atr15": round(gap / atr15, 2), + "p_break": round(100 * p)}) + out["zonen"] = zonen + + # ── 3b) MOTOR-ZUSTAND + RAUM ───────────────────────────────── + # ⚠⚠ ANLASS 2026-08-10 (Trade #49671601, −87 €): die Karte zeigte + # GRÜN, während der Signal-Motor zu 76 % mit `entry_room` blockte. + # Das ist kein Widerspruch im Motor, sondern eine LÜCKE in der + # Karte: dieselbe geometrische Tatsache — „ein Level ist sehr nah" + # — wird von beiden Seiten mit UMGEKEHRTEM Vorzeichen gelesen: + # · Gate ③ (Karte): Level in Reichweite → man kann eine ruhende + # Order dort platzieren → GUT + # · entry_room (Motor): das Ziel-Level ist zu nah → Ertrag + # gedeckelt, Kosten fressen ihn → SCHLECHT + # (gemessen ØR −0,119/−0,067 · PF 0,42/0,66) + # Beide sind gemessen und beide haben recht — über VERSCHIEDENE + # Trades (Markt-Einstieg mit dem Level als Hindernis gegen ruhende + # Order AM Level). Die Karte verschwieg nur die zweite Hälfte. + # ⚠ REINE ANZEIGE. Es wird nichts geblockt und keine Richtung + # ausgegeben — der Raum kommt für BEIDE Richtungen, weil der Mensch + # in jede handeln kann. Hartes Blockieren ist im Projekt verworfen + # (Übersteuerungen des Users liefen gemessen 68 % WR). + # ⚠ `room_info` ist DIESELBE Funktion, die das Gate entscheidet — + # kein Nachbau (die Divergenz-Falle, dreimal dokumentiert). + wsig = wave_signal or {} + try: + raum = self.wave.room_info(bid) + except Exception: + raum = None + out["motor"] = { + "signal": wsig.get("signal"), + "block": wsig.get("block"), + "grund": (wsig.get("reasons") or [None])[0], + "conf_pct": wsig.get("conf_pct"), + "raum": raum} + + # ── 4) REICHWEITE — der Kegel (kalibriert: 80 %-Band trifft 77 %) + lv = (cone or {}).get("levels") or [] + b80 = [x for x in lv if x.get("band") == 80] + out["reichweite"] = [{"minutes": x.get("minutes"), + "lo": x.get("lo"), "hi": x.get("hi")} + for x in b80] or None + + # ── 4b) ZIEL-ERREICHBARKEIT — reine GEOMETRIE auf dem Kegel. + # Gefragt wird nur: passt der Abstand noch in das 80-%-Band des + # jeweiligen Horizonts? ⚠ KEINE Bruch-Annahme — die im Vorschlag + # mitgedachte „je länger, desto eher bricht es"-Regel ist + # gemessen NICHT robust (Touch-Zahl: umgekehrtes U auf 4 + # Live-Tagen, im Backtest +0,010 AUC und nach dem Nachtrainieren + # gar nichts mehr). Hier steckt deshalb nur Arithmetik. + # + # ⚠⚠ FEHLER BEHOBEN 2026-08-10 (User-Fund „Kurs 79, Ziel 78,7 — bei + # LONG?"). Der erste Entwurf nahm als Ziel schlicht das ENTFERNTERE + # der beiden Level (`max` nach gap) — also **rein geometrisch, ohne + # die erlaubte Richtung zu prüfen**. Real zeigte die Karte damit + # `verboten=SHORT` UND ein Ziel 0,15 $ UNTER dem Kurs: die Arithmetik + # war korrekt, aber für die VERBOTENE Richtung. + # Strukturell steckte dahinter die stillschweigende Annahme eines + # **Fade-Trades** (am Level einsteigen, Gegenlevel als Ziel = Mean + # Reversion), während Gate ② ein **Trendfilter** ist. Zwei + # unvereinbare Handelskonzepte in einer Kette — und damit derselbe + # Fehler, wegen dem die Gesamtempfehlung entfernt wurde: aus einem + # VERBOT wird über die Hintertür wieder eine RICHTUNG. + # + # Jetzt gilt die gemessene Population aus `backtest_m15_gates.py`: + # Einstieg am **nächstgelegenen** Level, Richtung = die vom HTF + # erlaubte. Ein Ziel wird nur ausgewiesen, wenn es JENSEITS dieses + # Einstiegs in der erlaubten Richtung liegt. + # ⚠ Ohne Veto (M30 flach) gibt es KEIN Ziel — ein einzelnes Ziel + # zu zeigen wäre wieder eine Richtungsaussage. + # ⚠ `_draw_levels` liefert bewusst nur die NÄCHSTE Linie je Seite. + # Beim Ausbruchs-Einstieg (nächstes Level == Ziel-Seite) ist das + # nächste Ziel damit schlicht NICHT berechenbar — dann sagt die + # Karte das, statt eine Zahl zu erfinden. + out["ziel"] = None + d_ok = (1 if verboten == "SHORT" else + -1 if verboten == "LONG" else 0) + if len(zonen) == 2 and bid and b80 and d_ok: + nah = min(zonen, key=lambda x: x["gap_atr15"]) + kand = next((z for z in zonen + if z["seite"] == ("res" if d_ok > 0 else "sup")), None) + if kand is None: + grund = "kein Level in der erlaubten Richtung" + elif (kand["level"] - nah["level"]) * d_ok > 0: + strecke = abs(kand["level"] - bid) + out["ziel"] = { + "level": kand["level"], "strecke": round(strecke, 3), + "horizonte": [{"minutes": x.get("minutes"), + "drin": bool(x.get("lo") is not None + and x.get("hi") is not None + and x["lo"] <= kand["level"] <= x["hi"])} + for x in b80], + "von": nah["level"], + "richtung": "LONG" if d_ok > 0 else "SHORT"} + grund = None + else: + grund = ("Ausbruch — Einstieg am " + + ("Widerstand" if nah["seite"] == "res" else "Support") + + f" {nah['level']:.3f}, nächstes Ziel dahinter nicht berechenbar") + out["ziel_grund"] = grund + else: + out["ziel_grund"] = (None if d_ok else + "kein Veto — Richtung offen, kein Ziel bestimmbar") + + # ── 4d) STATUS + LESART mit HYSTERESE — EINMAL berechnet ──────── + # ⚠⚠ ANLASS (2026-08-12): die Karte wechselte den Zustand **alle 12 + # Sekunden** (~1.030 Wechsel/Tag). Gemessen an den Logzeilen war + # NICHT das Level die Ursache (nur 7,6 %), sondern **Schwellen- + # Flattern**: `gap ≤ 0,5` und das 45/55-Band von P(break) sind + # stetige Groessen, die um die Schwelle pendeln. + # Folge: die Selbstmessung war massiv geclustert — roh 60 %, + # entkoppelt 53 %. + # ✅ Das Projekt hat genau dieses Problem am 14.07. schon geloest + # (`analyze_pbreak_flicker.py`): Zeit-Glaettung + **Hysterese + # ±5 Pp**. Die Karte umging sie, weil sie P(break) selbst neu + # rechnet. Hier ist dieselbe Hysterese, auf beide Schwellen. + # ⚠⚠ UND: Status/Lesart wurden an DREI Stellen unabhaengig gebildet + # (Frontend, `_log_m15_state`, hier) — sie wichen bereits ab (der + # Logger kannte den „kein Veto"-Fall nicht). Jetzt EINE Quelle; + # Anzeige und Messung koennen nicht mehr auseinanderlaufen. + hy = getattr(self, "_m15_hyst", None) or {} + nah_z = min(zonen, key=lambda x: x["gap_atr15"]) if zonen else None + # (a) Status: gruen ab gap ≤ 0,50 — zurueck auf gelb erst > 0,60 + war_gruen = hy.get("status") == "gruen" + if kr is not None and kr >= 0.32: + status = "rot" + elif not nah_z: + status = "gelb" + else: + g = nah_z["gap_atr15"] + status = "gruen" if (g <= (0.60 if war_gruen else 0.50)) else "gelb" + # (b) Lesart: gerichtet erst ausserhalb 40/60, zurueck auf „unklar" + # erst innerhalb 45/55 (die dokumentierten ±5 Pp). + lesart = "unklar" + if nah_z and nah_z.get("p_break") is not None: + pb = nah_z["p_break"] + war_ger = hy.get("lesart") in ("auf", "ab") + lo_s, hi_s = (45, 55) if war_ger else (40, 60) + if pb < lo_s or pb > hi_s: + bricht = pb > hi_s if not war_ger else pb > 55 + res = nah_z["seite"] == "res" + lesart = "auf" if (res if bricht else not res) else "ab" + elif war_ger: + lesart = hy.get("lesart") # im Band: alten Zustand halten + self._m15_hyst = {"status": status, "lesart": lesart} + out["status"] = status + out["lesart"] = lesart + out["nah"] = nah_z + + # ── 4c) VOLA-REGIME als Slippage-HINWEIS (kein Gate, s. Docstring) + out["vola"] = self._vola_regime(atr15) + + # ── 5) VETO — die eigene Bilanz, je Lot (absolute Euro sind bei + # wechselnder Positionsgröße ungültig, Lehre vom 04.08.) + out["veto"] = alignment or None + return out + except Exception as e: + log.debug(f"m15_setup: {e}") + return None + + def _stop_approach_hint(self, market, position, wave_snap) -> dict | None: + """REINE ANZEIGE: P(Durchbruch) für das Level, dem sich der Kurs auf der + **Gegen-/Stop-Seite** der offenen Position nähert (LONG → Support darunter, + SHORT → Resistance darüber). Beantwortet die User-Frage „mein Trade läuft auf + ein Level zu — bricht es durch (Verlust läuft) oder prallt es ab (erholt sich)?". + NICHT der S/R-Auto-Close (der wirkt nur auf der Ziel-Seite im Plus) — hier wird + NICHTS geschlossen. Kalibrierung der Gegen-Seite gemessen + (`backtest_pbreak_calibration.py`, 2026-07-24): AUC 0,68/0,72 out-of-sample in + beiden Hälften, im niedrigen P-Bereich sauber kalibriert; im hohen (≥0,5) gegen + den Trend leicht KONSERVATIV (unterschätzt die Bruch-Rate ~10–13 Pp) → die Zahl + ist eine belastbare, eher vorsichtige Schätzung. Feature-/Glättungs-Logik + identisch zu `_sr_close_hint`, nur Level = Gegen-Level und Richtung = -d.""" + try: + if not position.get("ticket"): + self._stop_pb_state = None + return None + is_long = position.get("order_type") == 0 + bid = (market or {}).get("bid") + pf = (wave_snap or {}).get("pb_feats") or {} + atr = pf.get("atr"); mom6 = pf.get("mom6"); mom3 = pf.get("mom3") + ediff = pf.get("ema_diff") + entry = position.get("entry_price") or bid + dl = self._draw_levels() + # Gegen-Level: LONG → Support darunter, SHORT → Resistance darüber + if is_long: + level = (dl.get("sup") or [None])[0] + else: + level = (dl.get("res") or [None])[0] + if not (bid and atr and level) or mom6 is None or mom3 is None or ediff is None: + self._stop_pb_state = None + return None + dd = -1 if is_long else 1 # Break-Richtung GEGEN den Trade + gap = (level - bid) * dd # >0 solange Kurs noch vor dem Level + if gap <= 0 or gap > 0.6 * atr: # weit weg / schon durch → kein Hinweis + self._stop_pb_state = None + return None + wt = 1.0 if ediff * dd > 0 else 0.0 + dist = abs(level - entry) / atr + p_raw = _p_break(mom6 * dd, mom3 * dd, wt, dist) + import math as _m + now = time.time(); st = self._stop_pb_state + if not st or abs(st["level"] - level) > 1e-9: + st = {"level": level, "p": p_raw, "ts": now} + else: + a = 1.0 - _m.exp(-max(0.0, now - st["ts"]) / 60.0) + st["p"] += a * (p_raw - st["p"]); st["ts"] = now + self._stop_pb_state = st + p_s = st["p"] + confirm = round(level + dd * 0.5 * atr, 3) # Durchbruch bestätigt ab + reject = round(level - dd * 0.5 * atr, 3) # Abprall bestätigt ab + return {"level": round(level, 3), + "side": ("sup" if is_long else "res"), + "p_break": round(100 * p_s), + "at_level": gap <= 0.15 * atr, + "gap_atr": round(gap / atr, 2), + "confirm": confirm, "reject": reject} + except Exception as e: + log.debug(f"stop_approach_hint: {e}") + return None diff --git a/core/history.py b/core/history.py index 4aa134d..0254514 100644 --- a/core/history.py +++ b/core/history.py @@ -855,6 +855,32 @@ class HistoryLogger: "pct": round(100.0 * (r["c"] or 0) / (r["n"] or 1), 1)} return out + def fetch_squeeze_since(self, ab: int) -> list: + """P&L aller geschlossenen SQUEEZE_*-Trades ab Epoch `ab` (2026-08-19). + + ⚠ Bewusst NUR `setup LIKE 'SQUEEZE%'` — die `setup`-Spalte taugt NICHT + als allgemeine Bot/Mensch-Trennung: `engine._open` schreibt die + Wellenlage auch bei MANUELLEN Trades mit (`WAVE`, n=124). Nur + `SQUEEZE_*` ist autonom eroeffnet. Ein erster Auswertungsversuch am + 13.08. hat genau das verwechselt und dem Bot 152 Trades zugeschrieben. + + ⚠ NICHT je Lot normiert: die B5-Regel ist ausdruecklich auf + OeGewinn/OeVerlust in EURO fixiert worden. Sie hier still auf eine + andere Einheit umzustellen waere ein nachtraegliches Verschieben der + Latte. + + Fail-open (leere Liste) — `core/history.py` hat bewusst keinen Logger. + """ + try: + with self._connect() as c: + return [float(r[0]) for r in c.execute( + "SELECT pnl FROM trades " + " WHERE setup LIKE 'SQUEEZE%' AND exit_time IS NOT NULL " + " AND pnl IS NOT NULL AND entry_time > ? " + " ORDER BY entry_time", (int(ab),))] + except Exception: + return [] + def alignment_stats(self, last_n: int = 0) -> dict: """Ausrichtungs-Split: liefen die Trades MIT, GEGEN oder OHNE die Empfehlung? diff --git a/core/history.py.bak-2026-08-19-brk b/core/history.py.bak-2026-08-19-brk new file mode 100644 index 0000000..4aa134d --- /dev/null +++ b/core/history.py.bak-2026-08-19-brk @@ -0,0 +1,1410 @@ +""" +core/history.py — SQLite-basierter Verlaufs-Logger +==================================================== +Speichert Trades, KI-Analysen, Empfehlungen und Signale in einer lokalen +SQLite-Datenbank für spätere statistische Auswertung. + +Tabellen: + trades — jeder geöffnete + geschlossene Trade + ai_analyses — jede ChatGPT-Antwort + recommendations — Algorithm-Empfehlungen (gefiltert: alle 60 s) + signals — Reversal, Breakout, EMA-Cross etc. + +Alle Schreiboperationen sind thread-safe und werden in einem internen +Lock serialisiert. Lese-Queries (für Stats) können parallel laufen. + +Wichtig: Diese Klasse macht KEIN Machine Learning. Sie loggt nur. +Auswertung passiert separat über die `stats_*`-Methoden. +""" + +from __future__ import annotations + +import sqlite3 +import threading +import time +import json +from pathlib import Path +from datetime import datetime, timedelta + +# history sendet selbst kein Telegram mehr — Close-Pushes laufen über die Engine +# (Flip-Close-Alarm / Notfall-/Gewinn-Auto-Close). + + +SCHEMA = [ + """ + CREATE TABLE IF NOT EXISTS trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ticket INTEGER UNIQUE, + symbol TEXT, + direction TEXT, -- 'BUY' | 'SELL' + lots REAL, + entry_time INTEGER, -- unix timestamp (s) + entry_price REAL, + sl_at_entry REAL, + tp_at_entry REAL, + exit_time INTEGER, + exit_price REAL, + pnl REAL, + closed_by TEXT, -- 'manual' | 'sl' | 'tp' | 'trail' | 'unknown' + ai_sentiment TEXT, -- KI-Bewertung beim Einstieg + ai_confidence INTEGER, -- 0-100 + rec_signal TEXT, -- 'LONG' | 'SHORT' | 'WARTEN' + rec_score REAL, -- -1.0 .. +1.0 + setup TEXT, -- 'TREND_PULLBACK_LONG' | 'BREAKOUT_SHORT' | ... + regime TEXT, -- 'trend_up' | 'trend_down' | 'range' | 'transition' + rsi_at_entry REAL, -- RSI(14) M15 beim Einstieg + news_score REAL -- News-Sentiment -1..+1 beim Einstieg + ) + """, + """ + CREATE TABLE IF NOT EXISTS ai_analyses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER, + sentiment TEXT, + confidence INTEGER, + summary TEXT, + drivers_json TEXT, + cost_estimate REAL, + model TEXT + ) + """, + """ + CREATE TABLE IF NOT EXISTS recommendations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER, + signal TEXT, + score REAL, + conf_pct INTEGER, + angle_m5 REAL, + angle_m15 REAL, + angle_m30 REAL, + angle_h1 REAL, + reversal TEXT, + ai_sentiment TEXT, + ai_confidence INTEGER, + setup TEXT, + regime TEXT, + rsi REAL, + news_score REAL, + -- Maschinenlesbarer Grund für WARTEN (Stufe-2-Telemetrie 2026-07-31): + -- deadband · dead_hour · eia · htf_counter · stretch · min_conf · + -- breakout_pending · entry_room · no_data · stale. NULL = kein Block. + -- Ohne den war live nicht feststellbar, WELCHES Gate die 93 % WARTEN + -- erzeugt (Backtest-Erwartung ~43 %). + block_reason TEXT + ) + """, + """ + CREATE TABLE IF NOT EXISTS intended_trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + open_ts INTEGER, + open_price REAL, + direction TEXT, -- 'BUY' | 'SELL' + sl REAL, + tp REAL, + setup TEXT, + regime TEXT, + rsi REAL, + score REAL, + conf_pct INTEGER, + news_score REAL, + ai_sentiment TEXT, + ai_confidence INTEGER, + close_ts INTEGER, -- NULL = noch offen + close_price REAL, + close_reason TEXT, -- 'sl' | 'tp' | 'flip' | 'expired' | 'disabled' + pnl_pct REAL -- (close-open)/open * 100, vorzeichenrichtig + ) + """, + """ + CREATE TABLE IF NOT EXISTS signals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER, + signal_type TEXT, -- 'reversal' | 'breakout' | 'ema_cross' + direction TEXT, -- 'bullish' | 'bearish' + price REAL, + details_json TEXT + ) + """, + """ + CREATE TABLE IF NOT EXISTS pbreak_predictions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER, -- lokale Epoch (wie trades/recommendations) + symbol TEXT, + direction TEXT, -- 'LONG' | 'SHORT' (Positionsrichtung) + level REAL, + p_break REAL, -- geglättete P(break) 0..100 beim Touch + predicted TEXT, -- 'break' | 'bounce' (P(break) vs. sr_close_pbreak) + price_at_pred REAL, -- bid beim Touch + confirm_price REAL, -- level ± 0,5×ATR in Richtung Durchbruch + reject_price REAL, -- level ± 0,5×ATR in Richtung Abprall + atr REAL, + outcome TEXT, -- NULL bis ausgewertet, dann 'break' | 'bounce' + outcome_ts INTEGER, + correct INTEGER -- NULL bis ausgewertet, dann 0/1 + ) + """, + """ + CREATE TABLE IF NOT EXISTS rec_outcomes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER, -- lokale Epoch, Beginn der EPISODE + signal TEXT, -- 'LONG' | 'SHORT' + conf_pct INTEGER, + tf TEXT, + price REAL, -- Kurs bei Ausgabe der Empfehlung + atr REAL, -- ATR_M5 zur Normierung + ret30 REAL, -- (Kurs[+30min] - price) x Richtung / atr + ret60 REAL, + hit30 INTEGER, -- 1 = Kurs lief in Signalrichtung + hit60 INTEGER, + done INTEGER -- 1 = beide Horizonte ausgewertet + ) + """, + """ + -- M15-KARTE misst sich SELBST (2026-08-08). + -- ⚠ WARUM: jeder Ausfall, der in diesem Projekt je aufgedeckt wurde, kam aus + -- einer Rueckkopplung — `pbreak_predictions` entlarvte den Modellausfall LIVE + -- (was kein Backtest konnte), der B4-Monitor die Squeeze-Drift. Die Karte + -- hatte nichts davon und koennte monatelang falsch stehen, ohne dass es + -- auffaellt. Geloggt wird EINE Zeile je STATUSWECHSEL (nicht je Tick — sonst + -- zaehlt man dieselbe Marktlage dutzendfach; die Lehre aus rec_outcomes und + -- der Clusterung bei pbreak_predictions). + CREATE TABLE IF NOT EXISTS m15_states ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER, -- lokale Epoch, Beginn des Zustands + status TEXT, -- 'gruen' | 'gelb' | 'rot' + veto TEXT, -- verbotene Richtung ('LONG'/'SHORT'/NULL) + kosten REAL, -- Spread/ATR_M15 + level REAL, -- naechstes stehendes Level + gap_atr REAL, -- Abstand Kurs<->Level in ATR_M15 + p_break INTEGER, -- Durchbruchwahrscheinlichkeit in % + lesart TEXT, -- 'auf' | 'ab' | 'unklar' (die Richtungs-Lesart) + price REAL, + atr REAL, -- ATR_M15 zur Normierung + ret30 REAL, -- (Kurs[+30min]-price) x Lesart-Richtung / atr + ret60 REAL, + hit30 INTEGER, -- 1 = Kurs lief in Richtung der Lesart + hit60 INTEGER, + done INTEGER + ) + """, + """ + -- KEGEL-ABDECKUNG fuer Adaptive Conformal Inference (2026-08-08). + -- ⚠ NICHT-UEBERLAPPEND je Horizont geloggt: ein 120-min-Fenster alle 5 min + -- waere 24-fach ueberlappend, und ueberlappende Beobachtungen haben genau + -- die Cluster-Verzerrung erzeugt, an der `analyze_hl_funding.py` (06.08.) + -- und die Faelligkeitsbedingung `pbreak_accuracy_v2` (07.08.) aufgelaufen + -- sind. Je Horizont startet ein neuer Check erst, wenn der vorige ablief. + CREATE TABLE IF NOT EXISTS cone_checks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER, -- lokale Epoch + bars INTEGER, -- Horizont in M5-Bars (6/12/24) + band INTEGER, -- Nennband in % (80) + price REAL, + lo REAL, + hi REAL, + skala REAL, -- ACI-Skala, mit der dieses Band gebaut wurde + drin INTEGER, -- 1 = Kurs lag im Band + done INTEGER + ) + """, + """ + CREATE TABLE IF NOT EXISTS verdict_votes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER, -- lokale Epoch + headline TEXT, -- Wave-Signal (LONG/SHORT/WARTEN) + conf INTEGER, -- conf_pct der Welle + bias REAL, -- gewichteter Konsens-Bias −1..+1 + tf TEXT, -- Zeitebene der Empfehlung + wave INTEGER, -- Votes je Modul: +1/−1/0 + m30 INTEGER, + h1 INTEGER, + ki INTEGER, -- KI-Copilot-Vote (0 auch wenn ohne Aussage) + ki_w REAL, -- Copilot-Gewicht (0 = hatte keine Aussage) + elliott INTEGER, + elliott_w REAL, + squeeze INTEGER, + news_score REAL, -- News-Sentiment zum Zeitpunkt (Kontext) + -- ab 2026-07-30 (User-Vorgabe, Stimmen mit KLEINEM Gewicht): + pattern INTEGER, -- Chartmuster-Vote (Kontrolltest: kleiner Zusatz) + pattern_w REAL, -- 1,0 bestaetigt / 0,5 bildet sich / 0 keins + orderbook INTEGER, -- HL-Orderbuch-Imbalance (UNGEMESSEN) + orderbook_w REAL, + liqtrend INTEGER, -- Liquiditaets-Trend (UNGEMESSEN) + liqtrend_w REAL + ) + """, +] + +# Indizes — werden NACH den Migrationen ausgeführt, weil sie Spalten +# referenzieren, die u.U. erst per ALTER TABLE hinzugefügt werden. +INDEXES = [ + "CREATE INDEX IF NOT EXISTS idx_trades_entry ON trades(entry_time)", + "CREATE INDEX IF NOT EXISTS idx_trades_exit ON trades(exit_time)", + "CREATE INDEX IF NOT EXISTS idx_trades_setup ON trades(setup)", + "CREATE INDEX IF NOT EXISTS idx_trades_closed_by ON trades(closed_by)", + "CREATE INDEX IF NOT EXISTS idx_trades_open ON trades(exit_time) WHERE exit_time IS NULL", + "CREATE INDEX IF NOT EXISTS idx_ai_ts ON ai_analyses(timestamp)", + "CREATE INDEX IF NOT EXISTS idx_rec_ts ON recommendations(timestamp)", + "CREATE INDEX IF NOT EXISTS idx_rec_setup ON recommendations(setup)", + "CREATE INDEX IF NOT EXISTS idx_signals_ts ON signals(timestamp)", + "CREATE INDEX IF NOT EXISTS idx_intended_open ON intended_trades(open_ts)", + "CREATE INDEX IF NOT EXISTS idx_intended_close ON intended_trades(close_ts)", + "CREATE INDEX IF NOT EXISTS idx_pbreak_ts ON pbreak_predictions(ts)", + "CREATE INDEX IF NOT EXISTS idx_pbreak_open ON pbreak_predictions(outcome) WHERE outcome IS NULL", + "CREATE INDEX IF NOT EXISTS idx_verdict_ts ON verdict_votes(ts)", +] + +# Migrations: Spalten, die in alten DBs evtl. fehlen. +# `ALTER TABLE ADD COLUMN` ist in SQLite idempotent über try/except. +MIGRATIONS = [ + ("trades", "setup", "TEXT"), + ("trades", "regime", "TEXT"), + ("trades", "rsi_at_entry", "REAL"), + ("trades", "news_score", "REAL"), + ("trades", "commission", "REAL"), + ("recommendations","setup", "TEXT"), + ("recommendations","regime", "TEXT"), + ("recommendations","rsi", "REAL"), + ("recommendations","news_score", "REAL"), +] + + +class HistoryLogger: + """Persistenter SQLite-Logger für Trading-Verlauf.""" + + def __init__(self, db_path: Path, + rec_min_interval_s: int = 60): + self.db_path = db_path + self._lock = threading.Lock() + self._last_rec_ts = 0 # Throttling für recommendations + self.rec_min_interval = rec_min_interval_s + self._telegram: dict = {"enabled": False, "token": "", "chat_id": ""} + self._last_optimize_ts: float = 0.0 + self._init_db() + + def configure_telegram(self, *, enabled: bool, token: str, chat_id: str): + """Telegram-Benachrichtigungen konfigurieren (nach Config-Load aufrufen).""" + self._telegram = {"enabled": enabled, "token": token, "chat_id": chat_id} + + # ── Verbindung & Schema ───────────────── + def _connect(self): + # Eine neue Verbindung pro Thread vermeidet SQLite-Threading-Issues. + # Da wir alles unter Lock haben, ist eine Connection auch ok – aber wir + # bleiben safer mit Thread-Local Verbindungen. + conn = sqlite3.connect(str(self.db_path), timeout=5.0) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode = WAL") + now = time.time() + if now - self._last_optimize_ts > 86400: + conn.execute("PRAGMA optimize") + self._last_optimize_ts = now + return conn + + def _init_db(self): + with self._connect() as conn: + # 1. Tabellen anlegen (nur Spalten, keine Indizes) + for stmt in SCHEMA: + conn.execute(stmt) + # 2. Migrationen: fehlende Spalten in bestehenden DBs nachziehen. + # MUSS vor den CREATE-INDEX-Statements laufen, weil manche + # Indizes auf Spalten zeigen, die erst hier hinzukommen. + for table, col, ctype in MIGRATIONS: + try: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {ctype}") + except sqlite3.OperationalError: + pass # Spalte existiert bereits + # 3. Jetzt sind alle Spalten garantiert da → Indizes anlegen + for stmt in INDEXES: + conn.execute(stmt) + conn.commit() + + # ══════════════════════════════════════════ + # WRITE-METHODEN + # ══════════════════════════════════════════ + + def log_trade_open(self, *, ticket: int, symbol: str, direction: str, + lots: float, entry_price: float, + sl_at_entry: float | None = None, + tp_at_entry: float | None = None, + ai_sentiment: str | None = None, + ai_confidence: int | None = None, + rec_signal: str | None = None, + rec_score: float | None = None, + setup: str | None = None, + regime: str | None = None, + rsi_at_entry: float | None = None, + news_score: float | None = None, + ctx: dict | None = None): + """Loggt das Öffnen eines Trades. Idempotent über UNIQUE(ticket). + + `ctx` = vollständiger Entscheidungszustand beim Einstieg (Stufe 1 des + „Manuelle Trades"-Moduls, 2026-08-04). Bis dahin wurden nur 5 Felder + gespeichert — davon `ai_sentiment` in 1,4 % und `news_score` in 34 % der + Fälle befüllt. Damit war NICHT rekonstruierbar, in welcher Marktlage der + User eingestiegen ist, und jede Auswertung seiner Diskretion blieb + Spekulation. Alle `ctx_*`-Werte werden im Snapshot ohnehin berechnet; + hier werden sie nur mitgeschrieben (reine Telemetrie, kein Verhalten). + Unbekannte Schlüssel werden ignoriert — so bricht ein späterer Ausbau + nichts.""" + # Guard (Fix 2026-07-19): 0-Lot-/Preis-lose Einträge sind Reconcile- + # Artefakte (real: Ticket 47966457, 0.0L, closed_by=unknown) — sie + # verfälschen WR/Statistik. Nicht loggen. + if not lots or lots <= 0 or not entry_price or entry_price <= 0: + return + now_ts = int(time.time()) + # Kontext-Spalten dynamisch anhängen — nur solche, die es in der Tabelle + # wirklich gibt. So läuft eine ältere DB ohne Migration weiter (fail-open), + # statt beim INSERT zu werfen. + ctx = ctx or {} + with self._lock, self._connect() as conn: + vorhanden = {r[1] for r in conn.execute("PRAGMA table_info(trades)")} + extra = [(f"ctx_{k}", v) for k, v in ctx.items() + if f"ctx_{k}" in vorhanden and v is not None] + spalten = ("ticket, symbol, direction, lots, entry_time, entry_price, " + "sl_at_entry, tp_at_entry, ai_sentiment, ai_confidence, " + "rec_signal, rec_score, setup, regime, rsi_at_entry, news_score") + werte = [ticket, symbol, direction, lots, now_ts, entry_price, + sl_at_entry, tp_at_entry, ai_sentiment, ai_confidence, + rec_signal, rec_score, setup, regime, rsi_at_entry, news_score] + for name, v in extra: + spalten += f", {name}" + werte.append(v) + platz = ",".join("?" * len(werte)) + conn.execute(f"INSERT OR IGNORE INTO trades ({spalten}) VALUES ({platz})", + werte) + conn.commit() + # Kein Telegram bei Einstieg — nur Ergebnis (Close) wird gesendet + + def log_trade_close(self, *, ticket: int, exit_price: float, + pnl: float, closed_by: str = "manual", + exit_ts: int | None = None, + commission: float = 0.0): + """Schreibt Exit-Daten zu einem bestehenden Trade. + exit_ts kann gesetzt werden für Reconciliation alter Trades. + + Sanity-Check: wenn exit_ts < entry_time (kaputter Deal-Lookup), + wird stattdessen time.time() benutzt. + """ + ts = int(exit_ts) if exit_ts else int(time.time()) + with self._lock, self._connect() as conn: + # Defensive: exit kann nicht vor entry liegen. + row = conn.execute( + "SELECT entry_time FROM trades " + "WHERE ticket = ? AND exit_time IS NULL", (ticket,) + ).fetchone() + if row and row["entry_time"] and ts < row["entry_time"]: + ts = int(time.time()) + cur = conn.execute(""" + UPDATE trades + SET exit_time = ?, exit_price = ?, pnl = ?, + closed_by = ?, commission = ? + WHERE ticket = ? AND exit_time IS NULL + """, (ts, exit_price, pnl, closed_by, commission, ticket)) + # ⚠⚠ KORREKTUR EINER FEHLBUCHUNG (2026-08-12). Bis dahin galt strikt + # `exit_time IS NULL` — eine einmal geschriebene Zeile war endgueltig. + # Das machte einen Folgefehler dauerhaft: buchte der Fallback-Pfad + # eine Position faelschlich als geschlossen (real #49852362: + # +5,87 statt −95,38 € und auf dem falschen Tag), lief der SPAETERE, + # ECHTE Close still ins Leere. + # ⚠ ENG GEFASST, damit keine gute Zeile ueberschrieben wird: + # korrigiert wird NUR, wenn die bestehende Zeile eine erkennbare + # Fehlbuchung ist (`closed_by='unknown'`) UND die neue Buchung + # echte Deal-Daten mitbringt (`closed_by != 'unknown'`). + if cur.rowcount == 0 and closed_by != "unknown": + cur = conn.execute(""" + UPDATE trades + SET exit_time = ?, exit_price = ?, pnl = ?, + closed_by = ?, commission = ? + WHERE ticket = ? AND closed_by = 'unknown' + """, (ts, exit_price, pnl, closed_by, commission, ticket)) + if cur.rowcount: + self._korrigiert = getattr(self, "_korrigiert", 0) + 1 + conn.commit() + # KEIN Trade-Abschluss-Telegram mehr (User-Vorgabe): Telegram zum Thema + # Schließen kommt nur noch beim echten Signal-Flip (engine._check_close_ + # alert „🔔 CLOSE-Signal") sowie bei Notfall-/Gewinn-Auto-Close. Die terse + # „Sym +X"-Bestätigung bei jedem Close ist entfernt. + + # ── TELEMETRIE-PULS ────────────────────────────────────────────────── + # ⚠⚠ ANLASS (2026-08-11): `rec_outcomes` stand seit dem Einbau am 06.08. bei + # 0 Zeilen — ein falsches dict liess die Schreibbedingung nie wahr werden. + # KEIN NameError (der Linter sieht nichts), und das umgebende `except` + # loggt auf DEBUG. Fuenf Tage still tot; aufgefallen nur, weil die Tabelle + # neben ihren Schwestern mit 0 Zeilen stand. + # ⚠ Warum hier und nicht im Reminder-Skript: der Puls ist eine DB-Frage, und + # BEIDE Nutzer (Reminder-Popup + Tagesreport) brauchen ihn. Zwei Kopien + # waeren genau die Divergenz, die im Projekt dreimal zugeschlagen hat. + # Richtungsregel: Skripte duerfen `core` importieren, nicht umgekehrt. + # ⚠⚠ BEDINGUNGS-KENNTNIS (2026-08-17). Der Puls hat `pbreak_predictions` als + # „63,5 h still" gemeldet — dabei schreibt die Tabelle NUR bei frischem + # Level-Touch auf der ZIEL-Seite einer OFFENEN Position. Ohne offene + # Position kann sie legitim tagelang schweigen. Ein Waechter, der + # Fehlalarme produziert, wird ignoriert — und mit ihm die eine echte + # Meldung; genau das ist dem Divergenz-Waechter passiert (6 Tage + # Dauerwarnung, 02.08.). Deshalb traegt jede Zeile jetzt ihre VORBEDINGUNG. + # `bedingung=None` heisst „schreibt unbedingt, Stille ist immer ein Fehler". + PULS = ( + # (Tabelle, Zeitspalte, Kadenz in Minuten, wozu, Vorbedingung) + ("recommendations", "timestamp", 1, "Empfehlungs-Log", None), + # ⚠ Auch der M1-Logger hat eine Vorbedingung: er schreibt nur, wenn + # der MARKT LAEUFT. Pepperstone schliesst ~23:00 und das ganze + # Wochenende — ohne den Zusatz meldet der Puls jede Nacht und + # jedes Wochenende einen "Ausfall" (real gesehen 18.08. 23:30). + ("candles_m1", "time", 1, "M1-Kerzen-Sammlung", + "schreibt nur bei GEOEFFNETEM Markt — nachts und am Wochenende ist Stille normal"), + ("verdict_votes", "ts", 1, "Modul-Stimmen", None), + ("m15_states", "ts", 60, "M15-Karte Selbstmessung", None), + ("cone_checks", "ts", 60, "Kegel-Kalibrierung (ACI)", None), + ("pbreak_predictions", "ts", 60, "P(break)-Prognose-Tracking", + "braucht eine OFFENE Position und einen frischen Level-Touch auf der " + "Ziel-Seite — ohne offene Position ist Stille normal"), + ("rec_outcomes", "ts", 180, "Selbst-Kalibrierung Empfehlung", + "braucht einen Richtungs-WECHSEL der Empfehlung — bei ~93 % WARTEN " + "kann das lange dauern"), + ) + + def telemetrie_puls(self) -> list[str]: + """Welche Telemetrie-Tabelle schreibt nicht mehr? → Klartext-Zeilen. + + ⚠ Toleranz bewusst grosszuegig (Faktor 20 der Kadenz): ein ruhiger Markt + oder ein Wochenende darf keinen Fehlalarm ausloesen — ein Waechter, der + oft warnt, wird ignoriert (Lehre vom 02.08.). + """ + out: list[str] = [] + try: + jetzt = time.time() + with self._lock, self._connect() as conn: + for tab, sp, kadenz, wozu, bedingung in self.PULS: + try: + n = conn.execute(f"SELECT COUNT(*) FROM {tab}").fetchone()[0] + letzt = conn.execute(f"SELECT MAX({sp}) FROM {tab}").fetchone()[0] + except Exception: + continue # Tabelle gibt es (noch) nicht + if not n: + # ⚠ 0 Zeilen ist IMMER ein Befund — auch mit Vorbedingung: + # „hat nie geschrieben" laesst sich nicht wegerklaeren. + out.append(f"{tab}: 0 Zeilen — Logger hat NIE geschrieben ({wozu})") + continue + # ⚠ `candles_m1.time` ist ROHE Brokerzeit (UTC+3) — ohne den + # Abzug meldet der Puls dort dauerhaft 3 h Rueckstand. + ts = letzt - 10800 if tab == "candles_m1" else letzt + alter_min = (jetzt - ts) / 60.0 + if alter_min <= kadenz * 20: + continue + if bedingung: + # Bedingte Tabellen: als HINWEIS melden, nicht als Ausfall — + # sonst wird der Waechter zum Rauschen (s. Kommentar an PULS). + out.append(f"ⓘ {tab}: still seit {alter_min/60:.1f} h — " + f"{bedingung} ({wozu})") + else: + out.append(f"{tab}: letzte Zeile vor {alter_min/60:.1f} h " + f"(erwartet ~alle {kadenz} min) — {wozu}") + except Exception: + return out # fail-open wie der Rest der Schicht + return out + + def trade_by_ticket(self, ticket: int) -> dict | None: + """Einen Trade komplett lesen — für die Close-Benachrichtigung (2026-08-11). + + ⚠ Der Aufrufer muss `exit_time` prüfen: die Engine bemerkt den Flat-Zustand + über `trader.snapshot()` und kann dem DB-Schreiber um einen Tick voraus sein. + Ein Datensatz mit `exit_time IS NULL` heisst „noch nicht fertig", NICHT + „kein Trade" — wer das verwechselt, meldet einen Close ohne P&L. + """ + try: + with self._lock, self._connect() as conn: + row = conn.execute( + "SELECT * FROM trades WHERE ticket = ?", (int(ticket),)).fetchone() + return dict(row) if row else None + except Exception: + return None # fail-open wie der Rest dieser Schicht + + def log_ai(self, *, sentiment: str, confidence: int, + summary: str, drivers: list, + cost_estimate: float | None, + model: str): + with self._lock, self._connect() as conn: + conn.execute(""" + INSERT INTO ai_analyses + (timestamp, sentiment, confidence, summary, + drivers_json, cost_estimate, model) + VALUES (?,?,?,?,?,?,?) + """, (int(time.time()), sentiment, confidence, + summary[:500], json.dumps(drivers, ensure_ascii=False), + cost_estimate, model)) + conn.commit() + + def log_recommendation(self, *, signal: str, score: float, conf_pct: int, + angles: dict, reversal: str | None, + ai_sentiment: str | None, + ai_confidence: int | None, + setup: str | None = None, + regime: str | None = None, + rsi: float | None = None, + news_score: float | None = None, + block: str | None = None): + """ + Empfehlungs-Logging mit Throttling: nur jede N Sekunden, um die DB + nicht mit identischen Snapshots zu fluten. + """ + now = int(time.time()) + if now - self._last_rec_ts < self.rec_min_interval: + return + self._last_rec_ts = now + with self._lock, self._connect() as conn: + conn.execute(""" + INSERT INTO recommendations + (timestamp, signal, score, conf_pct, + angle_m5, angle_m15, angle_m30, angle_h1, + reversal, ai_sentiment, ai_confidence, + setup, regime, rsi, news_score, block_reason) + VALUES (?,?,?,?, ?,?,?,?, ?,?,?, ?,?,?,?,?) + """, (now, signal, score, conf_pct, + angles.get("M5"), angles.get("M15"), + angles.get("M30"), angles.get("H1"), + reversal, ai_sentiment, ai_confidence, + setup, regime, rsi, news_score, block)) + conn.commit() + + def log_signal(self, *, signal_type: str, direction: str, + price: float | None = None, details: dict | None = None): + with self._lock, self._connect() as conn: + conn.execute(""" + INSERT INTO signals + (timestamp, signal_type, direction, price, details_json) + VALUES (?,?,?,?,?) + """, (int(time.time()), signal_type, direction, price, + json.dumps(details or {}, ensure_ascii=False))) + conn.commit() + + def log_pbreak_prediction(self, *, symbol: str, direction: str, level: float, + p_break: float, predicted: str, price_at_pred: float, + confirm_price: float, reject_price: float, + atr: float) -> int: + """Loggt EINE Abprall/Durchbruch-Vorhersage bei frischem Level-Touch + (`predicted` = 'break'|'bounce', aus dem live-P(break) vs. `sr_close_pbreak`). + Auswertung passiert separat (`engine._evaluate_pbreak_predictions`, gegen + `candles_m1`). Rückgabe = Zeilen-ID.""" + with self._lock, self._connect() as conn: + cur = conn.execute(""" + INSERT INTO pbreak_predictions + (ts, symbol, direction, level, p_break, predicted, price_at_pred, + confirm_price, reject_price, atr) + VALUES (?,?,?,?,?,?,?,?,?,?) + """, (int(time.time()), symbol, direction, level, p_break, predicted, + price_at_pred, confirm_price, reject_price, atr)) + conn.commit() + return cur.lastrowid + + def log_rec_outcome(self, *, signal: str, conf_pct: int, tf: str, + price: float, atr: float) -> int: + """Loggt EINE Empfehlungs-EPISODE zur spaeteren Selbst-Kalibrierung. + + ⚠ WARUM EPISODEN UND NICHT ZEILEN: die Empfehlung wird ~1x/Minute + geloggt; eine Marktlage erzeugt dutzende identische Zeilen. Wer die zaehlt, + haelt 20.000 Faelle fuer eine Stichprobe, obwohl es 1.000 sind + (`analyze_signal_live.py`). Hier wird genau EINE Zeile je Richtungswechsel + geschrieben. + + Ausgewertet wird separat (`engine._evaluate_rec_outcomes`) gegen + `candles_m1` — dasselbe Muster wie `pbreak_predictions`, das den Ausfall + des P(break)-Modells live aufgedeckt hat. Rueckgabe = Zeilen-ID.""" + with self._lock, self._connect() as conn: + cur = conn.execute(""" + INSERT INTO rec_outcomes (ts, signal, conf_pct, tf, price, atr) + VALUES (?,?,?,?,?,?) + """, (int(time.time()), signal, int(conf_pct or 0), tf or "", + float(price or 0.0), float(atr or 0.0))) + conn.commit() + return cur.lastrowid + + def log_cone_check(self, *, bars: int, band: int, price: float, + lo: float, hi: float, skala: float) -> int: + """Legt EINEN Abdeckungs-Check des Kegels an (nicht-ueberlappend).""" + try: + with self._lock, self._connect() as conn: + cur = conn.execute(""" + INSERT INTO cone_checks (ts,bars,band,price,lo,hi,skala) + VALUES (?,?,?,?,?,?,?) + """, (int(time.time()), int(bars), int(band), float(price), + float(lo), float(hi), float(skala))) + conn.commit() + return cur.lastrowid + except Exception: + return 0 + + def cone_coverage(self, bars: int, last_n: int = 100) -> dict: + """Real gemessene Abdeckung der letzten `last_n` Checks eines Horizonts.""" + try: + with self._lock, self._connect() as conn: + rows = conn.execute( + "SELECT drin FROM cone_checks WHERE bars=? AND done=1 " + "ORDER BY id DESC LIMIT ?", (int(bars), int(last_n))).fetchall() + except Exception: + return {"n": 0} + n = len(rows) + if not n: + return {"n": 0} + return {"n": n, "abdeckung": round(100 * sum(r[0] for r in rows) / n, 1)} + + def log_m15_state(self, *, status: str, veto, kosten, level, gap_atr, + p_break, lesart, price, atr) -> int: + """Loggt EINEN Zustandswechsel der M15-Karte. + + ⚠ Nur bei WECHSEL aufrufen, nicht je Tick — sonst entsteht dieselbe + Cluster-Verzerrung, die am 07.08. eine Untersuchung mit elf widerlegten + Hypothesen ausgeloest hat (roh 922 Zeilen mit 52,6 %, entkoppelt 93 mit + 45,2 %). Ausgewertet wird separat gegen `candles_m1`. + ⚠ Fail-open wie der Rest dieser Klasse: ein Logging-Fehler darf den + Handelspfad nie stoeren.""" + try: + with self._lock, self._connect() as conn: + cur = conn.execute(""" + INSERT INTO m15_states + (ts,status,veto,kosten,level,gap_atr,p_break,lesart,price,atr) + VALUES (?,?,?,?,?,?,?,?,?,?) + """, (int(time.time()), status or "", veto, + None if kosten is None else float(kosten), + None if level is None else float(level), + None if gap_atr is None else float(gap_atr), + None if p_break is None else int(p_break), + lesart, float(price or 0.0), float(atr or 0.0))) + conn.commit() + return cur.lastrowid + except Exception: + return 0 + + def m15_accuracy(self, last_n: int = 200) -> dict: + """Trefferquote der LESART (nicht des Status) ueber die letzten + ausgewerteten Zustaende mit gerichteter Lesart. + + ⚠ Gegen 50 % zu lesen, nicht gegen 0. Und ⚠ die Karte behauptet KEINE + freie Richtung — gemessen wird nur, ob die BEDINGTE Lesart am Level + („Abprall -> eher abwaerts") getroffen hat. + + ⚠⚠ ENTKOPPELT seit 2026-08-12. Vorher nahm die Abfrage schlicht die + letzten N Zeilen (`ORDER BY id DESC LIMIT ?`). Die sind NICHT unabhaengig: + der Zustand wechselt ~990x/Tag, 200 Zeilen sind also rund fuenf Stunden + DERSELBEN Marktlage, vielfach gezaehlt. Die Karte zeigte dadurch **60 %**, + entkoppelt sind es **54,4 %** mit dem Intervall [46 … 63] — also nicht von + der Muenze zu unterscheiden. + ⚠ Dieselbe Cluster-Falle hat im Projekt schon die Faelligkeitsbedingung + `pbreak_accuracy_v2` um das ZEHNFACHE danebenliegen lassen (07.08.) und + elf Hypothesen ausgeloest (07.08.). Sie ist hier dieselbe. + ⚠ Schluessel = (Level auf 2 Stellen, Stunde) — identisch zu + `core.stichprobe.schluessel_level_stunde`, damit beide Auswertungen + dieselbe Definition benutzen. + """ + try: + with self._lock, self._connect() as conn: + # ⚠ Grosszuegig lesen und DANACH entkoppeln — sonst waeren nach der + # Entkopplung nur noch eine Handvoll Faelle uebrig. + raw = conn.execute(""" + SELECT hit30, hit60, ret30, level, ts FROM m15_states + WHERE done=1 AND lesart IN ('auf','ab') + ORDER BY id DESC LIMIT ? + """, (int(last_n) * 40,)).fetchall() + offen = conn.execute( + "SELECT COUNT(*) FROM m15_states WHERE done IS NULL " + "AND lesart IN ('auf','ab')").fetchone()[0] + except Exception: + return {"n": 0} + seen, rows = set(), [] + for r in raw: + k = (round(r[3] or 0.0, 2), int((r[4] or 0) // 3600)) + if k in seen: + continue + seen.add(k) + rows.append(r) + if len(rows) >= int(last_n): + break + n = len(rows) + if not n: + return {"n": 0, "offen": offen, "roh": len(raw)} + h30 = [r[0] for r in rows if r[0] is not None] + h60 = [r[1] for r in rows if r[1] is not None] + r30 = [r[2] for r in rows if r[2] is not None] + return {"n": n, "offen": offen, "roh": len(raw), + "wr30": round(100 * sum(h30) / len(h30)) if h30 else None, + "wr60": round(100 * sum(h60) / len(h60)) if h60 else None, + "avg30": round(sum(r30) / len(r30), 3) if r30 else None} + + def rec_accuracy(self, last_n: int = 200) -> dict: + """Trefferquote der letzten `last_n` ausgewerteten Empfehlungs-Episoden. + + ⚠ Die Trefferquote ist gegen 50 % zu lesen, nicht gegen 0 — und der + Ø-Return gegen die Drift des Zeitraums. Gemessen ueber 1.071 Episoden lag + die Richtungstreffer-Quote bei 48–51 % (`analyze_signal_live.py`); diese + Zeile macht laufend sichtbar, ob sich daran etwas aendert.""" + with self._connect() as conn: + rows = conn.execute(""" + SELECT signal, ret30, ret60, hit30, hit60 FROM rec_outcomes + WHERE done = 1 ORDER BY ts DESC LIMIT ? + """, (int(last_n),)).fetchall() + if not rows: + return {"n": 0} + def q(feld, hit): + v = [r[feld] for r in rows if r[feld] is not None] + h = [r[hit] for r in rows if r[hit] is not None] + return ((round(100.0 * sum(h) / len(h), 1) if h else None), + (round(sum(v) / len(v), 3) if v else None), len(h)) + wr30, avg30, n30 = q("ret30", "hit30") + wr60, avg60, n60 = q("ret60", "hit60") + return {"n": len(rows), "wr30": wr30, "avg30": avg30, "n30": n30, + "wr60": wr60, "avg60": avg60, "n60": n60} + + def pbreak_accuracy(self, period: str = "all") -> dict: + """Trefferquote der Abprall/Durchbruch-Prognose (nur ausgewertete Zeilen).""" + since, until = self._range_to_ts(period) + with self._connect() as conn: + rows = conn.execute(""" + SELECT predicted, outcome, correct FROM pbreak_predictions + WHERE outcome IS NOT NULL AND ts BETWEEN ? AND ? + """, (since, until)).fetchall() + pending = conn.execute( + "SELECT COUNT(*) FROM pbreak_predictions WHERE outcome IS NULL" + ).fetchone()[0] + n = len(rows) + n_correct = sum(1 for r in rows if r["correct"]) + n_break_pred = sum(1 for r in rows if r["predicted"] == "break") + n_bounce_pred = sum(1 for r in rows if r["predicted"] == "bounce") + n_break_correct = sum(1 for r in rows if r["predicted"] == "break" and r["correct"]) + n_bounce_correct = sum(1 for r in rows if r["predicted"] == "bounce" and r["correct"]) + return { + "n": n, "n_correct": n_correct, + "accuracy": round(100 * n_correct / n, 1) if n else None, + "n_break_pred": n_break_pred, "n_break_correct": n_break_correct, + "break_accuracy": round(100 * n_break_correct / n_break_pred, 1) if n_break_pred else None, + "n_bounce_pred": n_bounce_pred, "n_bounce_correct": n_bounce_correct, + "bounce_accuracy": round(100 * n_bounce_correct / n_bounce_pred, 1) if n_bounce_pred else None, + "pending": pending, + } + + def log_verdict_votes(self, *, headline: str, conf: int, bias: float, tf: str, + wave: int, m30: int, h1: int, ki: int, ki_w: float, + elliott: int, elliott_w: float, squeeze: int, + news_score=None, pattern: int = 0, pattern_w: float = 0.0, + orderbook: int = 0, orderbook_w: float = 0.0, + liqtrend: int = 0, liqtrend_w: float = 0.0) -> None: + """Verdict-Modul-Stimmen für die spätere Kalibrier-Auswertung loggen + (Copilot/Elliott sind unbelegte Stimmen — nach ein paar Wochen kann + `analyze_verdict_calibration.py`-artig entschieden werden, ob sie + Gewicht behalten). Intern gedrosselt auf max. 1×/60 s.""" + now = time.time() + if now - getattr(self, "_verdict_log_ts", 0.0) < 60: + return + self._verdict_log_ts = now + with self._lock, self._connect() as conn: + conn.execute(""" + INSERT INTO verdict_votes + (ts, headline, conf, bias, tf, wave, m30, h1, ki, ki_w, + elliott, elliott_w, squeeze, news_score, + pattern, pattern_w, orderbook, orderbook_w, liqtrend, liqtrend_w) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, (int(now), headline, conf, bias, tf, wave, m30, h1, ki, ki_w, + elliott, elliott_w, squeeze, news_score, + pattern, pattern_w, orderbook, orderbook_w, liqtrend, liqtrend_w)) + conn.commit() + + def manual_stats(self, tage: int = 30) -> dict: + """Kennzahlen NUR der manuellen Trades — Datengrundlage der Dashboard-Karte + „Manuelle Trades" (2026-08-04). + + ⚠ Bewusst DESKRIPTIV, nicht prädiktiv. Der volle Entscheidungszustand wird + erst seit dem 04.08. mitgeschrieben (`ctx_*`); bis genug davon vorliegt, + wäre jede Vorhersage aus diesen Zahlen Rauschen. Die Karte zeigt deshalb, + was die eigene Historie SAGT, und rechnet nichts hoch. + ⚠ `ctx_abdeckung` macht sichtbar, wie weit der neue Kontext schon reicht — + ohne diese Zahl könnte man die Karte für aussagekräftiger halten, als sie ist. + """ + MAN = ("(setup IS NULL OR (setup NOT LIKE 'AUTOSIG%' " + "AND setup NOT LIKE 'SQUEEZE%'))") + + def agg(row): + n = row["n"] or 0 + return {"n": n, "wr": round(100.0 * (row["w"] or 0) / n, 1) if n else 0.0, + "sum": round(row["s"] or 0.0, 2), + "avg": round((row["s"] or 0.0) / n, 2) if n else 0.0} + + out = {"tage": tage} + with self._connect() as conn: + conn.row_factory = sqlite3.Row + q = (f"SELECT COUNT(*) n, SUM(CASE WHEN pnl>0 THEN 1 ELSE 0 END) w, " + f"SUM(pnl) s FROM trades WHERE pnl IS NOT NULL AND {MAN}") + seit = f"AND exit_time >= strftime('%s','now','-{int(tage)} days')" + out["fenster"] = agg(conn.execute(f"{q} {seit}").fetchone()) + out["gesamt"] = agg(conn.execute(q).fetchone()) + out["heute"] = agg(conn.execute( + f"{q} AND exit_time >= strftime('%s','now','start of day')").fetchone()) + # Mit / ohne Signal-Deckung — die dokumentierte Kern-Frage + for lbl, cond in (("mit_signal", "rec_signal IN ('LONG','SHORT')"), + ("ohne_signal", "rec_signal='WARTEN'")): + out[lbl] = agg(conn.execute(f"{q} {seit} AND {cond}").fetchone()) + # Bot zum Vergleich (dieselbe Fensterlogik) + qb = ("SELECT COUNT(*) n, SUM(CASE WHEN pnl>0 THEN 1 ELSE 0 END) w, " + "SUM(pnl) s FROM trades WHERE pnl IS NOT NULL AND " + "(setup LIKE 'AUTOSIG%' OR setup LIKE 'SQUEEZE%')") + out["bot"] = agg(conn.execute(f"{qb} {seit}").fetchone()) + # Abdeckung des NEUEN Kontexts — ehrlicher Reifegrad-Indikator + r = conn.execute( + f"SELECT COUNT(*) n, SUM(CASE WHEN ctx_atr_m5 IS NOT NULL THEN 1 ELSE 0 END) c " + f"FROM trades WHERE {MAN}").fetchone() + out["ctx_abdeckung"] = { + "n": r["n"] or 0, "mit_ctx": r["c"] or 0, + "pct": round(100.0 * (r["c"] or 0) / (r["n"] or 1), 1)} + return out + + def alignment_stats(self, last_n: int = 0) -> dict: + """Ausrichtungs-Split: liefen die Trades MIT, GEGEN oder OHNE die Empfehlung? + + ⚠⚠ ZWEI AENDERUNGEN AM 2026-08-06, beide gemessen begruendet: + (1) **Volle Historie statt der letzten 40 Trades** (`last_n=0`). Ueber + 1.216 geschlossene Trades ist der Befund beidhaelftig robust; ueber 40 + war er eine Momentaufnahme. Das ist die einzige Richtungsaussage ueber + die Gesamtempfehlung, die den 2-Stichproben-Test besteht — sie sollte + auf der ganzen Stichprobe stehen. + (2) **Zusaetzlich JE LOT normiert** (`pnl_lot`). Absolute Euro-Vergleiche + ueber Zeitraeume mit unterschiedlicher Positionsgroesse sind ungueltig + — am 04.08. sah ein reiner Groessenzuwachs wie ein Strategie-Effekt aus + (Ø +55 € gegen +19 €, je Lot aber identisch). Das Order-Dialog-Feld + zeigt deshalb beides. + + Gemessener Stand (06.08., ganze Historie, je Lot): + MIT n=354 WR 56,5 % +3,22 €/Lot (H1 +0,37 / H2 +5,62) + GEGEN n= 87 WR 40,2 % −6,60 €/Lot (H1 −7,01 / H2 −5,68) + OHNE n=775 WR 56,6 % +0,36 €/Lot (kippt zwischen den Haelften) + MIT und OHNE sind praktisch gleich — der Empfehlung zu FOLGEN bringt + gemessen nichts, nur sie zu VERLETZEN kostet. Sie ist ein Veto, keine + Prognose. + """ + sql = ("SELECT direction, rec_signal, pnl, lots FROM trades " + "WHERE exit_time IS NOT NULL AND pnl IS NOT NULL AND lots > 0 " + "ORDER BY exit_time DESC") + args: tuple = () + if last_n and last_n > 0: + sql += " LIMIT ?" + args = (last_n,) + with self._connect() as conn: + rows = conn.execute(sql, args).fetchall() + + def fasse(grp: list) -> dict: + n = len(grp) + if not n: + return {"n": 0, "wr": None, "pnl": 0.0, "pnl_lot": None} + pnl = sum(r["pnl"] for r in grp) + lots = sum(r["lots"] for r in grp) or 0.0 + return {"n": n, + "wr": round(100 * sum(1 for r in grp if r["pnl"] > 0) / n), + "pnl": round(pnl, 2), + "pnl_lot": round(pnl / lots, 2) if lots > 0 else None} + + mit, gegen, ohne = [], [], [] + for r in rows: + rec = (r["rec_signal"] or "").upper() + if rec not in ("LONG", "SHORT"): + ohne.append(r) + elif (rec == "LONG") == (r["direction"] == "BUY"): + mit.append(r) + else: + gegen.append(r) + return {"with": fasse(mit), "against": fasse(gegen), "none": fasse(ohne)} + + # ══════════════════════════════════════════ + # STATISTIK-QUERIES + # ══════════════════════════════════════════ + + @staticmethod + def _range_to_ts(period: str) -> tuple[int, int]: + """'today' | 'week' | 'month' | 'all' → (since_ts, now_ts).""" + now = int(time.time()) + if period == "today": + today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + return (int(today.timestamp()), now) + if period == "yesterday": + today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + return (int((today - timedelta(days=1)).timestamp()), int(today.timestamp())) + if period == "week": + since = datetime.now() - timedelta(days=7) + return (int(since.timestamp()), now) + if period == "month": + since = datetime.now() - timedelta(days=30) + return (int(since.timestamp()), now) + return (0, now) + + def tag_bot_trade(self, ticket: int, setup: str) -> bool: + """Setup einer bereits geloggten Position NACHTRAGEN. → True bei Erfolg. + + ⚠ WARUM DAS NOETIG IST: eine gefuellte PENDING-Order laeuft nicht durch + `engine._open`, sondern wird vom Positions-Abgleich als „magic-match" + adoptiert und mit `log_trade_open(...)` OHNE Setup geschrieben. Am 06.08. + fuellten drei `SQZ-STOP`-Orders und landeten dadurch als `setup=NULL` in der + DB — ununterscheidbar von manuellen Trades. Folge: B4-Monitor und die + Reminder `squeeze_b5` / `squeeze_entry_gap` zaehlten 0, obwohl der Bot + gehandelt hatte. Genau die Messpipeline, die den Pending-Umbau + kontrollieren soll, war blind fuer ihn. + Nur setzen, wenn noch KEIN Setup steht — ein manuell/regulaer getaggter + Trade darf nicht ueberschrieben werden.""" + try: + with self._connect() as conn: + cur = conn.execute( + "UPDATE trades SET setup=? WHERE ticket=? AND setup IS NULL", + (setup, int(ticket))) + return cur.rowcount > 0 + except Exception: + # ⚠ `core/history.py` fuehrt bewusst keinen Logger (Konvention der + # Datei: stilles Abfangen). Ein fehlgeschlagenes Nachtragen ist + # unkritisch — der Trade steht in der DB, nur ohne Setup-Tag; der + # Aufrufer sieht das am Rueckgabewert. + return False + + def stats_overview(self, period: str = "all") -> dict: + """Liefert ein Dict mit den Hauptkennzahlen für den gewählten Zeitraum.""" + since, until = self._range_to_ts(period) + with self._connect() as conn: + # Trades (nur abgeschlossene) + rows = conn.execute(""" + SELECT direction, lots, entry_time, exit_time, entry_price, + exit_price, pnl, closed_by, ai_sentiment, ai_confidence, + rec_signal + FROM trades + WHERE exit_time IS NOT NULL + AND exit_time BETWEEN ? AND ? + ORDER BY exit_time + LIMIT 10000 + """, (since, until)).fetchall() + + n = len(rows) + wins = [r for r in rows if (r["pnl"] or 0) > 0] + losses = [r for r in rows if (r["pnl"] or 0) < 0] + breakeven = [r for r in rows if (r["pnl"] or 0) == 0] + + total_pnl = sum((r["pnl"] or 0) for r in rows) + avg_win = (sum(r["pnl"] for r in wins) / len(wins)) if wins else 0.0 + avg_loss = (sum(r["pnl"] for r in losses) / len(losses)) if losses else 0.0 + gross_win = sum(r["pnl"] for r in wins) + gross_loss = sum(-r["pnl"] for r in losses) + profit_factor = (gross_win / gross_loss) if gross_loss > 0 else None + + # Closed-by Verteilung + cb_counts = {} + for r in rows: + cb = r["closed_by"] or "unknown" + cb_counts[cb] = cb_counts.get(cb, 0) + 1 + + # KI-Trefferquote + ai_correct = ai_total = 0 + for r in rows: + sent = (r["ai_sentiment"] or "").lower() + pnl = r["pnl"] or 0 + direction = (r["direction"] or "").upper() + if sent in ("bullish", "bearish") and pnl != 0: + ai_total += 1 + # KI bullish + Long-Trade gewonnen, oder bearish + Short gewonnen + ai_aligned = ( + (sent == "bullish" and direction == "BUY" and pnl > 0) or + (sent == "bearish" and direction == "SELL" and pnl > 0) or + (sent == "bullish" and direction == "SELL" and pnl < 0) or + (sent == "bearish" and direction == "BUY" and pnl < 0) + ) + if ai_aligned: + ai_correct += 1 + + # Wochentag-Verteilung + weekday_stats = {i: {"wins": 0, "losses": 0} for i in range(7)} + for r in rows: + wd = datetime.fromtimestamp(r["exit_time"]).weekday() + if (r["pnl"] or 0) > 0: + weekday_stats[wd]["wins"] += 1 + elif (r["pnl"] or 0) < 0: + weekday_stats[wd]["losses"] += 1 + + # Stunden-Verteilung + hour_stats = {h: {"wins": 0, "losses": 0} for h in range(24)} + for r in rows: + hr = datetime.fromtimestamp(r["exit_time"]).hour + if (r["pnl"] or 0) > 0: + hour_stats[hr]["wins"] += 1 + elif (r["pnl"] or 0) < 0: + hour_stats[hr]["losses"] += 1 + + # Top-3 Stunden (mind. 2 Trades) + top_hours = sorted( + [(h, s["wins"], s["losses"]) for h, s in hour_stats.items() + if (s["wins"] + s["losses"]) >= 2], + key=lambda x: (x[1] / max(1, x[1]+x[2]), x[1]+x[2]), + reverse=True)[:3] + + return { + "period": period, + "since": since, + "until": until, + "n_trades": n, + "n_wins": len(wins), + "n_losses": len(losses), + "n_be": len(breakeven), + "winrate": (len(wins) / n * 100) if n else 0.0, + "total_pnl": total_pnl, + "avg_win": avg_win, + "avg_loss": avg_loss, + "gross_win": gross_win, + "gross_loss": gross_loss, + "profit_factor": profit_factor, + "closed_by": cb_counts, + "ai_correct": ai_correct, + "ai_total": ai_total, + "ai_winrate": (ai_correct / ai_total * 100) if ai_total else None, + "weekday_stats": weekday_stats, + "hour_stats": hour_stats, + "top_hours": top_hours, + } + + def setup_stats(self, period: str = "all") -> list[dict]: + """ + Performance-Aufschlüsselung pro Setup-Typ. + Liefert eine Liste {setup, n, wins, losses, winrate, total_pnl, avg_pnl, profit_factor} + sortiert nach total_pnl absteigend. + """ + since, until = self._range_to_ts(period) + with self._connect() as conn: + rows = conn.execute(""" + SELECT setup, pnl + FROM trades + WHERE exit_time IS NOT NULL + AND exit_time BETWEEN ? AND ? + AND setup IS NOT NULL + """, (since, until)).fetchall() + + groups: dict[str, list[float]] = {} + for r in rows: + groups.setdefault(r["setup"] or "UNKNOWN", []).append(r["pnl"] or 0.0) + + out = [] + for setup, pnls in groups.items(): + wins = [p for p in pnls if p > 0] + losses = [p for p in pnls if p < 0] + gross_w = sum(wins) + gross_l = sum(-p for p in losses) + out.append({ + "setup": setup, + "n": len(pnls), + "wins": len(wins), + "losses": len(losses), + "winrate": (len(wins) / len(pnls) * 100) if pnls else 0.0, + "total_pnl": sum(pnls), + "avg_pnl": sum(pnls) / len(pnls) if pnls else 0.0, + "gross_win": gross_w, + "gross_loss": gross_l, + "profit_factor": (gross_w / gross_l) if gross_l > 0 else None, + }) + out.sort(key=lambda x: x["total_pnl"], reverse=True) + return out + + def setup_multipliers(self, *, min_trades: int = 10, + lookback_days: int = 90) -> dict[str, dict]: + """ + Lernt aus historischen Trades einen Confidence-Multiplikator pro Setup. + + Mapping: + Profit-Factor 1.0 (break-even) → 1.0 (kein Effekt) + Profit-Factor ≥ 2.0 (sehr profitabel)→ 1.3 (Score +30 %) + Profit-Factor ≤ 0.5 (klar verlierend)→ 0.5 (Score −50 %) + Lineare Interpolation dazwischen. + + Setups unter `min_trades` bekommen Multiplikator 1.0 (Neutral, noch zu + wenig Daten). Lookback begrenzt auf `lookback_days` Tage, damit + Regime-Wechsel berücksichtigt werden. + + Rückgabe: {setup_name: {"multiplier": float, "n": int, + "profit_factor": float|None, "winrate": float}} + """ + cutoff = int(time.time()) - lookback_days * 86400 + with self._connect() as conn: + rows = conn.execute(""" + SELECT setup, pnl + FROM trades + WHERE exit_time IS NOT NULL + AND exit_time >= ? + AND setup IS NOT NULL + """, (cutoff,)).fetchall() + + groups: dict[str, list[float]] = {} + for r in rows: + groups.setdefault(r["setup"], []).append(r["pnl"] or 0.0) + + out: dict[str, dict] = {} + for setup, pnls in groups.items(): + n = len(pnls) + wins = [p for p in pnls if p > 0] + losses = [p for p in pnls if p < 0] + gross_w = sum(wins) + gross_l = sum(-p for p in losses) + pf = (gross_w / gross_l) if gross_l > 0 else ( + float("inf") if gross_w > 0 else 1.0) + wr = (len(wins) / n * 100) if n else 0.0 + + if n < min_trades: + mult = 1.0 + else: + # Mapping pf → multiplier (geclampt) + pf_capped = min(max(pf, 0.3), 3.0) + if pf_capped >= 1.0: + # 1.0 → 1.0, 2.0 → 1.3, 3.0 → 1.3 (gecapped) + mult = 1.0 + min(0.30, (pf_capped - 1.0) * 0.30) + else: + # 1.0 → 1.0, 0.5 → 0.5, 0.3 → 0.5 (gecapped) + mult = max(0.50, 1.0 - (1.0 - pf_capped) * 1.0) + out[setup] = { + "multiplier": round(mult, 3), + "n": n, + "profit_factor": pf if pf != float("inf") else None, + "winrate": wr, + } + return out + + # ══════════════════════════════════════════ + # AUTO-TRADE DRY-RUN + # ══════════════════════════════════════════ + def intended_open(self, *, direction: str, open_price: float, + sl: float, tp: float, + setup: str, regime: str | None, + rsi: float | None, score: float, conf_pct: int, + news_score: float | None, + ai_sentiment: str | None, + ai_confidence: int | None) -> int: + """Loggt einen hypothetischen Auto-Trade. Liefert die neue Row-ID.""" + with self._lock, self._connect() as conn: + cur = conn.execute(""" + INSERT INTO intended_trades + (open_ts, open_price, direction, sl, tp, + setup, regime, rsi, score, conf_pct, + news_score, ai_sentiment, ai_confidence) + VALUES (?,?,?,?,?, ?,?,?,?,?, ?,?,?) + """, (int(time.time()), open_price, direction, sl, tp, + setup, regime, rsi, score, conf_pct, + news_score, ai_sentiment, ai_confidence)) + conn.commit() + return cur.lastrowid + + def intended_close(self, *, row_id: int, close_price: float, + close_reason: str): + """Schließt einen hypothetischen Trade. Berechnet pnl_pct vorzeichenrichtig.""" + with self._lock, self._connect() as conn: + row = conn.execute( + "SELECT open_price, direction FROM intended_trades " + "WHERE id = ? AND close_ts IS NULL", (row_id,) + ).fetchone() + if not row: + return # bereits geschlossen oder nicht vorhanden + op = row["open_price"] or 0.0 + if op <= 0: + pnl_pct = 0.0 + else: + diff = (close_price - op) if row["direction"] == "BUY" \ + else (op - close_price) + pnl_pct = diff / op * 100.0 + conn.execute(""" + UPDATE intended_trades + SET close_ts = ?, close_price = ?, + close_reason = ?, pnl_pct = ? + WHERE id = ? + """, (int(time.time()), close_price, close_reason, pnl_pct, row_id)) + conn.commit() + + def intended_open_list(self) -> list[dict]: + """Liefert alle noch nicht geschlossenen intended trades.""" + with self._connect() as conn: + rows = conn.execute(""" + SELECT * FROM intended_trades WHERE close_ts IS NULL + ORDER BY open_ts DESC + """).fetchall() + return [dict(r) for r in rows] + + def intended_today_count(self) -> int: + """Anzahl heute geöffneter hypothetischer Trades (für Daily-Cap).""" + from datetime import datetime as _dt + today = _dt.now().replace(hour=0, minute=0, second=0, microsecond=0) + since = int(today.timestamp()) + with self._connect() as conn: + return conn.execute( + "SELECT COUNT(*) FROM intended_trades WHERE open_ts >= ?", + (since,) + ).fetchone()[0] + + def intended_last_close_ts(self) -> int: + """Timestamp des letzten geschlossenen intended trade (für Cooldown). + Flip-Closes zählen nicht — nach einem Signal-Flip darf die neue + Richtung sofort eröffnet werden (schnelle Breakout-Reaktion).""" + with self._connect() as conn: + row = conn.execute( + "SELECT MAX(close_ts) FROM intended_trades " + "WHERE close_ts IS NOT NULL " + " AND COALESCE(close_reason, '') != 'flip'" + ).fetchone() + return int(row[0] or 0) + + def intended_summary(self, period: str = "all") -> dict: + """Performance-Zusammenfassung aller hypothetischen Trades.""" + since, until = self._range_to_ts(period) + with self._connect() as conn: + rows = conn.execute(""" + SELECT pnl_pct, close_reason, setup FROM intended_trades + WHERE close_ts IS NOT NULL + AND close_ts BETWEEN ? AND ? + """, (since, until)).fetchall() + n = len(rows) + wins = sum(1 for r in rows if (r["pnl_pct"] or 0) > 0) + losses = sum(1 for r in rows if (r["pnl_pct"] or 0) < 0) + total_pnl_pct = sum(r["pnl_pct"] or 0 for r in rows) + avg_pnl_pct = total_pnl_pct / n if n else 0.0 + by_reason: dict[str, int] = {} + for r in rows: + by_reason[r["close_reason"] or "?"] = by_reason.get(r["close_reason"] or "?", 0) + 1 + return { + "n": n, + "wins": wins, + "losses": losses, + "winrate": (wins / n * 100) if n else 0.0, + "total_pnl_pct": total_pnl_pct, + "avg_pnl_pct": avg_pnl_pct, + "by_reason": by_reason, + } + + def last_closed_trades(self, n: int = 5) -> list[dict]: + """Liefert die letzten n geschlossenen Trades (neueste zuerst).""" + with self._connect() as conn: + rows = conn.execute(""" + SELECT direction, lots, entry_time, exit_time, + entry_price, exit_price, pnl, commission, closed_by, setup + FROM trades + WHERE exit_time IS NOT NULL + ORDER BY exit_time DESC + LIMIT ? + """, (n,)).fetchall() + return [dict(r) for r in rows] + + def open_trades(self) -> list[dict]: + """Liefert alle Trades, für die noch kein exit_time eingetragen ist.""" + with self._connect() as conn: + rows = conn.execute(""" + SELECT id, ticket, symbol, direction, entry_time, entry_price + FROM trades + WHERE exit_time IS NULL + ORDER BY entry_time + """).fetchall() + return [dict(r) for r in rows] + + def consistency_report(self) -> dict: + """ + Diagnostiziert Inkonsistenzen in der History-DB. + Liefert ein Dict mit erkannten Problemen: + + • n_open_trades_db — Anzahl "offene" Trades in DB + • n_orphan_old — offene Trades älter als 14 Tage (verwaist) + • n_negative_pnl_no_loss — geschlossene Trades mit pnl < 0 aber wins+losses=0 + • n_missing_setup — geschlossene Trades ohne setup-Spalte + • n_missing_exit_price — geschlossene Trades ohne exit_price + • duplicate_tickets — Tickets, die mehrfach existieren (sollte 0 sein) + • db_size_mb + • oldest_trade_age_days + """ + now = int(time.time()) + out = { + "timestamp": now, + "n_open_trades_db": 0, + "n_orphan_old": 0, + "n_missing_setup": 0, + "n_missing_exit_price": 0, + "n_pnl_zero_closed": 0, + "duplicate_tickets": [], + "db_size_mb": self.db_size_mb(), + "oldest_trade_age_days": None, + } + ORPHAN_AGE_S = 14 * 86400 + + with self._connect() as conn: + # Open trades + opens = conn.execute( + "SELECT ticket, entry_time FROM trades WHERE exit_time IS NULL" + ).fetchall() + out["n_open_trades_db"] = len(opens) + out["n_orphan_old"] = sum( + 1 for r in opens if (r["entry_time"] or now) < now - ORPHAN_AGE_S + ) + + # Geschlossene Trades ohne Setup-Spalte (alte Daten vor Migration) + out["n_missing_setup"] = conn.execute(""" + SELECT COUNT(*) FROM trades + WHERE exit_time IS NOT NULL AND (setup IS NULL OR setup = '') + """).fetchone()[0] + + # Geschlossene Trades ohne exit_price + out["n_missing_exit_price"] = conn.execute(""" + SELECT COUNT(*) FROM trades + WHERE exit_time IS NOT NULL + AND (exit_price IS NULL OR exit_price = 0) + """).fetchone()[0] + + # Geschlossene Trades mit pnl = 0 — aber NUR die, die NICHT + # bewusst als 'unknown' markiert sind (Phantom-Cleanup). Trades + # mit closed_by='unknown' AND pnl=0 sind gewollt (uns fehlen + # die echten Exit-Daten), das ist keine Anomalie. + out["n_pnl_zero_closed"] = conn.execute(""" + SELECT COUNT(*) FROM trades + WHERE exit_time IS NOT NULL + AND (pnl IS NULL OR pnl = 0) + AND COALESCE(closed_by, '') != 'unknown' + """).fetchone()[0] + + # Duplikat-Tickets (UNIQUE-Constraint sollte das verhindern, + # aber wir prüfen trotzdem) + dups = conn.execute(""" + SELECT ticket, COUNT(*) c FROM trades + GROUP BY ticket HAVING c > 1 + """).fetchall() + out["duplicate_tickets"] = [{"ticket": r["ticket"], "count": r["c"]} + for r in dups] + + # Ältester Trade + row = conn.execute( + "SELECT MIN(entry_time) FROM trades" + ).fetchone() + if row and row[0]: + out["oldest_trade_age_days"] = (now - row[0]) / 86400.0 + + return out + + def all_trades(self, period: str = "all") -> list[dict]: + """Vollständige Trade-Liste für CSV-Export.""" + since, until = self._range_to_ts(period) + with self._connect() as conn: + rows = conn.execute(""" + SELECT * FROM trades + WHERE entry_time BETWEEN ? AND ? + ORDER BY entry_time DESC + """, (since, until)).fetchall() + return [dict(r) for r in rows] + + def export_csv(self, file_path: Path, period: str = "all") -> int: + """Exportiert Trades als CSV. Liefert Anzahl exportierter Zeilen.""" + import csv + rows = self.all_trades(period) + if not rows: + return 0 + with open(file_path, "w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=rows[0].keys()) + writer.writeheader() + writer.writerows(rows) + return len(rows) + + def db_size_mb(self) -> float: + try: + return self.db_path.stat().st_size / (1024 * 1024) + except Exception: + return 0.0 + + def n_total_trades(self) -> int: + with self._connect() as conn: + return conn.execute( + "SELECT COUNT(*) FROM trades WHERE exit_time IS NOT NULL" + ).fetchone()[0] diff --git a/web/app.js b/web/app.js index eb7e8ca..8970e22 100644 --- a/web/app.js +++ b/web/app.js @@ -153,6 +153,58 @@ function beepRecommendation(sig, isNew) { // muss jedes Gate EINZELN passieren. Ein Gate, das fällt, lässt sich nicht // durch ein anderes ausgleichen. // ══════════════════════════════════════════════════════════════════════════ +// Breakout-Autotrader-Karte (2026-08-19). Nur ANZEIGE aus vorhandenen +// Snapshot-Feldern -- hier wird nichts neu gerechnet. +// Die Karte bleibt VERSTECKT, solange `squeeze_b5` fehlt (aelterer Server, das +// Feld gibt es erst nach einem Neustart). Eine halb gefuellte Karte waere +// schlimmer als keine: sie saehe aus wie ein Zustand statt wie eine fehlende +// Quelle. +function renderBrk(d) { + const box = $streng("card-brk"); + if (!box) return; + const b5 = d.squeeze_b5; + if (!b5) { box.classList.add("hidden"); return; } + box.classList.remove("hidden"); + + const an = !!d.auto_squeeze; + const nh = (d.squeeze_night_hours || []).map((h) => h + ":00").join(", "); + $("brk-state").textContent = (an ? "AN" : "AUS") + + (d.squeeze_pending ? " · ruhende Stop-Order" : " · Market-Fallback") + + (nh ? " · Nachtsperre " + nh : ""); + $("brk-state").className = an ? "ok" : "warn"; + + const sq = (d.wave || {}).squeeze || {}; + const st = sq.state; + $("brk-squeeze").textContent = + st === "active" ? "AUSBRUCH " + (sq.dir || "") + " @ " + (sq.level ?? "?") + : st === "armed" ? "komprimiert — Ausbruch steht bevor" + : "keine Kompression (Box " + (sq.box_atr ?? "?") + "×ATR)"; + + const pl = d.pending_levels || []; + $("brk-pending").textContent = pl.length + ? pl.length + " liegend (" + (d.pending_quelle || "?") + ")" + : "keine — " + (an ? "Squeeze nicht armiert oder Position offen" : "Trader aus"); + + // B5: der Stand der einzigen Regel, die die Autonomie beenden kann. + const r = b5.ratio, pf = b5.pf; + const erf = r != null && pf != null && r >= b5.latte && pf >= 1; + $("brk-b5").textContent = b5.n + "/" + b5.ziel + " Trades · Verhältnis " + + (r == null ? "—" : r.toFixed(2)) + " · PF " + + (pf == null ? "—" : pf.toFixed(2)) + + " · " + b5.sum_eur.toFixed(2) + " €"; + $("brk-b5").className = erf ? "ok" : "bad"; + + const el = $("brk-note"); + if (el) { + el.textContent = erf + ? "Latte (Verhältnis ≥ 1,0 UND PF ≥ 1) erfüllt." + : "⚠ Latte verfehlt (Ziel: Verhältnis ≥ 1,0 UND PF ≥ 1). Der Monitor " + + "im Statistik-Tab meldet daneben „ontrack“ — das kommt aus der MECHANIK " + + "(Regel neu gerechnet), nicht aus diesen echten Trades. Die Lücke " + + "zwischen beiden ist der Befund, kein Widerspruch."; + } +} + function renderM15(s) { const st = $("m15-status"); if (!s) { st.className = "m15-status"; st.textContent = "— keine Daten"; return; } @@ -936,6 +988,7 @@ function render(d) { // ── Gesamtempfehlung (aggregiert) ─────── renderM15(d.m15_setup); + renderBrk(d); renderKontext(d); renderCircuitBreaker(d.circuit_breaker); renderPatterns(d.patterns); diff --git a/web/app.js.bak-2026-08-19-brk b/web/app.js.bak-2026-08-19-brk new file mode 100644 index 0000000..eb7e8ca --- /dev/null +++ b/web/app.js.bak-2026-08-19-brk @@ -0,0 +1,2015 @@ +"use strict"; +// ── Live-Anbindung an server.py via WebSocket (Fallback: Polling) ────────── + +// ⚠⚠ AUFFANG-ELEMENT (2026-08-07) — behebt die haeufigste Ausfallart des +// Dashboards. `index.html` und `app.js` werden vom Browser UNABHAENGIG +// gecacht; trifft neue JS auf alte HTML, war `$("neu").textContent = …` eine +// TypeError — und weil das mitten in `render()` passiert, brach der GESAMTE +// Render ab: das Dashboard fror auf Altwerten ein, obwohl Server und Snapshot +// korrekt waren. Real passiert beim HL-Kurs-Einbau (01.08.), beinahe erneut +// beim Entfernen der Setup-Zeile (06.08., 8 Folgezugriffe auf `w`). +// Die Gegenregel „neue Elemente per `const el = $(…); if (el) {…}`" stand seit +// 01.08. in CLAUDE.md — GEMESSEN befolgt wurde sie bei **0 von 95** direkten +// Zugriffen. Eine Regel mit 0 % Befolgung ist keine Regel, also loest es jetzt +// der Code an EINER Stelle statt an 95. +// ⚠ `?.` waere hier keine Loesung gewesen: **80 der 95** Zugriffe sind +// ZUWEISUNGEN, und `$("x")?.y = v` ist ein Syntaxfehler (Optional Chaining ist +// links vom `=` verboten). +// ⚠ Der Fehler wird NICHT verschluckt, sondern EINMAL je ID in die Konsole +// gemeldet — ein stiller Fallback waere derselbe Fehler wie der `catch {}` in +// `pollSnapshot`, der Render-Fehler lautlos gefressen hat (behoben 01.08.). +const _FEHLT = document.createElement("span"); +const _fehlendGemeldet = new Set(); +const $ = (id) => { + const el = document.getElementById(id); + if (el) return el; + if (!_fehlendGemeldet.has(id)) { + _fehlendGemeldet.add(id); + console.warn(`[app] Element #${id} fehlt — vermutlich alte index.html im ` + + `Cache. Schreibzugriffe laufen ins Leere, der Rest rendert ` + + `weiter. Abhilfe: ?v=N in index.html hochzaehlen.`); + } + return _FEHLT; +}; + +// ⚠ STRENGE Variante — gibt `null` zurueck, wenn das Element fehlt. +// Zwingend fuer Pfade, die einen gelesenen Wert an den BROKER schicken: dort +// ist „Feld fehlt" NICHT dasselbe wie „Feld ist leer". Beim SL/TP-Feld heisst +// leer ausdruecklich „Stop entfernen" — ein Auffang-Element wuerde daraus ein +// stilles Loeschen des Broker-SL einer laufenden Position machen. Ein lauter +// Absturz ist dort das bessere Verhalten als ein stiller Schaden. +const $streng = (id) => document.getElementById(id); + +function fmt(v, d = 3) { + return (v === null || v === undefined || isNaN(v)) ? "—" : Number(v).toFixed(d); +} +function curSym(m) { + const c = m && m.currency; + return c === "EUR" ? "€" : c === "USD" ? "$" : (c ? c + " " : "$"); +} + +// ── Akustischer CLOSE-Alarm ───────────────────────────────────────────────── +// Web-Audio (kein Sound-File). Mobile entsperrt Audio erst nach der ersten +// Nutzer-Interaktion → wir starten/reaktivieren den Context bei jedem Tap. +let _audioCtx = null, _lastBeep = 0; +function _unlockAudio() { + try { + if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + if (_audioCtx.state === "suspended") _audioCtx.resume(); + } catch (e) {} +} +window.addEventListener("pointerdown", _unlockAudio); +window.addEventListener("touchstart", _unlockAudio); + +// Globaler Ton-Schalter (Mute-Button oben rechts; überlebt Reloads via localStorage) +let _muted = localStorage.getItem("oil_muted") === "1"; +function _tone(freq, startOffset, dur) { + if (_muted) return; + const o = _audioCtx.createOscillator(), g = _audioCtx.createGain(); + o.type = "square"; o.frequency.value = freq; + o.connect(g); g.connect(_audioCtx.destination); + const t = _audioCtx.currentTime + startOffset; + g.gain.setValueAtTime(0.0001, t); + g.gain.exponentialRampToValueAtTime(0.35, t + 0.02); + g.gain.exponentialRampToValueAtTime(0.0001, t + dur); + o.start(t); o.stop(t + dur + 0.03); +} +function beepCloseAlert() { + if (_muted) return; + if (!_audioCtx || _audioCtx.state !== "running") return; // erst nach 1. Tap aktiv + const now = Date.now(); + if (now - _lastBeep < 4000) return; // max. alle 4 s + _lastBeep = now; + _tone(880, 0, 0.18); // Zwei-Ton-Alarm (hoch→tief) + _tone(620, 0.22, 0.20); +} +// Ton bei Empfehlung: LONG = aufsteigend, SHORT = absteigend (vom Close-Alarm +// unterscheidbar). Sofort beim Signal-Wechsel + WIEDERHOLUNG alle 30 s, solange +// der Button blinkt (Empfehlung aktiv & flat) — einmaliger Piep war überhörbar. +const _REC_REBEEP_MS = 30000; +let _lastRecBeep = 0, _lastRecSignal = null, _audioHintShown = false, _lastSrCloseCount = null; + +// ── Laufzeit-Uhr des offenen Trades ─────────────────────────────────────────── +// Öffnungszeit kommt als echte Epoch (Broker-Offset serverseitig korrigiert) im +// Snapshot (`position.open_time`); der Ticker läuft eigenständig jede Sekunde, +// damit die Uhr auch zwischen den Snapshots (4 s Poll / 1 s WS) flüssig zählt. +let _tradeOpenTime = 0; +function _fmtDuration(sec) { + sec = Math.max(0, Math.floor(sec)); + const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60; + const p2 = (n) => String(n).padStart(2, "0"); + return h > 0 ? `${h}:${p2(m)}:${p2(s)}` : `${m}:${p2(s)}`; +} +function _tickTradeDuration() { + const el = document.getElementById("tb-dur"), hd = document.getElementById("tb-runtime"); + if (!el) return; + if (!_tradeOpenTime) { el.textContent = "—"; if (hd) hd.textContent = ""; return; } + const sec = Date.now() / 1000 - _tradeOpenTime; + const txt = _fmtDuration(sec); + el.textContent = txt; + if (hd) hd.textContent = "⏱ " + txt; + // Ab 2 h dezent hervorheben (Time-Stop greift bei 120 min in Phase „Init") + el.className = "tb-val" + (sec >= 7200 ? " warn" : ""); +} +setInterval(_tickTradeDuration, 1000); +function beepRecommendation(sig, isNew) { + if (_muted) return; // auch kein Sperr-Toast + if (!_audioCtx || _audioCtx.state !== "running") { + // Audio noch nicht freigeschaltet (Mobile braucht 1× Tippen) → 1× erklären + if (isNew && !_audioHintShown) { + _audioHintShown = true; + toast("🔇 Ton gesperrt — einmal auf die Seite tippen aktiviert ihn", false); + } + return; + } + const now = Date.now(); + if (now - _lastRecBeep < (isNew ? 3000 : _REC_REBEEP_MS)) return; + _lastRecBeep = now; + if (sig === "LONG") { _tone(520, 0, 0.16); _tone(780, 0.18, 0.20); } // ↑ + else { _tone(780, 0, 0.16); _tone(520, 0.18, 0.20); } // ↓ +} + +// ── Gesamtempfehlung: Konfidenz-Ring + Bias-Balken + Modul-Chips ─────────── +// ══════════════════════════════════════════════════════════════════════════ +// `renderVerdict` ENTFERNT (2026-08-08). Die Gesamtempfehlung aggregierte die +// Module zu Headline, Konfidenz-Ring, Bias-Nadel und Konsens-Zeile — gemessen +// ohne Prognosewert (48–51 % Trefferquote über 1.071 Live-Episoden), und der +// Konfidenz-Ring war nicht kalibriert (in der zweiten Stichprobe invertiert). +// ⚠ Die Order-Logik hing NIE daran: Blinken, Order-Dialog und Entry-Checkliste +// lesen `wave_signal` bzw. `checklist` — das ist unberührt. +// Was in der alten Funktion nur mitlief und hier weiterlebt: +// · Selbst-Kalibrierung (`#kx-selfcal`) — misst das WELLEN-Signal, das bleibt +// · HL-Kontext (`#hl-note`) — schreibt in die Meldungen-Karte, nicht hierher +// Entfallen ist die Block-Grund-Zeile (`#vd-blocks`). +// ══════════════════════════════════════════════════════════════════════════ +// ══════════════════════════════════════════════════════════════════════════ +// M15 SETUP-BEREITSCHAFT — sequenzielle GATE-KETTE, keine Score-Aggregation. +// [HTF-BIAS] → [KOSTEN] → [LEVEL & KEGEL] → AUSFÜHRUNG NUR AM LEVEL +// ⚠ Bewusst KEINE Richtungsaussage. Auf M15 trägt gemessen keine (Squeeze +// n=33/Nachbarn kippen, VWAP-Bänder ohne Edge). Die Karte sagt, ob die +// BEDINGUNGEN stimmen — der Auslöser bleibt beim Menschen. +// ⚠ Der Unterschied zur entfernten Gesamtempfehlung ist grundsätzlich: dort +// wurden Stimmen zu einem Bias VERRECHNET (gemessen 48–51 % Treffer), hier +// muss jedes Gate EINZELN passieren. Ein Gate, das fällt, lässt sich nicht +// durch ein anderes ausgleichen. +// ══════════════════════════════════════════════════════════════════════════ +function renderM15(s) { + const st = $("m15-status"); + if (!s) { st.className = "m15-status"; st.textContent = "— keine Daten"; return; } + const f = (v, d = 2) => (v == null ? "—" : (+v).toFixed(d)); + + // ── Gate 1: HTF-Bias (der einzige belegte Filter, Edge ×2) ─────────── + // ⚠⚠ VETO, KEINE RICHTUNG (Korrektur 2026-08-08). Der erste Entwurf zeigte + // „Bias ▲ LONG" und erzeugte am 07.08. dreizehn LONG-Episoden an einem Tag, + // der 1,02 $ FIEL (Trefferquote 0 %). Gemessen ist nur: GEGEN den M30-Trend + // zu handeln halbiert den Edge — eine Verbots-, keine Richtungsaussage. + const b = s.bias || {}; + const bOk = !!b.verboten; // Gate offen, sobald ein HTF steht + $("m15-bias").className = "m15-item " + (bOk ? "ok" : ""); + $("m15-bias").textContent = bOk + ? `① Veto: ${b.verboten} vermeiden (HTF ${b.m30}${b.einig ? " + H1 einig" : ""})` + : `① kein Veto — HTF flach (M30 ${b.m30 ?? "—"} / H1 ${b.h1 ?? "—"})`; + + // ── Gate 2: Kosten auf M15 ─────────────────────────────────────────── + const k = s.kosten || {}; + const kOk = k.stufe === "guenstig" || k.stufe === "normal"; + $("m15-kosten").className = "m15-item " + (k.ratio == null ? "" : kOk ? "ok" : "fail"); + // ⚠ Slippage-Hinweis: gemessen steigt die Exit-Slippage monoton mit ΔATR + // (0,020 → 0,084 $, Faktor 4,2). ABER der Bootstrap auf die Differenz enthält + // die Null (n≈85 je Quartil) — deshalb HINWEIS, kein Gate, und die + // Beschriftung sagt „historisch", nicht „belegt". + const vo = s.vola || {}; + const slipTxt = vo.stufe === "hoch" + ? " · ⚠ Vola zieht an — Slippage historisch ~4× höher" + : vo.stufe === "ruhig" ? " · Vola ruhig" : ""; + $("m15-kosten").textContent = k.ratio == null + ? "② Kosten —" + : `② Kosten ${f(k.ratio, 3)}×ATR · ${ + k.stufe === "guenstig" ? "günstig" : k.stufe === "normal" ? "normal" : "TEUER"}` + + slipTxt; + + // ── Gate 3: Level in Reichweite ────────────────────────────────────── + // ⚠⚠ `zOk` kommt seit 2026-08-12 aus dem BACKEND (`s.status`), nicht mehr aus + // einem eigenen Schwellenvergleich hier. Grund: die Karte wechselte den + // Zustand alle 12 Sekunden — Schwellen-Flattern an `gap ≤ 0,5`. Das Backend + // rechnet es EINMAL mit Hysterese (grün ab 0,50, zurück erst über 0,60) und + // liefert Status und Lesart mit; Anzeige und Selbstmessung können damit nicht + // mehr auseinanderlaufen (sie taten es bereits: der Logger kannte den + // „kein Veto"-Fall nicht). + const z = (s.zonen || []); + const nah = z.filter(x => x.gap_atr15 != null && x.gap_atr15 <= 0.5); + const zOk = s.status ? (s.status === "gruen") : (nah.length > 0); + $("m15-zone").className = "m15-item " + (z.length ? (zOk ? "ok" : "warn") : ""); + // ⚠⚠ RICHTUNGS-LESART, aber BEDINGT auf das Level. Eine FREIE Prognose ist + // gemessen unmoeglich (`analyze_reversal.py`: AUC 0,499–0,509 oos und + // **in-sample nur 0,52** — die Merkmale enthalten keine Richtungsinformation; + // Live-Richtung 48–51 % ueber 1.071 Episoden). P(break) fragt dagegen nicht + // „wohin geht der Kurs", sondern „haelt DIESE Struktur?" — bedingt auf ein + // reales Objekt mit Orders dahinter, AUC 0,65 oos. + // ⚠ 45–55 % bekommt BEWUSST keine Richtung: dort ist die Zahl ein Muenzwurf, + // und daraus einen Pfeil zu machen waere genau der Fehler, den die + // MQL5-Pfeile schon einmal vermieden haben. + const lesart = (x) => { + const p = x.p_break, res = x.seite === "res"; + if (p == null) return ""; + if (p >= 45 && p <= 55) return " → unentschieden"; + const bricht = p > 55; + // Widerstand gebrochen = aufwaerts · Widerstand haelt = abwaerts + const auf = res ? bricht : !bricht; + const q = bricht ? `${p} % Bruch` : `${100 - p} % Abprall`; + return ` → ${q}, eher ${auf ? "aufwärts ▲" : "abwärts ▼"}`; + }; + // ⚠⚠ ZEITEBENE MIT IM TEXT (Fix 2026-08-13). ③ liest M30-Level und misst in + // ATR_M15, ③b liest M5-Level und misst in ATR_M5. Beide standen vorher nur als + // „×ATR" da — zwei Zahlen nebeneinander (0,48 und 0,45), die vergleichbar + // AUSSAHEN und verschiedene Bezugsgroessen hatten. Die Quellen-Trennung ist + // Absicht (`entry_room_atr=0,6` ist auf M5 kalibriert), die stumme + // Beschriftung war es nicht. + // ⚠ „(Chart)" markiert, dass GENAU diese Linie im MT5-Chart gezeichnet wird — + // die M5-Level aus ③b werden dort NICHT gezeichnet. + $("m15-zone").textContent = z.length + ? "③ " + z.map(x => `${x.seite === "res" ? "Widerstand" : "Support"} M30 ` + + `${f(x.level, 3)} (Chart · ${f(x.gap_atr15)}×ATR_M15)${lesart(x)}`).join(" · ") + : "③ kein Level in Sicht"; + $("m15-zone").title = "GENAU diese Linie wird im MT5-Chart gezeichnet (M30-Pivots, " + + "geclustert) — der Chart nennt seit v1.35 dieselbe Zahl mit denselben Worten. " + + "⚠ Die Level in ③b stammen dagegen aus M5 und stehen NICHT im Chart. " + + "Richtungs-Lesart BEDINGT auf das Level, keine freie " + + "Prognose: eine solche ist gemessen unmöglich (AUC 0,50 out-of-sample und " + + "nur 0,52 in-sample — es steckt keine Richtungsinformation in den Daten). " + + "P(break) fragt stattdessen „hält diese Struktur?“ und ist out-of-sample " + + "kalibriert (AUC 0,65). ⚠ Live derzeit rund 7 Pp zu niedrig — die Abprall-" + + "Quote ist also eher noch etwas optimistisch. 45–55 % bleibt ohne Richtung."; + + // ── Reichweite (Kegel) — sagt WIE WEIT, nicht wohin ────────────────── + // ⚠ REINE GEOMETRIE: passt das Ziel noch in das 80-%-Band des jeweiligen + // Horizonts? Keine Bruch-Annahme — die wäre nicht belegt. + // ⚠⚠ Die Richtung steht seit dem Fix vom 2026-08-10 MIT im Text. Vorher war + // das Ziel rein geometrisch das entferntere Level und stand real GEGEN das + // Veto aus ② („verboten SHORT" + Ziel 0,15 $ unter dem Kurs). Ein Ziel ohne + // genannte Richtung lässt sich still wieder verdrehen — mit genannter nicht. + const zi = s.ziel; + const r = s.reichweite || []; + const spanne = r.length + ? " · Spanne " + r.map(x => `${x.minutes}′ ${f(x.lo, 2)}–${f(x.hi, 2)}`).join(" · ") + : ""; + const rEl = $("m15-reich"); + if (zi && zi.horizonte && zi.horizonte.length) { + const drin = zi.horizonte.filter(x => x.drin).map(x => x.minutes); + const pfeil = zi.richtung === "LONG" ? "▲" : "▼"; + rEl.className = "m15-item " + (drin.length ? "ok" : "warn"); + rEl.textContent = drin.length + ? `④ Ziel ${pfeil} ${f(zi.level, 3)} erreichbar ab ${Math.min(...drin)}′ ` + + `(${zi.strecke} $ vom Kurs, Einstieg ${f(zi.von, 3)})` + : `④ Ziel ${pfeil} ${f(zi.level, 3)} liegt AUSSERHALB des 120′-Kegels ` + + `(${zi.strecke} $) — CRV zeitlich nicht gedeckt`; + } else if (s.ziel_grund) { + // Kein berechenbares Ziel ist ein EIGENER Zustand, keine Lücke — + // deshalb wird der Grund genannt statt eine Zahl erfunden. + rEl.className = "m15-item"; + rEl.textContent = `④ kein Ziel — ${s.ziel_grund}${spanne}`; + } else { + rEl.className = "m15-item"; + rEl.textContent = r.length + ? "④ erwartete Spanne · " + r.map(x => `${x.minutes}′ ${f(x.lo, 2)}–${f(x.hi, 2)}`).join(" · ") + : "④ erwartete Spanne —"; + } + // ── ③b RAUM + Motor-Zustand ────────────────────────────────────────── + // ⚠⚠ ANLASS: Trade #49671601 (10.08., −87 €). Die Karte stand auf GRÜN, + // während der Signal-Motor zu 76 % mit `entry_room` blockte — der Widerstand + // lag 0,07×ATR voraus, der Stop 4,7×ATR zurück. Die Karte prüfte, ob ein + // Level NAH ist, nie ob RAUM dahinter ist. Genau das steht jetzt hier. + // ⚠ Reine Anzeige: keine Richtung, kein Blockieren der Buttons. + const mo = s.motor || {}; + const rm = mo.raum || {}; + // Dieselbe Zahl zusaetzlich AUF die Order-Knoepfe (2026-08-19). Sie wird + // hier NICHT neu gerechnet — `rm` liegt bereits vor; ein zweiter Rechenweg + // waere genau die Divergenz-Falle, die im Projekt dreimal zugeschlagen hat. + for (const st of ["long", "short"]) { + const el = $("raum-" + st); + const o = rm[st]; + if (!o || o.dist_atr == null) { el.textContent = ""; el.className = "btn-raum"; continue; } + const eng = o.frei === false; + el.textContent = (eng ? "⚠ " : "") + o.dist_atr.toFixed(2) + "×ATR"; + el.className = "btn-raum" + (eng ? " eng" : ""); + el.title = "Abstand zum naechsten Level in DIESER Richtung (M5-Pivots, " + + "Gate " + (rm.gate ?? 0.6) + "×ATR). Unter dem Gate gemessen teuer: " + + "79 % Trefferquote, aber −7 bis −12 €/Lot — der Gewinn ist am Level " + + "gedeckelt, der Verlust laeuft bis zum Stop. Reine Anzeige."; + } + + const moEl = $("m15-motor"); + const seite = (o, lbl) => { + if (!o) return null; + if (o.level == null) return `${lbl} frei`; + return `${lbl} ${o.dist_atr}×ATR_M5${o.frei ? "" : " ⚠"}`; + }; + if (mo.block) { + // Der Motor blockt — das ist die Information, die gefehlt hat. + const gr = { + entry_room: "kein Raum zum Gegenlevel", + breakout_pending: "Bestätigung läuft noch", + min_conf: `Konfidenz ${mo.conf_pct ?? "?"} % zu schwach`, + htf_counter: "gegen den M30-Trend", + stretch: "überdehnt", + deadband: "Totband — kein Trend", + eia: "EIA-Blackout", + dead_hour: "gesperrte Stunde", + stale: "Daten veraltet", + no_data: "keine Daten" + }[mo.block] || mo.block; + moEl.className = "m15-item " + (mo.block === "entry_room" ? "warn" : ""); + moEl.textContent = `③b Motor blockt: ${gr}`; + } else if (mo.signal) { + moEl.className = "m15-item ok"; + moEl.textContent = `③b Motor frei · ${mo.signal}`; + } else { + moEl.className = "m15-item"; + moEl.textContent = "③b Motor —"; + } + // ⚠ „M5" und „×ATR_M5" ausgeschrieben: diese Level stammen aus einer ANDEREN + // Zeitebene als ③ (M30) und werden im Chart NICHT gezeichnet. Ohne den Zusatz + // sucht man die genannte Linie dort vergeblich. + const teile = [seite(rm.long, "▲"), seite(rm.short, "▼")].filter(Boolean); + if (teile.length) { + moEl.textContent += ` · Raum ${teile.join(" / ")}` + + (rm.gate ? ` (Gate ${rm.gate})` : ""); + } + moEl.title = "Was der Signal-Motor GERADE blockt, plus der Raum zum nächsten " + + "Gegenlevel je Richtung. ⚠ Quelle sind M5-Pivots und ATR_M5 — eine ANDERE " + + "Zeitebene als ③ (M30-Level, ATR_M15), und diese Linien werden im Chart NICHT " + + "gezeichnet. Das ist Absicht: entry_room=0,6 ist über 80k Bars auf M5 " + + "kalibriert. Die beiden ×ATR-Zahlen sind deshalb NICHT vergleichbar. " + + "Dieselbe Rechnung wie das Gate selbst, kein " + + "Nachbau. Unter 0,6×ATR ist der Ertrag am Level gedeckelt: gemessen " + + "ØR −0,119/−0,067 bei PF 0,42/0,66 — Trefferquote hoch und trotzdem " + + "Verlust. ⚠ Gate ③ liest dieselbe Nähe als GUT (ruhende Order AM Level), " + + "entry_room als SCHLECHT (Markt-Einstieg mit dem Level als Hindernis). " + + "Beides stimmt — für verschiedene Trades. Blockt nichts."; + + rEl.title = "Reine Geometrie auf dem Kegel: liegt das Ziel im 80-%-Band des " + + "Horizonts? Das Ziel wird NUR in der von ② erlaubten Richtung gebildet — " + + "ein rein geometrisches Ziel zeigte am 10.08. auf die verbotene Seite. " + + "Beim Ausbruchs-Einstieg gibt es kein berechenbares Ziel: _draw_levels " + + "liefert bewusst nur die nächste Linie je Seite."; + + // ── Veto: die eigene Bilanz, JE LOT ────────────────────────────────── + const v = s.veto || {}; + const g = v.against || {}; + $("m15-veto").className = "m15-item " + (g.n >= 5 ? "warn" : ""); + $("m15-veto").textContent = g.n >= 5 + ? `⑤ gegen das Signal: ${g.n} Trades, ${f(g.pnl_lot, 2)} € je Lot` + : "⑤ Veto: noch zu wenige Trades"; + + // ── ⑥ Selbstmessung: die Karte zeigt ihre EIGENE Trefferquote ──────── + // ⚠ Gegen 50 % lesen. Gemessen wird die BEDINGTE Lesart am Level, nicht + // eine freie Prognose — die ist gemessen unmöglich (AUC 0,52 in-sample). + // ⚠⚠ ENTKOPPELT seit 2026-08-12. Vorher stand hier die ROHE Quote der letzten + // 200 Zeilen — bei ~990 Zustandswechseln/Tag sind das fünf Stunden derselben + // Marktlage, vielfach gezählt. Angezeigt wurden dadurch **60 %**, entkoppelt + // sind es **53 %**. Dieselbe Cluster-Falle hat im Projekt schon eine + // Fälligkeitsbedingung um das Zehnfache danebenliegen lassen. + // ⚠ `n` ist jetzt die ENTKOPPELTE Zahl (max 1 je Level+Stunde), `roh` die + // gelesene — beide werden genannt, damit die Schrumpfung sichtbar bleibt. + const tk = s.m15_track || {}; + const tEl = $("m15-track"); + if (tk.n >= 20 && tk.wr30 != null) { + tEl.className = "m15-item " + (tk.wr30 >= 55 ? "ok" : tk.wr30 <= 45 ? "warn" : ""); + tEl.textContent = `⑥ eigene Trefferquote (${tk.n} unabh. Zustände` + + (tk.roh ? ` aus ${tk.roh}` : "") + `): ${tk.wr30} % auf 30′` + + (tk.wr60 != null ? ` · ${tk.wr60} % auf 60′` : "") + + ` · Ø ${tk.avg30 == null ? "—" : (tk.avg30 >= 0 ? "+" : "") + f(tk.avg30, 3)}×ATR`; + } else { + tEl.className = "m15-item"; + tEl.textContent = `⑥ eigene Trefferquote: sammelt noch` + + (tk.n ? ` (${tk.n} von 20 ausgewertet)` : "") + + (tk.offen ? ` · ${tk.offen} offen` : ""); + } + + // ── ⑦ Ausführungsqualität — der einzige gemessen grosse Hebel ──────── + const eg = s.entry_gap || {}; + const eEl = $("m15-exec"); + if (eg.n_seit_umbau != null) { + const genug = eg.n_seit_umbau >= (eg.soll || 12); + eEl.className = "m15-item " + (genug ? "ok" : ""); + eEl.textContent = `⑦ Ausführung: ${eg.n_seit_umbau} Bot-Trades seit dem ` + + `Stop-Order-Umbau (Prüfung ab ${eg.soll || 12})`; + } else { + eEl.className = "m15-item"; + eEl.textContent = "⑦ Ausführung —"; + } + + // ── Status: die Kette wird der Reihe nach ausgewertet ──────────────── + // ⚠ Das Banner nennt AUSDRUECKLICH keine Richtung. Es sagt, ob die + // BEDINGUNGEN fuer eine Order am Level stehen — und was verboten ist. + // ⚠⚠ KLARTEXT-KOPFZEILE (2026-08-11, User: „noch etwas vereinfachen, wie: + // jetzt long/short zu xxx Prozent"). Der Wunsch ist berechtigt — die Karte + // war sieben Zeilen Technik. Umgesetzt ist er aber NICHT als „LONG zu 78 %": + // · eine RICHTUNGS-Prognose ist gemessen ein Münzwurf (48–51 % über 1.071 + // Live-Episoden, `analyze_signal_live.py`; frei sogar AUC 0,52 IN-sample), + // · und die Prozentzahl war als `conf_pct` NICHT kalibriert, in H2 sogar + // invertiert (85–100 % → 40 % Trefferquote, `analyze_verdict_calibration`). + // Genau daran ist die Gesamtempfehlung gescheitert und wurde entfernt. + // Die Zeile sagt deshalb, WAS ZU TUN IST, und nennt die EINE kalibrierte + // Prozentzahl, die es gibt: P(break) des Levels, an dem eingestiegen würde + // (AUC 0,65 oos). Die ist eine Aussage über die STRUKTUR, nicht über den Trade. + // ⚠ Bewusst KEINE Änderung der Gate-Semantik — nur die Formulierung. Ob der + // Raum (③b) das Banner auf gelb ziehen soll, ist ungemessen und bleibt offen. + const erl = b.verboten === "SHORT" ? "LONG" : b.verboten === "LONG" ? "SHORT" : null; + const zi2 = s.ziel; + const pTxt = (lvl) => { + const zz = (s.zonen || []).find(x => x.level === lvl); + if (!zz || zz.p_break == null) return ""; + if (zz.p_break >= 45 && zz.p_break <= 55) return " · Level unentschieden"; + return zz.p_break > 55 + ? ` · bricht zu ${zz.p_break} %` + : ` · hält zu ${100 - zz.p_break} %`; + }; + let cls, txt; + if (!kOk && k.ratio != null) { + cls = "rot"; + txt = `🔴 JETZT: nicht handeln — Spread frisst den Edge (${f(k.ratio)}×ATR)`; + } else if (!erl) { + // Ohne Veto ist keine Richtung ausgeschlossen — und eine zu nennen wäre + // wieder eine Prognose. Die Karte sagt dann ehrlich nichts. + cls = "gelb"; + txt = "🟡 JETZT: kein Trend-Veto — die Karte gibt hier keine Richtung"; + } else if (!zOk) { + cls = "gelb"; + const w = nah.length ? "" : ` · nächstes Level ${f((z[0] || {}).gap_atr15)}×ATR entfernt`; + txt = `🟡 JETZT: warten auf das Level${w} · erlaubt wäre ${erl}`; + } else { + cls = "gruen"; + const einstieg = zi2 ? zi2.von : nah[0].level; + const art = zi2 ? "Abpraller" : "Ausbruch"; + txt = `🟢 JETZT: ${erl}-Order bei ${f(einstieg, 3)} (${art})${pTxt(einstieg)}` + + (zi2 ? ` · Ziel ${f(zi2.level, 3)}` : "") + + ` · ⛔ ${b.verboten} meiden`; + } + st.className = "m15-status " + cls; + st.textContent = txt; + st.title = "Klartext-Zusammenfassung der Gate-Kette darunter. ⚠ Bewusst KEINE " + + "Angabe wie 'LONG zu 78 %': die Richtung ist über 1.071 Live-Episoden mit " + + "48–51 % Trefferquote gemessen (Münzwurf), und die frühere Konfidenz-Prozent" + + "zahl war nicht kalibriert — in der zweiten Hälfte sogar invertiert. " + + "Genannt wird nur P(break) des Einstiegs-Levels: out-of-sample kalibriert " + + "(AUC 0,65), eine Aussage über die STRUKTUR, nicht über den Trade — live " + + "derzeit rund 7 Pp zu niedrig. 'Erlaubt' heisst NICHT 'empfohlen': belegt " + + "ist nur, dass die Gegenrichtung kostet (−6,60 € je Lot, beidhälftig). " + + "⚠ Die Order-Buttons bleiben bedienbar — hartes Blockieren ist verworfen, " + + "Übersteuerungen des Users liefen gemessen 68 % Trefferquote."; +} + + +function renderKontext(snap) { + // ── Selbst-Kalibrierung: die Empfehlung zeigt ihre eigene Trefferquote ── + // ⚠ Gegen 50 % zu lesen, nicht gegen 0 — es ist eine Richtungsaussage. + // Ausgangsstand (analyze_signal_live.py, 1.071 Episoden): 48–51 %. + const trkEl = $("kx-selfcal"); + const tr = snap && snap.rec_track; + if (tr && tr.n >= 20 && tr.wr30 != null) { + const d = (x) => (x == null ? "—" : (x >= 0 ? "+" : "") + fmt(x, 3)); + const cls = tr.wr30 >= 55 ? "up" : tr.wr30 <= 45 ? "down" : ""; + trkEl.className = "kx-selfcal " + cls; + trkEl.textContent = + `🎯 Trefferquote der Empfehlung (letzte ${tr.n} Episoden): ${tr.wr30} % auf 30′` + + (tr.wr60 != null ? ` · ${tr.wr60} % auf 60′` : "") + + ` · Ø ${d(tr.avg30)}×ATR`; + trkEl.title = "Selbst-Kalibrierung: jede gerichtete Empfehlung wird nach " + + "30 und 60 Minuten gegen den echten Kurs ausgewertet. Gegen 50 % lesen, " + + "nicht gegen 0. Gemessener Ausgangsstand über 1.071 Episoden: 48–51 %."; + } else { + trkEl.className = "kx-selfcal dim"; + trkEl.textContent = `🎯 Trefferquote der Empfehlung: sammelt noch` + + (tr && tr.n ? ` (${tr.n} von 20 Episoden ausgewertet)` : ""); + trkEl.title = "Jede gerichtete Empfehlung wird nach 30/60 Minuten gegen " + + "den echten Kurs ausgewertet. Ab 20 ausgewerteten Episoden erscheint " + + "die Quote hier."; + } + + // ── HL-Kontext (Funding / OI / Premium) — reine Anzeige ─────────────── + // ⚠ Gemessen ohne Prognosewert (analyze_hl_funding.py): entüberlappt hält + // kein Bucket die 2-Stichproben-Regel. Schreibt in die MELDUNGEN-Karte. + const hlEl = $("hl-note"); + const h = snap && snap.hl_ctx; + if (h && h.premium != null) { + const fr = (h.funding != null) ? (h.funding * 100).toFixed(4) + " %" : "—"; + const oi = (h.open_interest != null) + ? (h.open_interest / 1e6).toFixed(2) + " Mio" : "—"; + hlEl.classList.remove("hidden"); + hlEl.textContent = `⛓ Hyperliquid: Perp ${h.premium >= 0 ? "+" : ""}` + + `${fmt(h.premium, 3)} $ zum Oracle · Funding ${fr}/h · OI ${oi}`; + hlEl.title = "Kontext vom Hyperliquid-Öl-Perp (xyz:CL). Positives Premium = " + + "der Perp notiert über dem Index, Longs zahlen. ⚠ Gemessen über 5.095 " + + "Funding-Stundenwerte (7 Monate) gegen den CFD: kein belastbarer " + + "Prognosewert."; + } else { + hlEl.classList.add("hidden"); + } +} + + +function renderStatsCompact(sc, m) { + sc = sc || {}; + const cs = curSym(m || {}); + const f2 = (v) => (v == null ? "—" : (+v).toFixed(2)); + const block = (label, s) => { + if (!s || !s.n) return `
${label}
` + + `
noch keine Trades
`; + const ratioCls = (s.ratio != null && s.ratio >= 1) ? "up" : "down"; + const pfCls = (s.pf != null && s.pf >= 1) ? "up" : "down"; + const pnlCls = (s.pnl >= 0) ? "up" : "down"; + return `
${label} · ${s.n} Trades
` + + `
` + + `WR ${s.wr}%` + + `PF ${f2(s.pf)}` + + `Verh ${f2(s.ratio)}` + + `Øgew +${cs}${f2(s.avg_win)}` + + `Øverl ${cs}${f2(s.avg_loss)}` + + `PnL ${s.pnl >= 0 ? "+" : ""}${cs}${f2(s.pnl)}` + + `
`; + }; + $("stx-row").innerHTML = block("Woche", sc.week) + block("Gesamt", sc.all); +} + +function renderGaps(g, m) { + const el = $("gaps-list"); + const list = (g && g.gaps) || []; + if (!list.length) { + el.innerHTML = 'keine offenen Gaps'; + return; + } + const bid = (m || {}).bid; + el.innerHTML = list.map(x => { + const up = x.dir === "up"; + const arr = up ? "▲" : "▼"; + const dist = bid != null ? (x.fill - bid) : null; + const ds = dist != null ? ` (${dist >= 0 ? "+" : ""}${fmt(dist)})` : ""; + // ⚠ Die zwei Gap-Arten unterscheidbar machen (2026-08-03): nur `vacuum` + // speist die Wellen-Konfidenz (darauf wurde gemessen), `close_open` ist + // reine Anzeige. Ohne den Hinweis sähen beide gleich verbindlich aus. + const co = x.kind === "close_open"; + const tag = co + ? `Eröffnung` + : `Vakuum`; + return `
${arr} ${x.dir.toUpperCase()}` + + `${fmt(x.lo)}–${fmt(x.hi)}` + + `schließt bei ${fmt(x.fill)}${ds}` + + tag + + `${x.date}
`; + }).join(""); +} + +// Manuelle Trades (2026-08-04) — DESKRIPTIV. Zeigt die eigene Historie, macht +// keine Vorhersage: der volle Entscheidungskontext wird erst seit dem 04.08. +// mitgeschrieben, vorher wären Aussagen daraus Rauschen. Die Reifegrad-Zeile +// nennt die Abdeckung ehrlich, damit die Karte nicht überschätzt wird. +function renderManual(m) { + const grid = $("mt-grid"); if (!grid) return; // null-sicher (Cache-Regel) + if (!m || !m.gesamt) { grid.innerHTML = ''; return; } + const cls = v => v > 0 ? "up" : v < 0 ? "down" : ""; + const box = (lbl, b) => !b || !b.n ? "" : + `
${lbl}
` + + `
${b.sum >= 0 ? "+" : ""}${fmt(b.sum, 2)}
` + + `
${b.n} Trades · ${fmt(b.wr, 1)} % · Ø ${b.avg >= 0 ? "+" : ""}${fmt(b.avg, 2)}
`; + grid.innerHTML = box("heute", m.heute) + box(`${m.tage} Tage`, m.fenster) + + box("gesamt", m.gesamt) + box("Bot (Vergleich)", m.bot); + + // Signal-Deckung: die dokumentierte Kern-Frage — trägt Handeln OHNE Empfehlung? + const sp = $("mt-split"); + if (sp) { + const z = (lbl, b) => !b || !b.n ? "" : + `${lbl} ${b.n}× · ${fmt(b.wr, 1)} % · ` + + `${b.sum >= 0 ? "+" : ""}${fmt(b.sum, 2)}`; + sp.innerHTML = z("mit Signal", m.mit_signal) + z("ohne Signal", m.ohne_signal); + } + const nt = $("mt-note"); + if (nt) { + const c = m.ctx_abdeckung || {}; + nt.textContent = `Entscheidungs-Kontext gespeichert für ${c.mit_ctx || 0} von ` + + `${c.n || 0} Trades (${fmt(c.pct || 0, 1)} %). Erst ab ausreichender ` + + `Abdeckung lässt sich messen, WORAN gute von schlechten Einstiegen zu ` + + `unterscheiden sind — bis dahin ist diese Karte reine Beschreibung.`; + } +} + +// Marktstruktur (M30) — reine Anzeige (HH/HL/LH/LL, BOS, Regressionskanal). +function renderStructure(s) { + s = s || {}; + const TR = { up: ["▲ Aufwärts", "up"], down: ["▼ Abwärts", "down"], range: ["→ Seitwärts", "dim"] }; + const [txt, cls] = TR[s.trend] || ["—", "dim"]; + const tEl = $("ms-trend"); + tEl.textContent = txt; tEl.className = "ms-trend " + cls; + + // Swing-Chips: HH/HL = bullisch (grün), LH/LL = bärisch (rot) + const chips = (s.swings || []).map(sw => { + const bull = sw.type === "HH" || sw.type === "HL"; + const c = (sw.type === "H" || sw.type === "L") ? "dim" : (bull ? "up" : "down"); + return `${sw.type}`; + }).join(''); + $("ms-swings").innerHTML = chips || ''; + + // BOS + const b = s.bos; + const bEl = $("ms-bos"); + if (b) { + const up = b.dir === "up"; + bEl.innerHTML = `BOS` + + `${up ? "▲ über" : "▼ unter"} ${fmt(b.level)}` + + `vor ${b.bars_ago} Bars`; + bEl.classList.remove("hidden"); + } else { bEl.classList.add("hidden"); } + + // Kanal: Richtung + Position (0 = unten, 1 = oben) + const ch = s.channel; + const CD = { up: ["Aufwärtskanal", "up"], down: ["Abwärtskanal", "down"], flat: ["Seitwärtskanal", "dim"] }; + if (ch) { + const [ctxt, ccls] = CD[ch.dir] || ["Kanal", "dim"]; + const dEl = $("ms-chan-dir"); + dEl.textContent = ctxt; dEl.className = ccls; + const pct = Math.round((ch.pos != null ? ch.pos : 0.5) * 100); + $("ms-chan-fill").style.width = pct + "%"; + $("ms-chan-dot").style.left = pct + "%"; + const zone = pct <= 25 ? "am unteren Rand" : pct >= 75 ? "am oberen Rand" : "in der Mitte"; + $("ms-chan-note").textContent = `Kurs ${zone} (${pct}%)`; + $("card-structure").classList.remove("hidden"); + } else { + $("ms-chan-dir").textContent = "—"; + $("ms-chan-note").textContent = ""; + $("ms-chan-fill").style.width = "0%"; + } +} + +// Circuit Breaker — roter Banner wenn ausgelöst, bernstein wenn nah (>60% des Limits). +function renderCircuitBreaker(cb) { + const el = $("circuit-banner"); + if (!el) return; + if (!cb || !cb.enabled) { el.className = "circuit hidden"; el.textContent = ""; return; } + const limit = cb.limit_eur, day = cb.day_total; + if (cb.tripped) { + el.className = "circuit tripped"; + el.textContent = `🛑 CIRCUIT BREAKER AKTIV — Tagesverlust ${fmt(day, 2)} € erreicht ` + + `(Limit −${fmt(limit, 0)} €). Keine Auto-Trades mehr heute.`; + } else if (limit && day < 0 && Math.abs(day) >= 0.6 * limit) { + el.className = "circuit near"; + el.textContent = `⚠ Tagesverlust ${fmt(day, 2)} € — Stopp bei −${fmt(limit, 0)} € ` + + `(${Math.round(100 * Math.abs(day) / limit)} % erreicht)`; + } else { + el.className = "circuit hidden"; el.textContent = ""; + } +} + +// Chartmuster (visuell) — reine Anzeige. Status: bildet sich / bestätigt / (ungültig raus). +// Kompakte SVG-Skizze der Muster-Form (viewBox 60×32). Rein schematisch, damit die +// Form auf einen Blick erkennbar ist; Farbe = Richtung (grün bull / rot bear / grau). +function _patternSvg(type, col) { + const P = { + double_top: "M2,26 L14,8 L22,16 L34,8 L46,26", + double_bottom: "M2,6 L14,24 L22,16 L34,24 L46,6", + hs: "M2,24 L12,14 L18,20 L28,4 L38,20 L44,14 L54,24", + inv_hs: "M2,8 L12,18 L18,12 L28,28 L38,12 L44,18 L54,8", + cup: "M2,6 C2,30 44,30 44,6 M44,6 L54,12", + inv_cup: "M2,26 C2,2 44,2 44,26 M44,26 L54,20", + tri_asc: "M2,26 L52,26 M2,4 L52,22", + tri_desc: "M2,6 L52,6 M2,28 L52,10", + tri_sym: "M2,4 L52,16 M2,28 L52,16", + }; + const d = P[type] || "M2,16 L54,16"; + return ``; +} + +// Kerzen-Anatomie (M5). ⚠ Bewusst OHNE grün/rot-Richtungsfarbe: die Lehrbuch- +// Deutung ist gemessen invertiert (`backtest_candles.py` — langer Körper läuft eher +// GEGEN sich, Volumen verstärkt das statt zu bestätigen), eine Ampel würde also +// systematisch in die falsche Richtung stupsen. Dieselbe Korrektur wie bei der +// Bounce-Anzeige, die deshalb heute „Überdehnung" heißt. +function renderCandles(ca) { + const el = $("ca-list"); + if (!el) return; + const items = (ca && ca.items) || []; + if (!items.length) { + el.innerHTML = `Keine markante Kerzenform in den letzten Bars.`; + return; + } + el.innerHTML = items.map(m => { + const arr = m.dir > 0 ? "▲" : m.dir < 0 ? "▼" : "◆"; + const ago = m.bars_ago === 1 ? "letzte Kerze" : `vor ${m.bars_ago} Kerzen`; + const vol = m.vol_x + ? `Vol ${fmt(m.vol_x, 1)}×` : ""; + return `
+
${arr} ${m.label} + ${vol}${ago}
+
Körper ${m.body_pct}% · Docht oben ${m.wick_up_pct}% + · unten ${m.wick_lo_pct}%
+
${m.reading || ""}
+
`; + }).join("") + + `
🕯 Reine Anzeige — KEIN Signal. Gemessen + (backtest_candles.py, 80k Bars, 2 Halbjahre): die gängige Deutung ist + invertiert — der lange Körper läuft eher GEGEN sich (−0,06/−0,08×ATR), mit + 2× Volumen noch stärker (−0,12/−0,20). Als Handelssignal durchgefallen + (backtest_candle_fade.py).
`; +} + +// Wahrscheinlichkeits-Kegel. ⚠ Zeigt STREUUNG, nicht Richtung — und beschriftet +// mit der REAL gemessenen Abdeckung (~76 %), nicht mit dem Nennwert 80 %. Der Kegel +// ist out-of-sample geprüft (analyze_cone.py, Fit H1 → Abdeckung H2) und damit nach +// dem P(break)-Modell die zweite kalibrierte Komponente im Projekt. +// ⚠ Seit 2026-08-08 in der Karte „Marktkontext" (`#kx-cone`) — die frühere +// eigenen Karte (User-Vorgabe). Gezeigt wird nur noch das 80 %-Band je Horizont — +// das enge 50 %-Band und der Fließtext sind entfallen und stecken im `title`. +function renderCone(cn) { + const el = $("kx-cone"); + if (!el) return; // null-sicher (Cache-Regel) + const lv = (cn && cn.levels) || []; + const b80 = lv.filter(x => x.band === 80) + .sort((a, b) => a.minutes - b.minutes); + if (!b80.length) { el.innerHTML = ""; return; } + const cov = b80[0].coverage != null ? `${fmt(b80[0].coverage, 0)} %` : "~"; + // ⚠ ACI (2026-08-08): die Bänder waren dokumentiert 3–5 Pp ZU ENG (80 % + // nominal → real 76,7/76,6/75,3). Adaptive Conformal Inference justiert das + // jetzt online nach; die Skala zeigt, wie stark. 1,00 = noch unverändert. + const sk = b80.map(x => x.skala).filter(v => v != null); + const skTxt = (sk.length && sk.some(v => Math.abs(v - 1) > 0.005)) + ? `ACI ×${fmt(sk.reduce((a, b) => a + b, 0) / sk.length, 2)}` + : ""; + el.innerHTML = `📐 erwartete Spanne` + + b80.map(x => `${x.minutes}′` + + `${fmt(x.lo, 2)}–${fmt(x.hi, 2)}`).join("") + + `trifft ${cov}` + skTxt; + // Die Einordnung gehört an die Zahl, nicht in einen Absatz darunter — sonst + // liest jemand die Spanne als Richtungsprognose. + el.title = "STREUUNG, nicht Richtung: wie WEIT der Kurs plausibel läuft, nicht " + + "wohin. 80 %-Band, out-of-sample geprüft (analyze_cone.py, Fit auf einem " + + "Halbjahr, Abdeckung im anderen gemessen) — es trifft real ~76 %, ist also " + + "leicht zu eng. Kein Signal, kein Einfluss auf die Empfehlung."; +} + +function renderPatterns(pt) { + const el = $("pt-list"); + if (!el) return; + const items = (pt && pt.patterns) || []; + if (!items.length) { + el.innerHTML = `Aktuell kein klares Muster erkannt.`; + return; + } + el.innerHTML = items.map(m => { + const dirCls = m.dir === "bull" ? "up" : m.dir === "bear" ? "down" : "dim"; + const col = m.dir === "bull" ? "#3fb950" : m.dir === "bear" ? "#f85149" : "#8b949e"; + const arr = m.dir === "bull" ? "▲" : m.dir === "bear" ? "▼" : "◆"; + const st = m.status === "confirmed" + ? `bestätigt` + : `bildet sich`; + const tgt = m.target != null + ? `Ziel ${fmt(m.target, 2)}` : `Ausbruch offen`; + const q = Math.round((m.quality || 0) * 100); + return `
+
${_patternSvg(m.type, col)} + ${arr} ${m.name}${st} + Güte ${q}%
+
Trigger ${fmt(m.trigger, 2)} · ${tgt} + · Höhe ${m.height_atr}×ATR · vor ${m.bars_ago} Bars
+
${m.note || ""}
+
`; + }).join("") + + `
📊 Reine Anzeige (mechanisch aus M30-Pivots) — KEIN Signal, + kein Einfluss auf die Empfehlung. Muster-Klasse im Projekt als Signal noch unbelegt; + Einfluss erst nach Backtest.
`; +} + +function render(d) { + // ── Header ────────────────────────────── + $("conn").className = "dot " + (d.connected ? "on" : "off"); + $("symbol").textContent = d.symbol || "—"; + const m = d.market || {}; + + // ── Server- (Broker) + Lokalzeit ──────── + const _p2 = (n) => String(n).padStart(2, "0"); + // server_time ist Broker-Wallclock, als UTC etikettiert → getUTC* zeigt sie 1:1 + const srv = m.server_time ? new Date(m.server_time) : null; + const srvStr = (srv && !isNaN(srv)) + ? `${_p2(srv.getUTCHours())}:${_p2(srv.getUTCMinutes())}:${_p2(srv.getUTCSeconds())}` + : "—"; + $("hdr-clock").textContent = `Srv ${srvStr} · Lok ${new Date().toLocaleTimeString("de-DE")}`; + + // ── Börsen-Session ────────────────────── + const ses = d.session || {}; + let stxt = ses.phase || "—"; + if (ses.next_open) stxt += ` · ${ses.next_open.name}-Open in ${ses.next_open.in_min} min`; + // 🕘 Prime-Time 21–22 Uhr (Berlin ≈ Browser-Lokalzeit): gemessen die mit Abstand + // stärksten Stunden (backtest_hourly: netto +0,25/+0,49 — Rest ~breakeven). + // 📰 News-Sturm: viele frische Öl-Headlines (news.storm_state) = Ereignis läuft + // → Vorsicht/Vola. Beides REINE ANZEIGE, kein Filter. + const _h = new Date().getHours(); + if (_h === 21 || _h === 22) stxt = `🕘 PRIME-TIME · ${stxt}`; + const storm = (d.news_storm || {}).level; + if (storm === "storm") stxt = `📰 NEWS-STURM · ${stxt}`; + else if (storm === "elevated") stxt = `📰 viele News · ${stxt}`; + // Marktstatus VOR die Session-Zeile: bei geschlossenem Markt ist die Phase + // ("Wochenende") allein zu leise — der eingefrorene Kurs sah live aus. + if (d.market_closed) { + stxt = `🔒 MARKT ZU · ${stxt}`; // HL-Kurs steht jetzt im Header + } + $("session").textContent = stxt; + $("session").className = "session-line " + + (d.market_closed ? "warn" + : storm === "storm" ? "warn" + : (_h === 21 || _h === 22) ? "warn" + : ses.just_opened ? "warn" : (ses.active && ses.active.length) ? "ok" : "dim"); + + // ── Konto: Balance / Equity ───────────── + const cs = curSym(m); + $("balance").textContent = m.balance != null ? cs + fmt(m.balance, 2) : "—"; + if (m.equity != null) { + $("equity").textContent = cs + fmt(m.equity, 2); + $("equity").className = (m.balance != null) + ? (m.equity >= m.balance ? "up" : "down") : ""; + } else { $("equity").textContent = "—"; $("equity").className = ""; } + + // ── Tages-P/L (realisiert heute + offene Position) ─────── + const dp = d.day_pl || {}; + if (dp.total != null) { + $("day-pl").textContent = (dp.total >= 0 ? "+" : "") + cs + fmt(dp.total, 2); + $("day-pl").className = dp.total > 0 ? "up" : dp.total < 0 ? "down" : ""; + } else { $("day-pl").textContent = "—"; $("day-pl").className = ""; } + + // ── Nähe zu S/R (Farb-Signal, innerhalb 0,3×ATR) ────────── + const srl = d.sr_levels || {}; + const atrRef = (d.trail || {}).atr || m.atr_m15 || 0; + const near = atrRef ? atrRef * 0.30 : 0.05; + let priceCls = ""; + if (m.bid != null) { + const dRes = srl.resistance != null ? srl.resistance - m.bid : Infinity; + const dSup = srl.support != null ? m.bid - srl.support : Infinity; + if (dRes >= 0 && dRes <= near && dRes <= dSup) priceCls = "near-res"; + else if (dSup >= 0 && dSup <= near && dSup < dRes) priceCls = "near-sup"; + } + + // ── Header: Kurs (färbt sich nahe S/R) + G/V der offenen Position (netto) ── + // ⚠ MARKT ZU (User 2026-08-01): Pepperstone handelt WTI nicht am Wochenende, der + // Kurs friert dann auf dem Freitags-Stand ein — sichtbar war das NICHT (der grüne + // Punkt im Header ist die VERBINDUNG, nicht der Marktstatus). Hyperliquid läuft + // 24/7 weiter, also zeigen wir dessen Kurs (basis-korrigiert = MT5-vergleichbar) + // statt des eingefrorenen — deutlich als „HL" gekennzeichnet, damit niemand ihn + // für einen Broker-Kurs hält. + // ⚠ Der BROKER-Kurs bleibt der Hauptkurs (erster Entwurf ersetzte ihn bei + // geschlossenem Markt durch den HL-Kurs — der User wollte stattdessen BEIDE + // nebeneinander, was ehrlicher ist: man sieht die Abweichung statt einer + // stillen Vertauschung). + $("hdr-price").textContent = m.bid != null ? fmt(m.bid) : "—"; + $("hdr-price").className = priceCls; + // ⚠ NULL-SICHER auf NEUE Elemente zugreifen (Fix 2026-08-01): `index.html` und + // `app.js` werden vom Browser UNABHÄNGIG gecacht. Trifft neue JS auf eine alte, + // gecachte HTML, wirft ein direkter Zugriff auf ein noch nicht existierendes + // Element eine TypeError — und weil das mitten in `render()` passiert, bricht der + // GESAMTE Render ab: das Dashboard friert ein und zeigt Altwerte. Genau so sah der + // Fehler beim Nutzer aus („ich sehe die Änderungen nicht"). Mit Guard läuft der + // Rest normal weiter, nur die neuen Felder fehlen, bis die HTML nachgeladen ist. + const lblEl = $("hdr-price-lbl"); + if (lblEl) { + lblEl.textContent = d.market_closed ? "PEPPERSTONE KURS · ZU" : "PEPPERSTONE KURS"; + lblEl.title = d.market_closed + ? `Pepperstone geschlossen — letzter Tick vor ${Math.round((d.tick_age_s || 0) / 60)} min` + : ""; + } + + // Hyperliquid daneben: basis-korrigiert (= MT5-Niveau), mit Abweichung im Tooltip. + const hl = d.hl_live || null; + const hlEl = $("hdr-hl"); + if (hlEl && hl && hl.hl_mid != null) { + // ⚠ Angezeigt wird der ROHE HL-Kurs (Fix 2026-08-02, User-Fund): hier stand + // vorher `mid_mt5` = HL + Basis, also der auf Pepperstone-Niveau umgerechnete + // Wert. Unter der Beschriftung „HYPERLIQUID KURS" ist das schlicht falsch — der + // Nutzer verglich mit app.hyperliquid.xyz (81,392) und sah bei uns 86,424. + // Jetzt steht hier exakt die Zahl, die auch auf der HL-Seite steht; das + // CFD-Äquivalent (für den Broker-Vergleich) wandert in den Tooltip. + const arr = hl.trend === "up" ? "▲" : hl.trend === "down" ? "▼" : ""; + hlEl.textContent = fmt(hl.hl_mid) + (arr ? " " + arr : ""); + const dv = hl.vs_mt5; + hlEl.className = d.market_closed ? "hl-price" + : (dv != null && Math.abs(dv) >= 0.15) ? "hl-price" : ""; + hlEl.title = `Hyperliquid xyz:CL — Rohkurs ${fmt(hl.hl_mid)}, ` + + `${fmt(hl.age_s, 0)} s alt.\n` + + `Auf Pepperstone-Niveau: ${fmt(hl.hl_mid)} + Basis ` + + `${hl.basis >= 0 ? "+" : ""}${fmt(hl.basis, 3)} = ${fmt(hl.mid_mt5)}` + + (dv != null ? ` (ggü. Broker ${dv >= 0 ? "+" : ""}${fmt(dv, 3)})` : "") + + (hl.basis_stale + ? `\n⚠ Broker steht — Basis ist auf dem letzten Stand mit lebendem Markt ` + + `eingefroren. Ohne das würde die Umrechnung nur den eingefrorenen ` + + `Broker-Kurs reproduzieren.` + : ""); + } else if (hlEl) { + hlEl.textContent = "—"; hlEl.className = ""; hlEl.title = ""; + } + + const hpos = d.position || {}; + if (hpos.ticket) { + const wht = d.wht_pct || 0; + // ⚠ Bei geschlossenem Markt steht der Broker-P&L still (der Kurs ist eingefroren). + // Dann die HL-SCHÄTZUNG zeigen (User 2026-08-01) — klar als Schätzung markiert: + // Swap/Kommission der Wochenendtage fehlen und HL ist ein anderer Kontrakt. + const useHl = d.market_closed && d.pnl_hl != null; + const raw = useHl ? d.pnl_hl : (hpos.pnl || 0); + const netHdr = raw > 0 ? raw * (1 - wht / 100) : raw; + $("hdr-pnl").textContent = (useHl ? "≈" : "") + (netHdr >= 0 ? "+" : "") + cs + fmt(netHdr, 2); + $("hdr-pnl").className = netHdr > 0 ? "up" : netHdr < 0 ? "down" : ""; + $("hdr-pnl").title = useHl + ? `Schätzung auf Basis des Hyperliquid-Kurses — der Broker-Kurs ist eingefroren ` + + `(Broker-P&L ${fmt(hpos.pnl || 0, 2)} ${cs}). Ohne Swap/Kommission des Wochenendes.` + : ""; + } else { $("hdr-pnl").textContent = "—"; $("hdr-pnl").className = ""; $("hdr-pnl").title = ""; } + + // ── S/R-Annäherungs-Warnung (Position-Kachel) — NUR wenn kein reicherer + // Positions-Hint (pos-srhint) läuft, sonst doppeltes „nähert sich Widerstand". + const hasRichHint = !!((d.position && d.position.ticket) && d.sr_close_hint); + if (hasRichHint) { + $("cur-near").className = "cur-near"; + $("cur-near").textContent = ""; + } else { + $("cur-near").className = "cur-near " + priceCls; + $("cur-near").textContent = + priceCls === "near-res" ? `▲ nähert sich Widerstand ${fmt(srl.resistance)}` : + priceCls === "near-sup" ? `▼ nähert sich Unterstützung ${fmt(srl.support)}` : ""; + } + + // ── Gesamtempfehlung (aggregiert) ─────── + renderM15(d.m15_setup); + renderKontext(d); + renderCircuitBreaker(d.circuit_breaker); + renderPatterns(d.patterns); + renderCandles(d.candle_anat); + renderCone(d.cone); + renderStructure(d.structure); + renderManual(d.manual_stats); + renderStatsCompact(d.stats_compact, d.market); + renderGaps(d.gaps, d.market); + + // ── Position (Kurs/G/V stehen jetzt im Header) ── + const p = d.position || {}; + + // ── Trade-Bereich über der Gesamtempfehlung (nur bei offener Position) ── + const tb = $("trade-bar"); + if (tb) { + if (p.ticket) { + tb.classList.remove("hidden"); + const long = p.order_type === 0; + const dEl = $("tb-dir"); + dEl.className = "tb-val tb-dir " + (long ? "up" : "down"); + dEl.textContent = long ? "▲ LONG" : "▼ SHORT"; + $("tb-lots").textContent = fmt(p.lots, 2); + $("tb-margin").textContent = p.margin ? cs + fmt(p.margin, 2) : "—"; + // Laufzeit: Öffnungszeit merken (echte Epoch, Broker-Offset korrigiert) und + // sekündlich per Ticker anzeigen — unabhängig vom Snapshot-Intervall. + _tradeOpenTime = p.open_time ? Number(p.open_time) : 0; + _tickTradeDuration(); + // SL/TP-Felder nur befüllen, wenn nicht gerade bearbeitet (kein Tipp-Konflikt) + const slEl = $("tb-sl"), tpEl = $("tb-tp"); + if (document.activeElement !== slEl) slEl.value = p.sl ? fmt(p.sl, 2) : ""; + if (document.activeElement !== tpEl) tpEl.value = p.tp ? fmt(p.tp, 2) : ""; + } else { + tb.classList.add("hidden"); + _tradeOpenTime = 0; + } + } + + // ── Notfall-Stop: Feld wieder eingeblendet (User-Vorgabe 2026-07-22; war + // 2026-07-17 entfernt, Schutz lief nur noch über Broker-SL+Trailing — Backend + // (set_emergency/emergency_state.json) blieb die ganze Zeit aktiv/persistent). + // %-Modus (auto_emergency_pct>0): der Auto-Arm nimmt pct% der Balance → im + // Flat-Zustand diesen dynamischen Wert zeigen statt des Fixwerts. + const emg = d.emergency_loss; + const emgPct = d.emergency_pct || 0; + const emgRem = (emgPct > 0 && d.balance) + ? Math.round(emgPct / 100 * d.balance * 100) / 100 + : d.emergency_remember; + const ei = $("emg-input"); + if (ei && document.activeElement !== ei) { + const shown = emg != null ? emg : (emgRem || ""); + ei.value = shown === "" ? "" : String(shown); + } + // („Gewinn mitnehmen"-Eingabe aus dem Dashboard entfernt — Backend-Funktion + // set_takeprofit/api bleibt erhalten, nur ohne UI.) + + // ── Trailing ──────────────────────────── + const tr = d.trail || {}; + $("trail-state").textContent = tr.enabled ? "AKTIV" : "aus"; + $("trail-state").className = tr.enabled ? "up" : "dim"; + $("trail-phase").textContent = tr.phase || "—"; + $("trail-sl").textContent = fmt(tr.last_sl, 3); + $("trail-tp").textContent = fmt(tr.last_tp, 3); + $("trail-atr").textContent = `${fmt(tr.atr, 3)} · ${tr.atr_tf || "—"}`; + + // ── Wellen-Meldungen (jetzt in „Meldungen"; Signal-Zeile entfällt — steht als + // Headline in der Gesamtempfehlung) ────────────────────── + // Setup- und Lauf-Zeile entfernt (User-Vorgabe 2026-08-06): das Setup steht + // ohnehin in der Verdict-Headline bzw. den Gründen, der „Lauf" war eine + // Beschreibung der Vergangenheit ohne Handlungsbezug. + // ⚠ `w` bleibt — es wird weiter unten noch für die Gründe-Zeile, das + // Button-Blinken, den Close-Alarm und die News-Konflikt-Meldung gebraucht. + const w = d.wave_signal || {}, ws = d.wave || {}; + // Überdehnungs-Anzeige (KEIN Handelssignal — gemessen ~Münzwurf, nur Kontext). + // Reaktionsrichtung explizit: LONG=überverkauft→nach oben, SHORT=überkauft→nach unten. + const bn = ws.bounce || {}; + const rword = bn.dir === "LONG" ? "nach oben ↑" : "nach unten ↓"; + const tf = bn.tf ? ` (${bn.tf})` : ""; + const be = $("wave-bounce"); + if (bn.state === "active") { + be.className = "bounce-ind warn strong"; + be.textContent = `⚠ stark überdehnt${tf} — Reaktion ${rword} möglich`; + } else if (bn.state === "expected") { + be.className = "bounce-ind warn"; + be.textContent = `⚠ überdehnt${tf} — Reaktion ${rword} möglich`; + } else { + be.className = "bounce-ind idle"; // immer sichtbar (Ruhe-Zustand) + be.textContent = "Überdehnung: keine"; + } + // Volatilitäts-Squeeze-Breakout (additiver Setup, gemessen 2-Stichproben-positiv): + // active = Ausbruch aus Kompression (echtes Signal → grün/rot) · armed = komprimiert, + // Ausbruch steht bevor (grau) · sonst keine Kompression. + const sq = ws.squeeze || {}; + const sqEl = $("wave-squeeze"); + if (sqEl) { + const arr = sq.dir === "LONG" ? "▲" : "▼"; + const bx = sq.box_atr != null ? ` · Box ${sq.box_atr}×ATR` : ""; + if (sq.state === "active") { + sqEl.className = "squeeze-ind fire " + (sq.dir === "LONG" ? "long" : "short"); + sqEl.textContent = `🚀 Squeeze-Breakout ${arr} ${sq.dir} @ ${fmt(sq.level, 3)}${bx}`; + } else if (sq.state === "armed") { + sqEl.className = "squeeze-ind armed"; + sqEl.textContent = `🎯 Kompression${bx} — Ausbruch ${arr} steht bevor`; + } else { + sqEl.className = "squeeze-ind idle"; + sqEl.textContent = "Squeeze: keine Kompression"; + } + } + // TF-Ampel: M1/M5/M15/M30 grün(auf)/rot(ab); frische EMA12/50-Kreuzung + // (≤3 Bars) → blinkendes ⟳ + Richtungspfeil. + const turns = ws.tf_turns || {}; + $("wave-turns").innerHTML = ["M1", "M5", "M15", "M30", "H1"].map(tf => { + const t = turns[tf] || {}; + const cls = t.dir > 0 ? "up" : t.dir < 0 ? "down" : "flat"; + const arr = t.dir > 0 ? "▲" : t.dir < 0 ? "▼" : "·"; + const fresh = t.fresh ? " fresh" : ""; + const turn = t.fresh ? `⟳${arr}` : arr; + const ago = (t.bars_ago != null && t.fresh) ? `${t.bars_ago}` : ""; + return `${tf} ${turn}${ago}`; + }).join(""); + // (S/R-Zeile im Position-Bereich ausgeblendet — die Level stehen im Chart; + // die Preis-Färbung/„nähert sich"-Warnung nutzt srl weiterhin.) + $("wave-reasons").textContent = (w.reasons || []).slice(0, 3).join(" · "); + + // Elliott/FVG + TradersUnion: keine Einzel-Kacheln mehr (ausgeblendet) — + // beide fließen weiterhin in die Gesamtempfehlung (engine._verdict) ein. + + // ── KI-Agent ──────────────────────────── + const ag = (d.agent || {}).advisory; + if (ag) { + const b = ag.bias || "NEUTRAL"; + const bc = b === "LONG" ? "long" : b === "SHORT" ? "short" : ""; + const ba = b === "LONG" ? "▲" : b === "SHORT" ? "▼" : "◌"; + $("agent-bias").className = "signal " + bc; + $("agent-bias").textContent = + `${ba} ${b} · ${ag.confidence}%` + (ag.timeframe ? ` · ${ag.timeframe}` : ""); + $("agent-head").textContent = ag.headline || ""; + $("agent-reason").textContent = ag.reasoning || ""; + $("agent-risks").innerHTML = (ag.risks || []).map(r => `
  • ${r}
  • `).join(""); + } else { + $("agent-bias").className = "signal dim"; + $("agent-bias").textContent = (d.agent || {}).busy ? "◌ analysiere …" : "◌ —"; + $("agent-head").textContent = ""; + $("agent-reason").textContent = (d.agent || {}).error || ""; + $("agent-risks").innerHTML = ""; + } + + // ── Aktionsleiste: Zustände ───────────── + const hasPos = !!p.ticket; + const longBtn = document.querySelector('.btn[data-side="long"]'); + const shortBtn = document.querySelector('.btn[data-side="short"]'); + const closeBtn = document.querySelector('.btn[data-side="close"]'); + longBtn.disabled = hasPos; + shortBtn.disabled = hasPos; + closeBtn.disabled = !hasPos; + $("btn-squeeze").classList.toggle("on", !!d.auto_squeeze); + // ⚠⚠ Der 🎯 SIG-Button ist am 2026-08-12 ENTFERNT (User-Entscheidung). Der + // autonome Entry auf die Empfehlung war seit dem 01.08. aus und auf BEIDEN + // Ausführungs-Achsen gemessen negativ — Markt in allen 8 Feldern, ruhende + // Order mit Touch-Fill in allen 6 und dabei schlechter als Markt. Grund + // strukturell: das Bestätigungs-Level `_pend` WANDERT. + $("btn-trail").classList.toggle("on", !!(d.trail || {}).enabled); + + // Empfehlungs-Blinken: LONG/SHORT-Button blinkt, sobald die Welle eine + // Richtung empfiehlt (= wie in der Welle-Kachel sichtbar) und keine Position + // offen ist. Nicht mehr an die 70%-Auto-Schwelle gekoppelt. + longBtn.classList.toggle("blink", !hasPos && w.signal === "LONG"); + shortBtn.classList.toggle("blink", !hasPos && w.signal === "SHORT"); + + // Ton bei Empfehlung (nur flat): sofort beim Signal-Wechsel, danach Erinnerung + // alle 30 s, solange der Button blinkt. Kein Ton bei offener Position. + const recSig = (!hasPos && (w.signal === "LONG" || w.signal === "SHORT")) + ? w.signal : null; + if (recSig) beepRecommendation(recSig, recSig !== _lastRecSignal); + _lastRecSignal = recSig; + + // CLOSE-Alarm (Blink + Ton) NUR wenn der Gewinn ÜBER dem eingestellten S/R- + // Mindestgewinn liegt (User-Vorgabe). Feld leer/0 → wie bisher: jeder Gewinn > 0. + // Deckt sich mit dem Auto-Close-Gate im Backend (unter der Schwelle schließt der + // Bot ohnehin nicht → dann auch kein Alarm/Ton). + const posLong = p.order_type === 0; + const minGain = d.sr_close_min_gain || 0; + const gainOK = hasPos && (p.pnl || 0) > minGain; + const flipClose = gainOK && + ((posLong && w.signal === "SHORT") || (!posLong && w.signal === "LONG")); + // S/R-Close-Empfehlung (gemessen, nur das robuste Segment): Kurs AM Level + + // schwacher Anlauf → Level hält ~74 % → Close schlägt Laufenlassen. Text-Hinweis + // in der Position-Kachel immer; Blink/Ton nur über dem Mindestgewinn. + const srHint = d.sr_close_hint || null; // vorhanden = Kurs am Level + const srAuto = !!d.auto_sr_close; + const srClose = !!(srHint && srHint.close); // P(break) < Schwelle → schließen + const closeAlert = flipClose || (gainOK && srClose); + closeBtn.classList.toggle("blink", closeAlert); + if (closeAlert) beepCloseAlert(); // Blinken + Ton (gedrosselt, alle 4 s) + const hintEl = $("pos-srhint"); + if (hintEl) { + if (hasPos && srHint) { + const lvlName = posLong ? "Widerstand" : "Support"; + // Bestätigungs-Preis (level±0,5×ATR, dieselbe Definition wie beim Modell- + // Training): ein kurzer Docht über die Levelzahl ist noch KEIN Durchbruch. + const confirmTxt = srHint.confirm != null + ? ` (Bestätigung erst ab ${fmt(srHint.confirm, 3)})` : ""; + if (!srHint.at_level) { + // Vorwarnung: Kurs nähert sich dem Level (0,15–0,6×ATR davor). + // Schwelle dynamisch aus der Config (sr_close_pbreak), nicht hart 60. + const thr = (d.sr_close_pbreak || 0.60) * 100; + hintEl.className = "pos-srhint approach"; + hintEl.textContent = `🎯 Nähert sich ${lvlName} ${fmt(srHint.level, 3)} ` + + `(${srHint.gap_atr}×ATR) · Durchbruch ${srHint.p_break}%${confirmTxt}` + + (srHint.p_break < thr ? " → Bot schließt dort (im Plus)" : " → dürfte durchlaufen"); + } else if (srClose) { + hintEl.className = "pos-srhint warn"; + const tail = (srAuto && gainOK) ? "🤖 Bot schließt automatisch" : "Close erwägen"; + hintEl.textContent = `🎯 Am ${lvlName} ${fmt(srHint.level, 3)} · Durchbruch nur ` + + `${srHint.p_break}%${confirmTxt} → prallt wahrscheinlich ab → ${tail}`; + } else { + hintEl.className = "pos-srhint run"; + hintEl.textContent = `🎯 Am ${lvlName} ${fmt(srHint.level, 3)} · Durchbruch ` + + `${srHint.p_break}%${confirmTxt} wahrscheinlich → läuft weiter`; + } + } else { + hintEl.className = "pos-srhint hidden"; + hintEl.textContent = ""; + } + } + // Gegen-/Stop-Seiten-Hinweis (REINE ANZEIGE, kein Auto-Close): Kurs läuft auf das + // Level GEGEN die Position zu → bricht es durch (Verlust läuft) oder prallt es ab? + // P(break)-Modell auf der Gegen-Seite gemessen kalibriert (AUC 0,72 o.o.s.), + // im hohen Bereich leicht konservativ. Nur zeigen, wenn KEIN Ziel-Level-Hint läuft. + const stopEl = $("pos-stophint"); + if (stopEl) { + const sa = d.stop_approach || null; + if (hasPos && sa && !srHint) { + const lvlName = sa.side === "sup" ? "Support" : "Widerstand"; + const dir = sa.side === "sup" ? "nach unten" : "nach oben"; + const confirmTxt = sa.confirm != null ? ` (Bruch ab ${fmt(sa.confirm, 3)})` : ""; + const holds = sa.p_break < 50; + stopEl.className = "pos-stophint " + (holds ? "hold" : "break"); + const lead = sa.at_level ? `Am ${lvlName}` : `Nähert sich ${lvlName}`; + stopEl.textContent = + `ℹ️ ${lead} ${fmt(sa.level, 3)} (${sa.gap_atr}×ATR) · Durchbruch ${dir} ` + + `${sa.p_break}%${confirmTxt} → ` + + (holds ? "Level hält wahrscheinlich" : "bricht wahrscheinlich durch"); + } else { + stopEl.className = "pos-stophint hidden"; + stopEl.textContent = ""; + } + } + // News-Konflikt-Chip (2026-07-24, realer Auslöser 23.07.: zwei Shorts gegen einen + // +0,98-Sentiment-Burst): |Score|≥0,5 UND Richtung gegen offene Position bzw. + // gegen das aktive Signal → Warnung. Reiner Kontext, Prädiktivität unbelegt. + const ncEl = $("news-conflict"); + if (ncEl) { + const ns = d.news_sentiment; + let txt = ""; + if (ns != null && Math.abs(ns) >= 0.5) { + const newsDir = ns > 0 ? "LONG" : "SHORT"; + const newsWord = ns > 0 ? "bullisch" : "bärisch"; + if (hasPos) { + const posDir = posLong ? "LONG" : "SHORT"; + if (posDir !== newsDir) + txt = `📰 News-Flow stark ${newsWord} (${ns > 0 ? "+" : ""}${fmt(ns, 2)}) — steht gegen deine ${posDir}-Position`; + } else if (w.signal === "LONG" || w.signal === "SHORT") { + if (w.signal !== newsDir) + txt = `📰 News-Flow stark ${newsWord} (${ns > 0 ? "+" : ""}${fmt(ns, 2)}) — steht gegen das ${w.signal}-Signal`; + } + } + if (txt) { ncEl.className = "news-conflict warn"; ncEl.textContent = txt; } + else { ncEl.className = "news-conflict hidden"; ncEl.textContent = ""; } + } + // Wirtschaftskalender (2026-07-30): laufendes News-Fenster bzw. Vorwarnung ≤60 min. + // ⚠ NICHT gemessen — Handbuch-Regel; der Bot pausiert im Fenster nur seine + // AUTONOMEN Entries, manuelle Orders bleiben frei. + const calEl = $("cal-note"); + if (calEl) { + const c = d.calendar || {}; + let t = ""; + if (c.blackout) { + const m = c.blackout.minutes; + t = `📅 News-Fenster: ${c.blackout.title} ${m > 0 ? "in " + Math.round(m) + " min" : "vor " + Math.round(-m) + " min"} — Bot eröffnet gerade keine Auto-Trades`; + } else if (c.next && c.next.minutes <= 60) { + t = `📅 ${c.next.title} in ${c.next.minutes} min`; + } + if (t) { calEl.className = "news-conflict warn"; calEl.textContent = t; } + else { calEl.className = "news-conflict hidden"; calEl.textContent = ""; } + } + // Auto-Flip-Close sichtbar machen (2026-08-05). Dieser Exit schliesst echte + // Positionen, ist aber in BEIDEN Halbjahren gemessen NEGATIV (−92/−160 R, + // backtest_flipclose2.py) und lief bis heute voellig ohne Anzeige. Die Zeile + // nennt bewusst die Messung mit — sonst sieht ein Zaehler nach Erfolg aus. + const flipEl = $("flip-note"); + if (flipEl) { + if (d.flip_close) { + const n = d.flip_close_count || 0; + const r = d.flip_close_min_r; + flipEl.className = "news-conflict warn"; + flipEl.textContent = `🔄 Auto-Flip-Close AKTIV (ab ${r != null ? r : "?"}×ATR im Plus)` + + ` — bisher ${n}× ausgelöst · ⚠ gemessen in beiden Halbjahren negativ`; + flipEl.title = "Schliesst die Position, wenn die Empfehlung dreht. " + + "backtest_flipclose2.py: −92 R (H1) / −160 R (H2) gegenueber Nichtstun. " + + "Laeuft auf ausdruecklichen Wunsch; abschalten via [trading] auto_flip_close=false."; + } else { + flipEl.className = "news-conflict hidden"; flipEl.textContent = ""; + } + } + // Schalter-Zustand: Auto-Close am S/R an/aus + Mindestgewinn + const srBtn = $("srclose-btn"); + if (srBtn) { + srBtn.textContent = srAuto ? "AN" : "AUS"; + srBtn.title = srAuto ? "Bot schließt am Level (im Plus)" : "nur Hinweis, kein Auto-Close"; + srBtn.classList.toggle("on", srAuto); + } + const srMin = d.sr_close_min_gain || 0; + const smi = $("srmin-input"); + if (smi && document.activeElement !== smi) { + smi.value = srMin > 0 ? String(srMin) : ""; + } + // Feste Einsatz-Margin (0/leer = automatisch). Nicht überschreiben, während + // getippt wird — sonst löscht der 1-s-Render die Eingabe unter den Fingern weg. + const mgi = $("margin-input"); + if (mgi && document.activeElement !== mgi) { + const mm = d.manual_margin || 0; + mgi.value = mm > 0 ? String(mm) : ""; + mgi.classList.toggle("on", mm > 0); + } + // Einsatz in Prozent der freien Margin. Ausgegraut, sobald links ein fester + // Betrag steht — der hat Vorrang, und ohne den Hinweis wäre unklar, was gilt. + const mpi = $("marginpct-input"); + if (mpi && document.activeElement !== mpi) { + const mp = d.margin_pct || 0; + mpi.value = mp > 0 ? String(mp) : ""; + const fest = (d.manual_margin || 0) > 0; + mpi.classList.toggle("on", mp > 0 && !fest); + const wrap = $("marginpct-wrap"); + if (wrap) { + wrap.classList.toggle("muted", fest); + wrap.title = fest + ? `Ohne Wirkung: links steht ein fester Einsatz (${fmt(d.manual_margin, 2)} €). ` + + `Der Prozentsatz (${mp} %) bleibt nur die Obergrenze.` + : "Einsatz als Prozent der freien Margin (1–99). " + + "Greift, solange links kein fester Betrag steht."; + } + } + // Einblendung: der Bot hat am S/R automatisch geschlossen (Zähler stieg) + const src = d.sr_close_count; + if (src != null) { + if (_lastSrCloseCount != null && src > _lastSrCloseCount) { + toast("🎯 Bot hat am Level geschlossen (schwacher Anlauf, Gewinn gesichert)", true); + } + _lastSrCloseCount = src; + } + + // ── Letzte Trades ─────────────────────── + // 5 → 10 (User-Wunsch 2026-08-04). Das Backend liefert bereits 10 + // (`engine.snapshot` → `last_closed_trades(10)`); dieser Slice ist die zweite, + // unabhängige Begrenzung — beide müssen zusammenpassen. + const tds = (d.last_trades || []).slice(0, 10); + $("trades").innerHTML = tds.map(x => { + const pnl = x.pnl || 0; + const cls = pnl > 0 ? "up" : pnl < 0 ? "down" : "dim"; + const arr = (x.direction || "").toUpperCase() === "BUY" ? "▲" : "▼"; + const ts = (x.exit_time || x.entry_time || 0) * 1000; + const hm = ts ? new Date(ts).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) : "—"; + const cb = (x.closed_by || "").toUpperCase().slice(0, 2) || "—"; + return `
    ${arr}${pnl >= 0 ? "+" : ""}${fmt(pnl, 2)}
    ` + + `
    ${hm} ${cb}
    `; + }).join("") || '
    keine
    '; +} + +// ── WebSocket mit Auto-Reconnect ─────────────────────────────────────────── +let sock = null, retry = null; +// `lastTs` dedupliziert zwischen WS-Push und REST-Poll (beide liefern denselben +// `engine.snapshot_cached().ts`) — nur die jeweils NEUERE Quelle rendert, kein +// Flackern/Doppel-Render, wenn beide fast gleichzeitig eintreffen. +let lastTs = 0; +// Wann kam zuletzt ein FRISCHER Snapshot an (Wanduhr, egal aus welcher Quelle)? +// Grundlage für den 1-s-Fallback und die Stale-Erkennung unten. +let lastRecvMs = 0; +// Getrennt davon: wann kam zuletzt eine WS-NACHRICHT? Nur diese Uhr entscheidet, +// ob der Poll uebersprungen wird (s. Kommentar in `sock.onmessage`). +let lastWsMs = 0; +function _applySnapshot(d) { + if (typeof d.ts === "number" && d.ts <= lastTs) return; // schon Neueres gezeigt + if (typeof d.ts === "number") lastTs = d.ts; + lastRecvMs = Date.now(); + render(d); +} +function connect() { + const proto = location.protocol === "https:" ? "wss" : "ws"; + sock = new WebSocket(`${proto}://${location.host}/ws`); + sock.onmessage = (ev) => { + // ⚠ NUR hier setzen, NICHT in `_applySnapshot`. Der Poll darf die + // WS-Frische nicht mitschreiben, sonst drosselt er sich selbst: er würde + // nach jedem eigenen Abruf wieder WS_FRISCH_MS pausieren und käme bei totem + // WS nur auf ~2,9 s statt 1 s. (In der Simulation aufgefallen, s. unten.) + lastWsMs = Date.now(); + try { _applySnapshot(JSON.parse(ev.data)); } catch (e) { console.warn(e); } + }; + sock.onclose = () => { + $("conn").className = "dot off"; + clearTimeout(retry); + retry = setTimeout(connect, 2000); + }; + sock.onerror = () => sock.close(); +} + +// ── REST-Polling als Sicherheitsnetz (2026-07-22, User-Wunsch) ──────────────── +// Zusätzlich zum WS-Push: alle POLL_MS Sekunden `/api/snapshot` abfragen. Fängt +// Fälle auf, in denen der WebSocket STILL hängt (kein onclose, keine Zustellung +// mehr — z. B. schwaches WireGuard-Signal, App-Wechsel ohne Visibility-Event), +// ohne auf einen Reconnect warten zu müssen. Der `lastTs`-Dedup verhindert +// Doppel-Renders, solange der WS normal liefert — reine Redundanz, kein Konflikt. +// ⚠⚠ 4000 → 1000 (2026-08-07, User: „G/V wird extrem lagged, 1 Sekunde"). +// GEMESSEN, bevor gedreht wurde: die gesamte Server-Kette liefert bereits 1 s +// (`_pos_loop` 1 s · Snapshot-Cache 0,8 s · WS-Push **Median 1,04 s**, direkt am +// Socket nachgemessen). Der Lag entsteht also NICHT im Backend, sondern wenn der +// WebSocket am Handy still wegbricht (WireGuard-Aussetzer, App-Wechsel): dann +// trägt nur noch dieser Poll — und der stand auf 4 s. +// ⚠ Das VERDOPPELT den Verkehr NICHT: solange der WS liefert, überspringt der +// Poll den Abruf (s. `WS_FRISCH_MS`). Er feuert nur, wenn der WS schweigt. +const POLL_MS = 1000; +const WS_FRISCH_MS = 2200; // > WS-Takt (1,04 s) + Jitter, sonst pollt es doppelt +async function pollSnapshot() { + if (document.hidden) return; // seite unsichtbar → spart Akku/Daten, Wake-Handler holt beim Zurückkommen sofort nach + // Liefert der WS gerade? Dann NICHT zusätzlich abfragen — reine Redundanz + // kostet über WireGuard sonst ~14 KB/s ohne jeden Nutzen. + if (Date.now() - lastWsMs < WS_FRISCH_MS) return; + let d; + try { + d = await (await fetch("api/snapshot")).json(); + } catch (e) { return; } // still — Netz-/WS-Aussetzer, dafür IST der Fallback da + // ⚠ NICHT mit ins try oben (Fix 2026-08-01): der Poll-`catch` umschloss früher + // auch `_applySnapshot` → `render`. Ein Render-Fehler wurde damit LAUTLOS + // verschluckt: der Poll feuerte weiter alle 4 s, jeder Durchlauf starb still, + // das Dashboard fror auf dem letzten guten Stand ein und die Konsole blieb + // leer. Genau diese Kombination hat eine Fehlersuche unnötig lang gemacht. + // Netzfehler bleiben still (die sind erwartbar), Render-Fehler werden laut. + _applySnapshot(d); +} +setInterval(pollSnapshot, POLL_MS); + +// ── Ehrlicher Verbindungspunkt (2026-08-07) ──────────────────────────────── +// ⚠ Bisher wurde er NUR bei `sock.onclose` auf „off" gesetzt. Genau der Fall, +// der den Lag verursacht, löst aber KEIN onclose aus: der Socket hängt still +// (WireGuard-Aussetzer, App-Wechsel) — der Punkt blieb grün und behauptete eine +// Verbindung, die nichts mehr liefert. Ein Zustandsanzeiger, der im +// Fehlerfall nicht anschlägt, ist schlimmer als keiner. +// Jetzt zählt, was WIRKLICH ankommt: liegt der letzte angewandte Snapshot mehr +// als STALE_MS zurück, wird der Punkt matt — unabhängig davon, was der Socket +// über sich selbst behauptet. +const STALE_MS = 3500; +setInterval(() => { + const el = $("conn"); + if (!el) return; + const alt = Date.now() - lastRecvMs; + el.classList.toggle("stale", lastRecvMs > 0 && alt > STALE_MS); + el.title = lastRecvMs + ? `letzte Aktualisierung vor ${(alt / 1000).toFixed(1)} s` + + (alt > STALE_MS ? " — Verbindung hängt, es wird nachgepollt" : "") + : "noch keine Daten empfangen"; +}, 1000); + +// ── Trade-Steuerung ──────────────────────────────────────────────────────── +const TOKEN_KEY = "oil_token"; + +// Token robust speichern: localStorage UND als langlebiges Cookie. So bleibt +// "einmal eingeben" auch erhalten, wenn localStorage zickt (PWA/Privat-Tab). +function saveToken(tok) { + try { localStorage.setItem(TOKEN_KEY, tok); } catch (e) {} + try { + document.cookie = TOKEN_KEY + "=" + encodeURIComponent(tok) + + ";path=/;max-age=31536000;SameSite=Lax"; + } catch (e) {} +} +function getToken() { + let t = null; + try { t = localStorage.getItem(TOKEN_KEY); } catch (e) {} + if (t) return t; + const m = document.cookie.match(new RegExp("(?:^|; )" + TOKEN_KEY + "=([^;]*)")); + return m ? decodeURIComponent(m[1]) : null; +} +function clearToken() { + try { localStorage.removeItem(TOKEN_KEY); } catch (e) {} + try { document.cookie = TOKEN_KEY + "=;path=/;max-age=0"; } catch (e) {} +} + +let lastSnap = {}; +const origRender = render; +render = (d) => { lastSnap = d; origRender(d); }; // letzten Snapshot merken + +// Generisches Modal → Promise(true/false). mode "confirm" | "token" +function modal(title, msg, mode = "confirm", danger = false) { + return new Promise((resolve) => { + const box = $("modal"), inp = $("modal-input"); + $("modal-title").textContent = title; + $("modal-msg").textContent = msg || ""; + $("modal-msg").className = "box-msg" + (danger ? " danger" : ""); + $("modal-ok").textContent = danger ? "Trotzdem" : "Bestätigen"; + inp.classList.toggle("hidden", mode !== "token"); + if (mode === "token") inp.value = ""; + box.classList.remove("hidden"); + if (mode === "token") setTimeout(() => inp.focus(), 50); + const done = (val) => { + box.classList.add("hidden"); + $("modal-ok").onclick = $("modal-cancel").onclick = null; + resolve(val); + }; + $("modal-ok").onclick = () => done(mode === "token" ? (inp.value.trim() || null) : true); + $("modal-cancel").onclick = () => done(mode === "token" ? null : false); + }); +} + +function toast(msg, ok = true) { + const el = $("toast"); + el.textContent = msg; + el.className = "toast " + (ok ? "ok" : "err"); + clearTimeout(toast._t); + toast._t = setTimeout(() => el.classList.add("hidden"), 3500); +} + +// Token sicherstellen (gespeichert; bei Bedarf abfragen + serverseitig prüfen) +async function ensureToken() { + // Token-Pflicht serverseitig abgeschaltet? Dann kein Fenster, kein Header nötig. + if (lastSnap.auth_required === false) return ""; + let tok = getToken(); + while (true) { + if (!tok) { + tok = await modal("App-Token", "Token aus der Server-Konsole eingeben " + + "(steht in [web] api_token).", "token"); + if (!tok) return null; + } + try { + const r = await fetch("api/auth/check", { method: "POST", + headers: { "X-Auth-Token": tok } }); + if (r.ok) { saveToken(tok); return tok; } + } catch (e) { /* Netzfehler → unten behandelt */ } + clearToken(); tok = null; + const retry = await modal("Token ungültig", "Erneut eingeben?", "confirm"); + if (!retry) return null; + } +} + +async function postAuth(url, tok) { + const r = await fetch(url, { method: "POST", + headers: { "X-Auth-Token": tok, "Content-Type": "application/json" }, + body: JSON.stringify({}) }); + if (r.status === 401) { clearToken(); throw new Error("401"); } + return r.json(); +} + +async function sendOrder(side, tok) { + const r = await fetch("api/order", { method: "POST", + headers: { "X-Auth-Token": tok, "Content-Type": "application/json" }, + body: JSON.stringify({ side }) }); + if (r.status === 401) { clearToken(); throw new Error("401"); } + return r.json(); +} + +// Empfehlungs-Abgleich: zeigt Welle UND KI-Copilot, warnt bei Konflikt +function recInfo(side) { + const w = lastSnap.wave_signal || {}; + const sig = w.signal || "—"; + const conf = w.conf_pct || 0; + const want = side === "long" ? "LONG" : "SHORT"; + const minc = lastSnap.min_conf || 70; + + // ── Welle (primäres Signal → bestimmt die Warnung) ── + let waveLine, danger = false; + if (sig === "WARTEN" || sig === "—") { + waveLine = "⚠ Welle: WARTEN — kein klarer Trend"; danger = true; + } else if (sig !== want) { + waveLine = `⚠ Welle GEGEN dich: ${sig} ${conf}%`; danger = true; + } else if (conf < minc) { + waveLine = `Welle ${sig} ${conf}% (unter Schwelle ${minc}%)`; + } else { + waveLine = `✓ Welle ${sig} ${conf}%`; + } + + // ── KI-Copilot (Kontext) ── + const adv = (lastSnap.agent || {}).advisory; + let agentLine = "· KI-Copilot: —"; + if (adv) { + const ab = adv.bias || "NEUTRAL", ac = adv.confidence || 0; + const mark = ab === want ? "✓" : ab === "NEUTRAL" ? "·" : "⚠"; + agentLine = `${mark} KI-Copilot: ${ab} ${ac}%`; + // Agent ist nur Kontext — die Warnung (danger) bestimmt die validierte Welle. + } + + return { line: waveLine + "\n" + agentLine, danger }; +} + +// Order-Buttons (LONG/SHORT/CLOSE). +// Eröffnen wird NIE blockiert/abgefragt — bei WARTEN/Gegen-Signal nur ein Hinweis +// (Toast), der Trade läuft trotzdem. Schließen direkt — außer im Minus: +// Sicherheitsabfrage (kein versehentlicher Verlust-Close). +document.querySelectorAll(".btn[data-side]").forEach(btn => { + btn.onclick = async () => { + if (btn.disabled) return; + const side = btn.dataset.side; + // ⚠⚠ ENTRY-DIALOG ENTFERNT (User-Vorgabe 2026-08-11: „entferne auch die + // Warnung beim Eröffnen von Trades"). LONG/SHORT feuern jetzt sofort. + // ⚠ Was damit wegfällt: die 7-Punkte-Checkliste (Signal-Deckung · HTF-Trend · + // Entry-Raum · News · Kosten · Verlust-Serie · Nacht) UND der + // Ausrichtungs-Split im Moment der Entscheidung. Letzterer zeigte die + // einzige beidhälftig robuste Zahl des Projekts: gegen das Signal + // −6,60 € je Lot über 1.216 Trades. Genau diese diskretionären + // Abweichungen sind die gemessene Kern-Leckage. + // ⚠ BEWUSSTE ENTSCHEIDUNG DES USERS, nicht ein Messergebnis. Sie deckt sich + // aber mit der Projekt-Linie „sichtbar machen, nicht bevormunden": + // Übersteuerungen liefen gemessen 68 % Trefferquote, und hartes Blockieren + // war ohnehin nie eingebaut. + // ✅ Das Backend ist UNBERÜHRT: `engine._entry_checklist` rechnet weiter und + // steht als `checklist` im Snapshot — nur der Dialog ist weg. Die + // Zahlen bleiben also auswertbar, und Zurückholen ist ein Einzeiler + // (`const ENTRY_DIALOG = true`). + const ENTRY_DIALOG = false; + if (ENTRY_DIALOG && (side === "long" || side === "short")) { + const cl = (lastSnap.checklist || {})[side]; + const info = recInfo(side); + if (cl && (cl.fails > 0 || cl.warns > 0)) { + const ICON = { fail: "❌", warn: "⚠", ok: "✅" }; + const bad = cl.items.filter(x => x.state !== "ok"); + const good = cl.items.filter(x => x.state === "ok"); + let body = bad.map(x => `${ICON[x.state]} ${x.label}` + + (x.detail ? `\n ${x.detail}` : "")).join("\n"); + if (good.length) body += `\n\n${good.map(x => `✅ ${x.label}`).join("\n")}`; + // Eigene Zahlen zur Ausrichtung ergänzen (gemessene Kern-Leckage) + const al = lastSnap.alignment || null; + const sigNow = (lastSnap.wave_signal || {}).signal || "WARTEN"; + const isWait0 = (sigNow === "WARTEN" || sigNow === "—"); + if (al && cl.items.some(x => x.key === "signal" && x.state === "fail")) { + const grp = isWait0 ? al.none : al.against, ref = al.with; + // ⚠ JE LOT ist die belastbare Zahl: absolute Euro-Vergleiche sind bei + // wechselnder Positionsgroesse ungueltig (Lehre 04.08. — ein reiner + // Groessenzuwachs sah wie ein Strategie-Effekt aus). Seit 06.08. laeuft + // der Split ueber die VOLLE Historie (1.216 Trades) statt ueber 40. + const f = g => { + if (!g || g.n < 5) return null; + const lot = (g.pnl_lot === null || g.pnl_lot === undefined) ? "" + : ` · ${g.pnl_lot >= 0 ? "+" : ""}${fmt(g.pnl_lot, 2)} €/Lot`; + return `${g.wr}% Treffer${lot} · ${g.pnl >= 0 ? "+" : ""}${fmt(g.pnl, 0)} € (n${g.n})`; + }; + const own = f(grp), rf = f(ref); + if (own) body += `\n\n📊 Deine Trades ${isWait0 ? "OHNE" : "GEGEN"} Signal: ${own}`; + if (rf) body += `\n📊 Mit Signal: ${rf}`; + } + const head = cl.verdict === "stop" + ? `🛑 Checkliste sagt NEIN (${cl.fails} Punkte dagegen)` + : `⚠ Checkliste: ${cl.fails} dagegen, ${cl.warns} Vorsicht`; + const ok = await modal(head, body + `\n\n${side.toUpperCase()} trotzdem eröffnen?`, + "confirm", true); + if (!ok) return; + } else if (info.danger) { // Fallback: alter Hinweis (kein Block) + const sig = (lastSnap.wave_signal || {}).signal || "WARTEN"; + const isWait = (sig === "WARTEN" || sig === "—"); + const head = isWait + ? "ℹ Keine Empfehlung (WARTEN)" + : `ℹ Empfehlung steht GEGEN dich (${sig})`; + // Ehrliche EIGENE Zahlen (letzte 40 Trades, Ausrichtungs-Split aus der DB): + // die gemessene Kern-Leckage sind genau diese diskretionären Abweichungen. + let statLine = ""; + const al = lastSnap.alignment || null; + if (al) { + const grp = isWait ? al.none : al.against; + const ref = al.with; + const f = g => (g && g.n >= 5) + ? `${g.wr}% Trefferquote · ${g.pnl >= 0 ? "+" : ""}${fmt(g.pnl, 0)} € (n${g.n})` + : null; + const own = f(grp), rf = f(ref); + if (own) statLine += `\n\n📊 Deine Trades ${isWait ? "OHNE Signal" : "GEGEN das Signal"}: ${own}`; + if (rf) statLine += `\n📊 Mit Signal: ${rf}`; + } + const ok = await modal(head, + info.line + statLine + `\n\n${side.toUpperCase()} trotzdem eröffnen?`, + "confirm", true); + if (!ok) return; // Abbrechen = doch nicht; sonst läuft er + } + } + if (side === "close") { // Sicherheitsabfrage bei Verlust + const p = lastSnap.position || {}; + const pnl = p.pnl || 0; + if (p.ticket && pnl < 0) { + const cs = curSym(lastSnap.market || {}); + const ok = await modal( + "⚠ Position im Minus schließen?", + `Aktueller Verlust: ${cs}${fmt(pnl, 2)}\n\nWirklich jetzt mit Verlust schließen?`, + "confirm", true); + if (!ok) return; + } + } + const tok = await ensureToken(); + if (tok === null) return; // null = Token abgebrochen ("" = kein Token nötig) + try { + const res = await sendOrder(side, tok); + res.ok ? toast(`${side.toUpperCase()} ausgeführt`, true) + : toast(`Fehler: ${res.error || "unbekannt"}`, false); + } catch (e) { + toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false); + } + }; +}); + +// (Notfall-Stop-UI entfernt 2026-07-17 — POST /api/emergency existiert weiter, ohne UI.) +// („Gewinn mitnehmen"-UI entfernt — POST /api/takeprofit existiert weiter, ohne UI.) + +// Manuelles SL/TP (Trade-Bereich) — sendet beide Felder; deaktiviert das Trailing. +async function sendSltp() { + // ⚠ STRENG (s. `$streng` oben): hier ist „Feld fehlt" NICHT „Feld ist leer". + // Leer heisst ausdruecklich „Stop entfernen" (`sl: null`) — mit dem + // Auffang-Element waere `.value` `undefined`, `parseFloat` liefert NaN, und + // `JSON.stringify` macht daraus wieder `null`. Eine fehlende Eingabemaske + // wuerde so den Broker-SL einer laufenden Position stillschweigend loeschen. + const elSl = $streng("tb-sl"), elTp = $streng("tb-tp"); + if (!elSl || !elTp) { + toast("SL/TP-Felder nicht geladen — Seite neu laden (Strg+F5)", false); + return; + } + const slv = elSl.value, tpv = elTp.value; + const tok = await ensureToken(); + if (tok === null) return; + elSl.blur(); elTp.blur(); + try { + const r = await fetch("api/sltp", { method: "POST", + headers: { "X-Auth-Token": tok, "Content-Type": "application/json" }, + body: JSON.stringify({ sl: slv === "" ? null : parseFloat(slv), + tp: tpv === "" ? null : parseFloat(tpv) }) }); + if (r.status === 401) { clearToken(); throw new Error("401"); } + const j = await r.json(); + if (j.ok) toast(`SL/TP gesetzt${j.trail_was_on ? " · Trailing AUS" : ""}`, true); + else toast(j.error || "Broker lehnte ab", false); + } catch (e) { + toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false); + } +} +["tb-sl", "tb-tp"].forEach(id => { + $(id).addEventListener("change", sendSltp); + $(id).addEventListener("keydown", e => { + if (e.key === "Enter") { e.preventDefault(); sendSltp(); } + }); +}); + +// Mute-Button (oben rechts): alle akustischen Signale an/aus +function _updateMuteBtn() { + const b = $("mute-btn"); + if (b) { b.textContent = _muted ? "🔇" : "🔊"; b.classList.toggle("muted", _muted); } +} +$("mute-btn").onclick = () => { + _muted = !_muted; + localStorage.setItem("oil_muted", _muted ? "1" : "0"); + _updateMuteBtn(); + toast(_muted ? "🔇 Alle Töne aus" : "🔊 Töne an", true); +}; +_updateMuteBtn(); + +// Toggle-Buttons (AUTO/TRAIL/S-R-Close) — ohne Bestätigung +$("btn-squeeze").onclick = () => toggle("api/autosqueeze", "Auto-Squeeze (autonom!)"); +// (🎯 SIG entfernt 2026-08-12 — s. renderOrderButtons oben) +$("btn-trail").onclick = () => toggle("api/trail", "Trailing"); +$("srclose-btn").onclick = () => toggle("api/srclose", "Auto-S/R-Close"); + +// Mindestgewinn für den S/R-Auto-Close (0/leer = aus) +async function setSrMin() { + const raw = $("srmin-input").value; + const v = raw === "" ? 0 : parseFloat(raw); + if (isNaN(v) || v < 0) { toast("Bitte Betrag ≥ 0 eingeben (0 = aus)", false); return; } + const tok = await ensureToken(); + if (tok === null) return; + $("srmin-input").blur(); + try { + const r = await fetch("api/srclose_min", { method: "POST", + headers: { "X-Auth-Token": tok, "Content-Type": "application/json" }, + body: JSON.stringify({ value: v }) }); + if (r.status === 401) { clearToken(); throw new Error("401"); } + toast(v > 0 ? `Mindestgewinn +${v} gesetzt` : "Mindestgewinn aus", true); + } catch (e) { + toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false); + } +} +// Feld sitzt in der Button-Zeile: setzt beim Verlassen/Enter automatisch +$("srmin-input").addEventListener("change", setSrMin); +// Enter NUR blurren → das change-Event feuert setSrMin genau EINMAL (und nur, wenn +// der Wert wirklich geändert wurde). Vorher: keydown + change = doppelter Send, +// und Enter auf leerem Feld schickte ungewollt 0 (Fix 2026-07-22). +$("srmin-input").addEventListener("keydown", e => { + if (e.key === "Enter") { e.preventDefault(); e.target.blur(); } +}); + +// Feste Einsatz-Margin (User-Wunsch 2026-08-04). Leer/0 = automatisch. +// ⚠ Wirkt auf ALLE neuen Positionen, auch die autonomen — deshalb sagt der Toast +// ausdrücklich, was gilt, statt nur „gespeichert". +async function setManualMargin() { + const el = $("margin-input"); if (!el) return; + const raw = el.value; + const v = raw === "" ? 0 : parseFloat(raw); + if (isNaN(v) || v < 0) { toast("Bitte Betrag ≥ 0 eingeben (0 = automatisch)", false); return; } + const tok = await ensureToken(); + if (tok === null) return; + el.blur(); + try { + const r = await fetch("api/manualmargin", { method: "POST", + headers: { "X-Auth-Token": tok, "Content-Type": "application/json" }, + body: JSON.stringify({ value: v }) }); + if (r.status === 401) { clearToken(); throw new Error("401"); } + const j = await r.json(); + toast(v > 0 ? `Einsatz fest: ${fmt(j.value ?? v, 2)} € je Position` + : "Einsatz wieder automatisch (95 % der freien Margin)", true); + } catch (e) { + toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false); + } +} +(() => { + const el = $("margin-input"); + if (!el) return; // null-sicher (Cache-Regel) + el.addEventListener("change", setManualMargin); + el.addEventListener("keydown", e => { + if (e.key === "Enter") { e.preventDefault(); e.target.blur(); } + }); +})(); + +// Einsatz in PROZENT der freien Margin (User-Wunsch 2026-08-05) — das Gegenstück +// zum festen Betrag oben. Steuert `margin_buffer_pct`, das bisher nur in der ini +// stand. ⚠ Ein gesetzter fester Betrag hat Vorrang; darauf weist der Toast hin. +async function setMarginPct() { + const el = $("marginpct-input"); if (!el) return; + const v = parseFloat(el.value); + if (isNaN(v) || v < 1 || v > 99) { + toast("Bitte 1–99 % eingeben (100 % ließe keinen Puffer für Spread/Swap)", false); + return; + } + const tok = await ensureToken(); + if (tok === null) return; + el.blur(); + try { + const r = await fetch("api/marginpct", { method: "POST", + headers: { "X-Auth-Token": tok, "Content-Type": "application/json" }, + body: JSON.stringify({ value: v }) }); + if (r.status === 401) { clearToken(); throw new Error("401"); } + const j = await r.json(); + if (j.ok === false) { toast(j.error || "abgelehnt", false); return; } + const fest = parseFloat(($("margin-input") || {}).value || "0") > 0; + toast(`Einsatz: ${fmt(j.value ?? v, 0)} % der freien Margin` + + (fest ? " — greift erst, wenn der feste Betrag links leer ist" : ""), true); + } catch (e) { + toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false); + } +} +(() => { + const el = $("marginpct-input"); + if (!el) return; // null-sicher (Cache-Regel) + el.addEventListener("change", setMarginPct); + el.addEventListener("keydown", e => { + if (e.key === "Enter") { e.preventDefault(); e.target.blur(); } + }); +})(); + +// Notfall-Stop: manueller Verlust-Wert, bei dem der Server sofort schließt. +// Wieder eingeblendet 2026-07-22 (Backend/Persistenz waren die ganze Zeit aktiv). +async function setEmg() { + const raw = $("emg-input").value; + const v = raw === "" ? 0 : parseFloat(raw); + if (isNaN(v) || v < 0) { toast("Bitte Betrag ≥ 0 eingeben (0 = aus)", false); return; } + const tok = await ensureToken(); + if (tok === null) return; + $("emg-input").blur(); + try { + const r = await fetch("api/emergency", { method: "POST", + headers: { "X-Auth-Token": tok, "Content-Type": "application/json" }, + body: JSON.stringify({ value: v > 0 ? v : null }) }); + if (r.status === 401) { clearToken(); throw new Error("401"); } + toast(v > 0 ? `Notfall-Stop bei −${v} gesetzt` : "Notfall-Stop aus", true); + } catch (e) { + toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false); + } +} +$("emg-input").addEventListener("change", setEmg); +// Enter blurrt nur (kein doppeltes Senden, kein ungewolltes 0 auf leerem Feld — +// derselbe Fix wie beim Mindestgewinn-Feld, 2026-07-22). +$("emg-input").addEventListener("keydown", e => { + if (e.key === "Enter") { e.preventDefault(); e.target.blur(); } +}); + +async function toggle(url, label) { + const tok = await ensureToken(); + if (tok === null) return; // null = abgebrochen ("" = kein Token nötig) + try { + const res = await postAuth(url, tok); + toast(`${label} ${res.enabled ? "AN" : "AUS"}`, true); + } catch (e) { + toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false); + } +} + +// ── Modul-Menü / Views ───────────────────────────────────────────────────── +function showView(name) { + document.querySelectorAll(".view").forEach(v => v.classList.add("hidden")); + const el = document.getElementById("view-" + name); + if (el) el.classList.remove("hidden"); + document.querySelectorAll(".tab").forEach(t => + t.classList.toggle("active", t.dataset.view === name)); + if (name === "logs") loadLogs(); + if (name === "stats") loadStats(); + if (name === "news") loadNews(); +} +// Nur echte View-Tabs registrieren — NICHT den Mute-Button (der trägt .tab nur +// fürs Styling; ohne [data-view]-Filter überschrieb das hier seinen onclick und +// showView(undefined) blendete alle Views aus). +document.querySelectorAll(".tab[data-view]").forEach(t => t.onclick = () => showView(t.dataset.view)); + +async function loadLogs() { + const el = $("logs-body"); + el.textContent = "lade …"; + try { + const d = await (await fetch("api/logs?lines=300")).json(); + if (d.error) { el.textContent = "Fehler: " + d.error; return; } + el.textContent = (d.lines || []).join("\n"); + el.scrollTop = el.scrollHeight; + } catch (e) { el.textContent = "Netzwerkfehler"; } +} + +function statBlock(title, s) { + const cs = curSym(lastSnap.market || {}); + if (!s || s.error) + return `

    ${title}

    ${(s && s.error) || "—"}
    `; + const money = (v, sign) => `${sign && v >= 0 ? '+' : ''}${cs}${(v || 0).toFixed(2)}`; + const pf = s.profit_factor != null ? s.profit_factor.toFixed(2) : "—"; + const hasWht = (s.wht_pct || 0) > 0; + const npf = s.net_profit_factor != null ? s.net_profit_factor.toFixed(2) : "—"; + + // Brutto-Block + let rows = ` +
    Trades${s.n_trades}
    +
    Winrate${(s.winrate || 0).toFixed(0)}%
    +
    P&L brutto${money(s.total_pnl, true)}
    +
    Profit-Faktor${pf}
    `; + + // Netto-Block (nur wenn WHT konfiguriert) + if (hasWht) { + rows += ` +
    WHT ${s.wht_pct}%−${cs}${(s.wht || 0).toFixed(2)}
    +
    P&L netto${money(s.net_pnl, true)}
    +
    Profit-Faktor netto${npf}
    `; + } + rows += ` +
    Ø Gewinn${hasWht ? ' netto' : ''}+${cs}${((hasWht ? s.net_avg_win : s.avg_win) || 0).toFixed(2)}
    +
    Ø Verlust${cs}${(s.avg_loss || 0).toFixed(2)}
    `; + + return `

    ${title}

    ${rows}
    `; +} +function setupRow(s) { + const cs = curSym(lastSnap.market || {}); + const hasWht = (lastSnap._wht || 0) > 0 || s.net_pnl != null; + const pnl = hasWht ? (s.net_pnl ?? s.total_pnl) : s.total_pnl; + const pf = hasWht ? s.net_profit_factor : s.profit_factor; + const cls = pnl >= 0 ? "up" : "down"; + return `
    +
    ${(s.setup || "—").replace(/ +
    ${s.n}× ${(s.winrate || 0).toFixed(0)}%
    +
    ${pnl >= 0 ? "+" : ""}${cs}${(pnl || 0).toFixed(2)}
    +
    PF ${pf != null ? pf.toFixed(2) : "—"}
    +
    `; +} + +// Auto-Squeeze-Monitor (B4): Entry-Edge (Mechanik) + Live-Trades vs. Erwartung. +const _SQM_VERDICT = { + ontrack: ["✓ trägt live", "up"], weak: ["○ dünn/schwach", "dim"], + drift: ["⚠ driftet — B5 prüfen", "down"], watch: ["… zu wenig Daten", "dim"], +}; +function renderSqueezeMonitor(d) { + const box = $("sqm-box"); + if (!d || d.error) { box.innerHTML = '
    Monitor n/v
    '; return; } + const m = d.mech || {}, lv = d.live || {}, ex = d.expect || {}; + const [vtxt, vcls] = _SQM_VERDICT[d.verdict] || ["—", "dim"]; + const row = (k, v, cls) => `
    ${k}${v}
    `; + const sign = (x, dg = 2) => (x == null ? "—" : (x >= 0 ? "+" : "") + Number(x).toFixed(dg)); + // Mechanik-Block (Entry-Edge isoliert, apples-to-apples zum Backtest) + let mechHtml; + if (m.error) mechHtml = `
    ${m.error}
    `; + else if (!m.n) mechHtml = '
    keine Ausbrüche im Fenster
    '; + else { + const orCls = m.oR >= (ex.or_lo || 0.14) ? "up" : m.oR > 0 ? "" : "down"; + mechHtml = row("Ausbrüche (30 T)", m.n) + row("Treffer", m.wr + " %") + + row("ØR", sign(m.oR, 3), orCls) + row("PF", (m.pf != null ? m.pf.toFixed(2) : "—"), m.pf > 1 ? "up" : "down"); + } + // Live-Block (echt, Gesamtsystem inkl. reale Exits) + let liveHtml; + if (!lv.n) liveHtml = '
    noch keine Auto-Squeeze-Trades
    '; + else { + const dn = lv.night || {}, dy = lv.day || {}; + liveHtml = row("Trades", lv.n) + row("Treffer", lv.wr + " %") + + row("Ø-Gewinn / -Verlust", `${sign(lv.avg_win)} / ${sign(lv.avg_loss)} €`) + + row("Verhältnis", lv.ratio != null ? lv.ratio.toFixed(2) : "—", lv.ratio >= 1 ? "up" : "down") + + row("Σ real", sign(lv.sum_eur) + " €", lv.sum_eur >= 0 ? "up" : "down") + + row("Tag / Nacht", `${sign(dy.sum)} / ${sign(dn.sum)} € (${dy.n || 0}/${dn.n || 0})`); + } + box.innerHTML = + `

    Auto-Squeeze-Monitor (B4)

    ` + + `${vtxt}
    ` + + `
    Entry-Edge · Mechanik (~30 T M5, isoliert)
    ` + + `
    ${mechHtml}
    Erwartung ØR +0,14…+0,23 · PF>1
    ` + + `
    Live-Trades · echt (Gesamtsystem)
    ` + + `
    ${liveHtml}
    ` + + `
    Mechanik = nur der Squeeze-Einstieg mit Backtest-Exit ` + + `(zeigt, ob der Edge lebt). Live = die echten Trades inkl. realer Exits/Eingriffe. ` + + `Klaffen sie auseinander, liegt es am Exit/Verhalten, nicht am Signal.
    `; +} +async function loadSqueezeMonitor() { + try { + const d = await (await fetch("api/squeeze_monitor")).json(); + renderSqueezeMonitor(d); + } catch (e) { $("sqm-box").innerHTML = '
    Monitor-Fehler
    '; } +} + +async function loadStats() { + loadSqueezeMonitor(); + const el = $("stats-body"), setEl = $("stats-setups"); + el.innerHTML = '
    lade …
    '; + try { + const d = await (await fetch("api/stats")).json(); + lastSnap._wht = d.wht_pct || 0; + el.innerHTML = statBlock("Heute", d.today) + statBlock("Woche", d.week) + + statBlock("Gesamt", d.all) + + `
    Trades in DB gesamt: ${d.n_total ?? "—"}
    `; + const ss = (d.setups || []).filter(x => !x.error); + setEl.innerHTML = ss.length + ? `
    ${d.wht_pct > 0 ? "P&L/PF = netto nach " + d.wht_pct + "% WHT" : "brutto"}
    ` + + ss.map(setupRow).join("") + : '
    keine Setup-Daten
    '; + } catch (e) { el.innerHTML = '
    Netzwerkfehler
    '; } +} + +async function loadNews() { + const sEl = $("news-sentiment"), bEl = $("news-body"); + bEl.innerHTML = '
    lade …
    '; sEl.textContent = ""; + try { + const d = await (await fetch("api/news")).json(); + const s = d.sentiment || {}; + if (s.score != null) { + const sc = s.score > 0.05 ? "up" : s.score < -0.05 ? "down" : "dim"; + sEl.innerHTML = `Sentiment ${s.score >= 0 ? '+' : ''}${(s.score || 0).toFixed(2)}` + + ` · ▲${s.n_bull || 0} ▼${s.n_bear || 0}`; + } + const hs = d.headlines || []; + bEl.innerHTML = hs.length ? hs.map(h => + `` + + `
    ${(h.title || '').replace(/` + + `
    ${(h.source || '').replace(/`).join("") + : `
    ${d.error || "noch keine News geladen"}
    `; + } catch (e) { bEl.innerHTML = '
    Netzwerkfehler
    '; } +} + +$("logs-reload").onclick = loadLogs; +$("stats-reload").onclick = loadStats; +$("news-reload").onclick = loadNews; +// Auto-Refresh nur solange der Charts-Tab sichtbar ist (schont den mt5_lock) + +// Sofort einmal per REST laden, dann auf den Live-Stream umschalten +fetch("api/snapshot").then(r => r.json()).then(_applySnapshot).catch(() => {}); +connect(); + +// ── Wake-up nach Sperrbildschirm/Hintergrund (Fix 2026-07-22: „G/V lag") ────── +// Mobile Browser pausieren WS+Timer beim Sperren/Backgrounding — die Seite zeigt +// bis zu einem Reconnect den STALEN Stand. Beim Zurückkommen (Tab/App wieder +// sichtbar) sofort per REST einen frischen Snapshot holen UND die WS-Verbindung +// hart neu aufbauen (statt auf den evtl. hängenden alten Socket zu warten). +function _wakeRefresh() { + fetch("api/snapshot").then(r => r.json()).then(_applySnapshot).catch(() => {}); + if (!sock || sock.readyState !== WebSocket.OPEN) { + clearTimeout(retry); + try { sock && sock.close(); } catch (e) {} + connect(); + } +} +document.addEventListener("visibilitychange", () => { + if (!document.hidden) _wakeRefresh(); +}); +window.addEventListener("pageshow", _wakeRefresh); +window.addEventListener("focus", _wakeRefresh); diff --git a/web/index.html b/web/index.html index ea312c4..574bb88 100644 --- a/web/index.html +++ b/web/index.html @@ -12,7 +12,7 @@ ⚠ LOKAL eingebunden, kein CDN: die PWA laeuft ueber WireGuard und muss offline funktionieren. --> - + @@ -156,6 +156,23 @@
    + + +

    Marktkontext

    @@ -370,6 +387,6 @@
    - +