Korrektur meiner Behauptung vom 11.08. Nach dem Neustart um 22:45 gab
es bis 09:00 mindestens 10 gerichtete Episoden, und rec_outcomes stand
weiter bei 0 Zeilen. Die Praemisse (falsches dict: s.get("pb_feats")
auf dem MT5Data- statt dem Wave-Snapshot) ist live belegt - die
WIRKUNG des Fixes war es nicht. Ich habe "behoben" geschrieben, ohne
sie zu pruefen.
Warum es von aussen nicht feststellbar war: der except-Zweig loggte
auf DEBUG, der Logger steht auf INFO. Genau die Blindheit, die den
Ausfall fuenf Tage getragen hat.
Jetzt melden sich BEIDE Zweige sichtbar:
- Erfolg: "rec_outcome #<id>: LONG @ <px> atr=<atr>" (INFO)
- Fehlschlag: "rec_outcome NICHT geschrieben: atr=.. px=.. pb_feats=.."
- Exception: WARNING statt DEBUG, mit repr
Es feuert nur bei einem echten Richtungswechsel, also selten - kein
Laerm. Ursache offen bis zur naechsten Episode.
Der Telemetrie-Puls ist der Grund, dass die ausbleibende Wirkung
ueberhaupt auffiel.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4734 lines
269 KiB
Python
4734 lines
269 KiB
Python
"""
|
||
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
|
||
_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
|
||
|
||
# ── 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
|
||
# Signal-Pfad bekommt dieselbe Mechanik (2026-08-05, gemessen
|
||
# `backtest_auto_signal_v3.py`: am Bestätigungs-Level ØR +0,35/+0,32 statt
|
||
# −0,09/−0,11 am Markt — SIG besteht damit ERSTMALS die 2-Stichproben-Regel).
|
||
self._signal_pending = self.cfg["trading"].get(
|
||
"signal_pending_entry", "true").lower() == "true"
|
||
self._pending_levels = None
|
||
self._pending_quelle = ""
|
||
# 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._signal_chase_logged = None
|
||
self._squeeze_reverse_sig = None # Dedup Reverse-Close (Gegen-Trade)
|
||
self._squeeze_entry_count = 0
|
||
# ── Autonomer Entry auf die EMPFEHLUNG (User-Wunsch 2026-07-30) ───────────
|
||
# ⚠ GEMESSEN NICHT tragfähig (H1 negativ, s. backtest_auto_signal.py) — auf
|
||
# ausdrücklichen User-Wunsch gebaut, Default AUS, mit den besten Parametern.
|
||
_t = self.cfg["trading"]
|
||
self._auto_signal = _t.get("auto_signal", "false").lower() == "true"
|
||
try:
|
||
self._auto_signal_min_conf = int(float(_t.get("auto_signal_min_conf", "75")))
|
||
except (TypeError, ValueError):
|
||
self._auto_signal_min_conf = 75
|
||
self._auto_signal_skip_night = _t.get("auto_signal_skip_night", "true").lower() == "true"
|
||
self._signal_last_sig = None # Dedup: (dir, Signal-Episode) 1× handeln
|
||
self._signal_entry_count = 0
|
||
# 15-Minuten-Regel für BOT-eröffnete Trades (gemessen kalibriert: 0,5×ATR)
|
||
try:
|
||
self._adverse15_atr = max(0.0, float(_t.get("adverse_15min_atr", "0.5")))
|
||
except (TypeError, ValueError):
|
||
self._adverse15_atr = 0.5
|
||
self._bot_open_ticket = None # Ticket, das der Bot selbst eröffnet hat
|
||
self._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 # <terminal>\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"
|
||
# 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
|
||
self._check_auto_signal() # autonomer Entry auf die Empfehlung
|
||
except Exception as e:
|
||
log.error(f"_pos_loop: {e}", exc_info=True)
|
||
self._sleep(_POS_S)
|
||
|
||
def set_emergency(self, value) -> float | None:
|
||
"""Notfall-Verlust-Schwelle setzen/löschen (Kontowährung, als positiver
|
||
Betrag gespeichert). None/leer/0 → aus. Rückgabe = aktiver Wert."""
|
||
try:
|
||
v = abs(float(value)) if value not in (None, "", "0", 0) else None
|
||
if v is not None and v <= 0:
|
||
v = None
|
||
except (TypeError, ValueError):
|
||
v = None
|
||
# Vertipper-Schutz: ein Wert unter dem Minimum (Spread-Region) würde den
|
||
# Trade sofort schließen UND sich auf alle Folge-Trades vererben → als „aus"
|
||
# behandeln (Fix 2026-07-27, real: −1-€-Stop killte jeden Trade sofort).
|
||
if v is not None and self._emergency_min > 0 and v < self._emergency_min:
|
||
log.warning(f"Notfall-Stop −{v:.2f} < Minimum {self._emergency_min:.2f} € "
|
||
f"(zu eng, würde sofort schließen) → als AUS behandelt")
|
||
v = None
|
||
self._emergency_loss = v
|
||
if v is not None:
|
||
self._emergency_remember = v # für die nächste Position merken
|
||
else:
|
||
self._emergency_remember = 0.0 # „aus" schaltet auch den Auto-Arm ab
|
||
log.info(f"Notfall-Stop {('gesetzt: -' + format(v, '.2f')) if v else 'aus'}")
|
||
self._save_emergency_state() # Handeingabe neustart-fest (Fix 2026-07-17)
|
||
return self._emergency_loss
|
||
|
||
def set_takeprofit(self, value) -> float | None:
|
||
"""Gewinn-Ziel setzen/löschen (Kontowährung, positiver Betrag). None/leer/0
|
||
→ aus. Gemerkt für die nächste Position. Rückgabe = aktiver Wert."""
|
||
try:
|
||
v = abs(float(value)) if value not in (None, "", "0", 0) else None
|
||
if v is not None and v <= 0:
|
||
v = None
|
||
except (TypeError, ValueError):
|
||
v = None
|
||
self._takeprofit = v
|
||
if v is not None:
|
||
self._takeprofit_remember = v
|
||
else:
|
||
self._takeprofit_remember = 0.0 # „aus" schaltet auch den Auto-Arm ab
|
||
log.info(f"Gewinn-Ziel {('gesetzt: +' + format(v, '.2f')) if v else 'aus'}")
|
||
self._save_emergency_state() # Handeingabe neustart-fest (Fix 2026-07-17)
|
||
return self._takeprofit
|
||
|
||
def _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"🛑 <b>CIRCUIT BREAKER</b>\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"⚠ <b>{ok_word} FEHLGESCHLAGEN</b>\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} <b>{ok_word}</b>\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"⚠ <b>S/R-Close FEHLGESCHLAGEN</b>\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"🎯 <b>S/R-Auto-Close</b>\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."""
|
||
if pfad == "squeeze":
|
||
if not self._auto_squeeze:
|
||
return "Auto-Squeeze aus"
|
||
skip = self._squeeze_skip_night
|
||
else:
|
||
if not self._auto_signal:
|
||
return "Auto-Signal aus"
|
||
skip = self._auto_signal_skip_night
|
||
if self._circuit_halted():
|
||
return "Circuit-Breaker"
|
||
if 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"
|
||
setup = f"SQUEEZE_{d}" if quelle == "squeeze" else f"AUTOSIG_{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" if quelle == "squeeze" else "auto_signal"
|
||
if quelle == "squeeze":
|
||
self._squeeze_entry_count += 1
|
||
else:
|
||
self._signal_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"🎯 <b>Pending-Fill ({quelle})</b>\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")
|
||
# ── 2) Signal: EINE Order am Bestätigungs-Level ──────────────────
|
||
# Gemessen `backtest_auto_signal_v3.py`: am Level ØR +0,35/+0,32 (PF 2,2)
|
||
# gegen −0,09/−0,11 am Markt — dieselbe Mechanik, die den Squeeze rettet.
|
||
bo = ws.get("breakout") or {}
|
||
if (self._signal_pending and bo.get("pending") and bo.get("level")
|
||
and bo.get("dir") in ("LONG", "SHORT")
|
||
and (bo.get("conf") or 0) >= self._auto_signal_min_conf
|
||
and self._auto_guard("signal") is None):
|
||
ot = (mt5.ORDER_TYPE_BUY_STOP if bo["dir"] == "LONG"
|
||
else mt5.ORDER_TYPE_SELL_STOP)
|
||
return {ot: round(float(bo["level"]), 3)}, "signal"
|
||
return {}, ""
|
||
|
||
def _manage_squeeze_pending(self):
|
||
"""RUHENDE Stop-Orders an beiden Box-Grenzen, solange der Squeeze `armed` ist.
|
||
|
||
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"🔄 <b>Auto-Squeeze-Reverse</b>\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"⚠ <b>Auto-Squeeze-Entry FEHLGESCHLAGEN</b>\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"🚀 <b>Auto-Squeeze-Entry</b>\n{sym} {d} selbständig "
|
||
f"eröffnet (Ausbruch aus Kompression @ {lvl}).",
|
||
tg["bot_token"], tg["chat_id"])
|
||
except Exception as e: log.warning(f"Squeeze-Telegram: {e}")
|
||
except Exception as e:
|
||
log.error(f"_check_auto_squeeze: {e}", exc_info=True)
|
||
|
||
def set_auto_squeeze(self, on: bool) -> bool:
|
||
"""Autonomen Squeeze-Entry an/aus (UI/Config). Rückgabe = neuer Zustand."""
|
||
self._auto_squeeze = bool(on)
|
||
self._squeeze_last_sig = None
|
||
log.info(f"Auto-Squeeze-Entry {'AN' if self._auto_squeeze else 'AUS'}")
|
||
self._save_runtime_state()
|
||
return self._auto_squeeze
|
||
|
||
def _check_auto_signal(self):
|
||
"""Autonomer Entry auf die EMPFEHLUNG (Wave-Signal), nur wenn FLAT.
|
||
⚠ GEMESSEN NICHT tragfähig (`backtest_auto_signal.py`: H1 ØR −0,05…−0,18 in
|
||
BEIDEN Konfidenz-Stufen, H2 positiv = Regime-Kippen; Live-Dry-Run 2026-07-23
|
||
WR 29 %/PF 0,64). Auf ausdrücklichen User-Wunsch (2026-07-30) gebaut, Default
|
||
AUS, mit den gemessen besten Parametern (Konf≥75 + Nacht-Sperre) und dem
|
||
15-Min-Adverse-Schutz. B4/B5: Live-Perf. gegen die Erwartung halten — driftet
|
||
es weg, zurückbauen. Dedup: 1× je Signal-Episode (re-armt bei WARTEN/Flip)."""
|
||
if not self._auto_signal:
|
||
return
|
||
if self._circuit_halted(): # Tagesverlust-Stopp → heute keine Auto-Trades
|
||
return
|
||
try:
|
||
sig_snap = self.wave.signal() or {}
|
||
d = sig_snap.get("signal")
|
||
if d not in ("LONG", "SHORT"):
|
||
self._signal_last_sig = None # WARTEN → Episode zu Ende, re-armen
|
||
return
|
||
conf = sig_snap.get("conf_pct") or 0
|
||
if conf < self._auto_signal_min_conf:
|
||
return # zu schwach (Dedup NICHT setzen)
|
||
# Nacht-Sperre — dasselbe Fenster wie beim Auto-Squeeze
|
||
# (`auto_squeeze_night_hours`, Default 3–6). Kein Dedup-Marker, damit das
|
||
# Signal danach noch feuern kann, falls es dann noch steht.
|
||
# ⚠ Anders als beim Squeeze ist die Kostenfalle für die WELLEN-Population
|
||
# gemessen einschlägig (alle Nacht-Bars 0,333×ATR) — hier ist der Guard also
|
||
# auch ertrags-begründet. Der Pfad ist ohnehin aus (`auto_signal=false`).
|
||
if (self._auto_signal_skip_night
|
||
and datetime.now(_BERLIN).hour in self._squeeze_night_hours):
|
||
return
|
||
# News-Blackout (s. `_check_auto_squeeze`) — kein Dedup-Marker.
|
||
if self.calendar.blackout():
|
||
return
|
||
ps = self.trader.snapshot()
|
||
if ps.get("ticket"):
|
||
# Position offen: läuft sie in Signalrichtung, gilt die Episode als
|
||
# „schon dabei" (kein Nachkauf); Gegen-Position wird NICHT gedreht
|
||
# (unbelegt — dieselbe Begründung wie beim Squeeze-Reverse).
|
||
pos_dir = "LONG" if ps.get("order_type") == 0 else "SHORT"
|
||
if pos_dir == d:
|
||
self._signal_last_sig = d
|
||
return
|
||
if d == self._signal_last_sig:
|
||
return # diese Episode schon gehandelt
|
||
# Wiedereinstiegs-Sperren (wie beim Squeeze): nach S/R-Close bzw. nach
|
||
# JEDEM Close nicht sofort gleichgerichtet neu eröffnen.
|
||
now = time.time()
|
||
if (d == self._sr_close_dir and now - self._sr_close_ts < _SQUEEZE_SR_COOLDOWN_S):
|
||
return
|
||
if (d == self._pos_close_dir and now - self._pos_close_ts < _SQUEEZE_REENTRY_COOLDOWN_S):
|
||
return
|
||
# ── NACHJAGD-BREMSE (2026-08-05, gemessen `backtest_auto_signal_v3.py`).
|
||
# Der Market-Weg ist nur noch FALLBACK, wenn die ruhende Stop-Order am
|
||
# Bestätigungs-Level nicht gegriffen hat. Am Level ØR +0,35/+0,32, am
|
||
# Bar-Close −0,09/−0,11 — und der Härtetest zeigt den Kipp-Punkt bei
|
||
# ~0,25–0,30×ATR Abstand (bei +0,20 noch +0,148/+0,090). Also nur bis
|
||
# `squeeze_max_chase_atr` nachkaufen, sonst diese Episode auslassen.
|
||
# ⚠ `_signal_last_sig` wird NICHT gesetzt → kommt der Kurs ans Level
|
||
# zurück, darf der Entry noch kommen.
|
||
ws = self.wave.snapshot() or {}
|
||
bo = ws.get("breakout") or {}
|
||
atr_m5 = (ws.get("pb_feats") or {}).get("atr") or 0.0
|
||
lvl = bo.get("level") if bo.get("dir") == d else None
|
||
tick = self.data.snapshot() or {}
|
||
px = tick.get("ask") if d == "LONG" else tick.get("bid")
|
||
if lvl and atr_m5 > 0 and px:
|
||
weg = (float(px) - float(lvl)) * (1 if d == "LONG" else -1) / atr_m5
|
||
if weg > self._squeeze_max_chase:
|
||
if self._signal_chase_logged != d:
|
||
self._signal_chase_logged = d
|
||
log.info(f"⏭ Auto-Signal übersprungen: Kurs steht schon "
|
||
f"{weg:.2f}×ATR über dem Bestätigungs-Level {lvl} "
|
||
f"(Grenze {self._squeeze_max_chase:.2f}) — "
|
||
f"gemessen ab hier kein Edge mehr")
|
||
return
|
||
self._signal_chase_logged = None
|
||
self._signal_last_sig = d
|
||
if isinstance(self._last_rec, dict):
|
||
self._last_rec["setup"] = f"AUTOSIG_{d}"
|
||
log.info(f"🎯 AUTO-SIGNAL-ENTRY: {d} (Konfidenz {conf}% ≥ "
|
||
f"{self._auto_signal_min_conf}%) — eröffne selbständig")
|
||
err = (self.open_long(source="auto_signal") if d == "LONG"
|
||
else self.open_short(source="auto_signal"))
|
||
sym = self.data.symbol or ""
|
||
tg = self.cfg["telegram"]
|
||
tg_on = (tg.get("enabled", "false").lower() == "true" and tg.get("bot_token"))
|
||
if err:
|
||
log.error(f"Auto-Signal-Entry fehlgeschlagen: {err}")
|
||
self._signal_last_sig = None # Fehlschlag → nächster Tick erneut
|
||
if tg_on:
|
||
try: send_telegram(f"⚠ <b>Auto-Signal-Entry FEHLGESCHLAGEN</b>\n"
|
||
f"{sym} {d} (Konf {conf}%) — {err}",
|
||
tg["bot_token"], tg["chat_id"])
|
||
except Exception as e: log.warning(f"Signal-Telegram (Fehler): {e}")
|
||
return
|
||
self._signal_entry_count += 1
|
||
if tg_on:
|
||
try: send_telegram(f"🎯 <b>Auto-Signal-Entry</b>\n{sym} {d} selbständig "
|
||
f"eröffnet (Empfehlung, Konfidenz {conf}%).",
|
||
tg["bot_token"], tg["chat_id"])
|
||
except Exception as e: log.warning(f"Signal-Telegram: {e}")
|
||
except Exception as e:
|
||
log.error(f"_check_auto_signal: {e}", exc_info=True)
|
||
|
||
def set_auto_signal(self, on: bool) -> bool:
|
||
"""Autonomen Signal-Entry an/aus (UI/Config). Rückgabe = neuer Zustand."""
|
||
self._auto_signal = bool(on)
|
||
self._signal_last_sig = None
|
||
log.info(f"Auto-Signal-Entry {'AN' if self._auto_signal else 'AUS'} "
|
||
f"(Konf≥{self._auto_signal_min_conf}%"
|
||
f"{', Nacht-Sperre' if self._auto_signal_skip_night else ''})")
|
||
self._save_runtime_state()
|
||
return self._auto_signal
|
||
|
||
def _check_adverse15(self):
|
||
"""15-Minuten-Regel (User-Wunsch 2026-07-30): steht ein **vom Bot eröffneter**
|
||
Trade 15 min nach Entry mehr als `adverse_15min_atr`×ATR im Minus → schließen
|
||
(der Einstieg war falsch; gemessen: Gewinner nehmen Ø 0,19 $ Hitze, Verlierer
|
||
0,41 $). ⚠ Schwelle GEMESSEN kalibriert = **0,5×ATR**; „sobald im Minus" wäre
|
||
die schlechteste Variante (WR 39→31 %). Netto bringt die Regel über beide
|
||
Halbjahre ~nichts (Regime-Schutz: hilft im schlechten, kostet im guten) —
|
||
bewusst als Sicherheit eingebaut. Greift NICHT bei manuellen Trades (dort
|
||
bleibt die Diskretion beim User) und nicht in der Startup-Schonfrist."""
|
||
if self._adverse15_atr <= 0:
|
||
return
|
||
if self._in_startup_grace():
|
||
return
|
||
ps = self.trader.snapshot()
|
||
ticket = ps.get("ticket")
|
||
if not ticket:
|
||
return
|
||
if ticket != self._bot_open_ticket: # nur Bot-eröffnete Trades
|
||
return
|
||
# ⚠ 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"⏱ <b>15-Min-Regel</b>\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:
|
||
_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.timeframe_name 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"🔄 <b>AUTO-FLIP-CLOSE</b>\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"🔔 <b>CLOSE-Signal</b>\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"])
|
||
if "auto_signal" in st:
|
||
self._auto_signal = bool(st["auto_signal"])
|
||
if "auto_sr_close" in st:
|
||
self._auto_sr_close = bool(st["auto_sr_close"])
|
||
if "sr_close_min_gain" in st:
|
||
self._sr_close_min_gain = max(0.0, float(st["sr_close_min_gain"] or 0))
|
||
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_signal": self._auto_signal,
|
||
"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<b>⚙ Systemzustand:</b>\n" + "\n".join(zust)) if zust else ""
|
||
zust_html = ("<h3>⚙ Systemzustand</h3><p>"
|
||
+ "<br>".join(zust) + "</p>") if zust else ""
|
||
|
||
txt = (f"📊 <b>Oil Tagesreport</b>\n\n"
|
||
f"<b>Heute — Prognose (Web):</b>\n{fc}\n\n"
|
||
f"<b>Gestern ({yday}):</b>\n"
|
||
f"{n} Trades · WR {wr:.0f}% · PnL {pnl:+.2f} {cur} · PF {pf_s}"
|
||
f"{zust_txt}")
|
||
html = (f"<h2>Oil Tagesreport</h2>"
|
||
f"<h3>Heute — Prognose (Web)</h3><p>{fc.replace(chr(10), '<br>')}</p>"
|
||
f"<h3>Gestern ({yday})</h3><p>{n} Trades · Trefferquote {wr:.0f} % · "
|
||
f"PnL <b>{pnl:+.2f} {cur}</b> · Profit-Faktor {pf_s}</p>"
|
||
f"{zust_html}"
|
||
f"<p style='color:#888;font-size:12px'>Prognose ist Dritt-Kontext "
|
||
f"(Web-Recherche), kein Handelssignal.</p>")
|
||
|
||
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 = ("<h2>Oil Wochenreport (B4)</h2>"
|
||
f"<pre style='font-family:Consolas,monospace;font-size:12px;line-height:1.35'>"
|
||
f"{escape(text)}</pre>"
|
||
"<p style='color:#888;font-size:12px'>Live-Stats vs. Backtest-Erwartung "
|
||
"(Track B/B4). Bei [!]: Ursache trennen (Regime/Disziplin/Sizing), nicht "
|
||
"am Signal drehen.</p>")
|
||
sent = False
|
||
tg = self.cfg["telegram"]
|
||
if tg.get("enabled", "false").lower() == "true" and tg.get("bot_token"):
|
||
try:
|
||
send_telegram(f"📈 <b>Wochenreport (B4) KW{kw}</b>\n<pre>{escape(text)}</pre>",
|
||
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"💰 <b>Teil-Exit (Partial TP)</b>\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"🛑 <b>Früh-Ausstieg</b>\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"🧠 <b>KI-Copilot: {adv['bias']} ({adv['confidence']}%)</b>\n"
|
||
f"{adv['headline']}\n\n{adv['reasoning']}"
|
||
+ (f"\n\n<b>Risiken</b>\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 `<terminal>\\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;<U|M|L>;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;<B|A>;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;<B|A>;preis;size;<U|D>;<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;<U|D> = 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;<H|L>;lo;hi;<broker_time>
|
||
# → 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>;<von>;<nach>;<text> (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;<mt5_aequivalent>;<alter_s>;<bid>;<ask>;<trend U|D|-> — 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
|
||
# Trade-Marker: TR;<O|C>;<broker_time> → 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
|
||
nah = min(zonen, key=lambda x: x["gap_atr15"]) if zonen else None
|
||
status = ("rot" if (kosten is not None and kosten >= 0.32)
|
||
else "gruen" if (nah and nah["gap_atr15"] <= 0.5) else "gelb")
|
||
# Lesart: dieselbe Regel wie im Frontend (45–55 % = Muenzwurf)
|
||
lesart = "unklar"
|
||
if nah and nah.get("p_break") is not None:
|
||
pb = nah["p_break"]
|
||
if pb < 45 or pb > 55:
|
||
bricht = pb > 55
|
||
res = nah["seite"] == "res"
|
||
lesart = "auf" if (res if bricht else not res) else "ab"
|
||
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 _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._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,
|
||
"signal_pending": self._signal_pending,
|
||
"pending_levels": (
|
||
sorted(round(v, 3) for v in self._pending_levels.values())
|
||
if self._pending_levels else []),
|
||
"pending_quelle": self._pending_quelle,
|
||
"squeeze_max_chase_atr": self._squeeze_max_chase,
|
||
"squeeze_entry_count": self._squeeze_entry_count,
|
||
"auto_signal": self._auto_signal,
|
||
"auto_signal_min_conf": self._auto_signal_min_conf,
|
||
"signal_entry_count": self._signal_entry_count,
|
||
"adverse15_atr": self._adverse15_atr,
|
||
"adverse15_count": self._adverse15_count,
|
||
# 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"SIG {an(self._auto_signal)} · "
|
||
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} <b>Trade geschlossen</b> {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")
|
||
|
||
# ── 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
|