User-Wunsch: "Kennzeiche die Liquiditaetslinie mit einem Pfeil in welche richtung
die linie den Kurs treibt. Zeichne mir eine Trendlnie in pink im mt5 auf basis
der liquiditaet ein".
(a) Pfeil je Wand in Wirkrichtung (Lesart "die Wand haelt": Nachfrage stuetzt hoch,
Angebot deckelt runter); groessere Wand dicker + "<<". CSV-Format erweitert:
LQ;<B|A>;preis;size;<U|D>;<1|0>.
WICHTIG: Pfeilrichtung ist INTERPRETATION, nicht gemessen -- die Gegenthese
("Liquidity Grab", Kurs laeuft ZUR Wand) ist genauso verbreitet. Klaert
analyze_bookflow.py Frage 3.
(b) Pinke Liquiditaets-Trendlinie LT;t1;p1;t2;p2;<U|D>: Regression ueber den
VOLUMENGEWICHTETEN Wand-Schwerpunkt der letzten 60 min. Zeigt, wohin das
Gewicht der RUHENDEN Orders wandert -- nicht der gehandelte Kurs.
Zeiten im Bot in Broker-Zeit umgerechnet, Preise basis-korrigiert.
HL /api/walls liefert jetzt arrow_bid/arrow_ask/dominant/trend.
Verifiziert: LQ;B;84.182;2441;U;1 / LQ;A;84.223;2441;D;0 /
LT;1785427175;84.135;1785430664;83.978;D in sr_levels.csv. Indikator kompiliert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2526 lines
137 KiB
Python
2526 lines
137 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,
|
||
parse_zones,
|
||
)
|
||
from core.logger import get_logger
|
||
from core.history import HistoryLogger
|
||
from core.mt5data import MT5Data
|
||
from core.trader import TradeManager
|
||
from core.trailing import TrailingManager
|
||
from core.tu_rating import TradersUnionProvider
|
||
from core.wave_rec import WaveRecommender
|
||
from core.elliott import ElliottAnalyzer
|
||
from core.agent import TradingAgent
|
||
from core.news import NewsFetcher, NewsTranslator
|
||
from core.notify import send_telegram
|
||
from core.mailer import send_graph_mail
|
||
from core.mt5_utils import mt5_lock
|
||
from core.market_hours import session_state
|
||
from core.daily_levels import DailyLevels
|
||
from core.gaps import GapAnalyzer
|
||
from core.structure import MarketStructure
|
||
from core.patterns import PatternDetector
|
||
from core.candle_logger import CandleLogger
|
||
from core.wave_rec import (_atr as _wr_atr, _ema_last,
|
||
_EMA_FAST, _EMA_SLOW, _TREND_DEADBAND, _STRETCH_MAX)
|
||
|
||
log = get_logger("engine")
|
||
|
||
_ATR_TF_MAP = {"M1": mt5.TIMEFRAME_M1, "M5": mt5.TIMEFRAME_M5,
|
||
"M15": mt5.TIMEFRAME_M15, "M30": mt5.TIMEFRAME_M30,
|
||
"H1": mt5.TIMEFRAME_H1}
|
||
|
||
# Loop-Intervalle (Sekunden) — entsprechen den *_REFRESH_MS-Werten des Widgets
|
||
_PRICE_S = 0.5
|
||
_TREND_S = 5.0
|
||
_POS_S = 1.0
|
||
_TU_S = 300.0 # TU ist nur noch Snapshot-Anzeige (aus Empfehlung/Verdict/
|
||
# Copilot entfernt) — 5 min statt 60 s reicht, spart Scrape+Log
|
||
_AGENT_WARMUP = 20.0 # erste Beurteilung erst, wenn Daten geladen sind
|
||
_CLOSE_ALERT_COOLDOWN_S = 300.0 # CLOSE-Telegram max. 1×/5 min (kein Spam, wenn
|
||
# der Kurs ums Signal-Level pendelt)
|
||
_MIN_CONF_DISPLAY = 70 # Konfidenz-Schwelle für die "Order ohne Signal"-
|
||
# Warnung im Frontend (nur Anzeige-Konstante — war
|
||
# bis 2026-07-23 auf dem entfernten Dry-Run-Auto-
|
||
# Trader beheimatet, hier als eigener Wert erhalten)
|
||
_BERLIN = ZoneInfo("Europe/Berlin")
|
||
_SQUEEZE_NIGHT = (0, 1, 2, 3, 4, 5, 6, 7) # Auto-Squeeze-Nacht-Sperre (Kostenfalle)
|
||
_SQUEEZE_SR_COOLDOWN_S = 180.0 # nach S/R-Close kein Auto-Squeeze in GLEICHER Richtung
|
||
# (sonst Close-„Level hält" vs. Entry-„Level bricht"-
|
||
# Roundtrip in 1 s; 2026-07-17)
|
||
_SQUEEZE_REENTRY_COOLDOWN_S = 180.0 # nach JEDEM Close eines Trades (Notfall/SL/Time-Stop/
|
||
# manuell) kein Gleichrichtungs-Squeeze-Wiedereinstieg —
|
||
# gegen die Open→Instant-Close-Kaskade bei zu engem Stop
|
||
# (real 2026-07-27 15:39: −3-€-Stop killte Entry #1 in 16 s,
|
||
# Squeeze noch aktiv → sofortiger Re-Entry; 2026-07-28)
|
||
_STARTUP_GRACE_S = 60.0 # nach Bot-Start schließt der Bot 60 s lang KEINEN
|
||
# laufenden Trade server-seitig (S/R-Close/Notfall/
|
||
# Time-Stop/Reverse) — die Daten (Preis/ATR/P(break)/
|
||
# Level) stabilisieren sich erst; verhindert den
|
||
# Sofort-Close direkt nach restart_server.bat.
|
||
# Broker-SL bleibt unberührt (läuft am Broker).
|
||
|
||
# ── Kalibriertes P(Durchbruch)-Modell (logistische Regression, auf H1 trainiert,
|
||
# auf H2 KALIBRIERT geprüft: AUC 0,71, predicted≈echt; `analyze_srwaves.py`).
|
||
# Merkmale (in Positionsrichtung): [mom6, mom3, mit-Trend(0/1), Level-Distanz/ATR].
|
||
# Standardisierung (mu/sd) + Gewichte (intercept + 4) aus `backtest_srclose_prob.py`.
|
||
_PB_MU = (0.7482, 0.7036, 0.9112, 0.7072)
|
||
_PB_SD = (1.1597, 0.9687, 0.2844, 0.6403)
|
||
_PB_W = (-0.5853, 0.1192, 0.7820, 0.1204, -0.2994) # [b0, mom6, mom3, wt, dist]
|
||
|
||
def _p_break(mom6: float, mom3: float, wt: float, dist: float) -> float:
|
||
"""P(Level-Durchbruch) ∈ (0,1) aus den 4 standardisierten Merkmalen."""
|
||
import math
|
||
feat = (mom6, mom3, wt, dist)
|
||
z = _PB_W[0]
|
||
for k in range(4):
|
||
z += _PB_W[k + 1] * (feat[k] - _PB_MU[k]) / _PB_SD[k]
|
||
return 1.0 / (1.0 + math.exp(-max(-30.0, min(30.0, z))))
|
||
|
||
|
||
class TradingEngine:
|
||
"""Headless-Pendant zu OilWidget: hält die core-Objekte, fährt die
|
||
Refresh-Loops als Threads und liefert einen aggregierten Snapshot."""
|
||
|
||
def __init__(self, cfg=None):
|
||
self.cfg = cfg or load_config()
|
||
try:
|
||
set_margin_buffer(float(self.cfg["trading"].get("margin_buffer_pct", "90")))
|
||
except Exception as e:
|
||
log.warning(f"Margin-Buffer aus Config: {e}")
|
||
try:
|
||
set_risk_per_trade(float(self.cfg["trading"].get("risk_pct", "1.5")))
|
||
except Exception as e:
|
||
log.warning(f"Risiko/Trade aus Config: {e}")
|
||
|
||
# ── History + Telegram ────────────────────────────────────────────
|
||
self.history = HistoryLogger(HISTORY_DB_FILE)
|
||
tg = self.cfg["telegram"]
|
||
self.history.configure_telegram(
|
||
enabled = tg.get("enabled", "false").strip().lower() == "true",
|
||
token = tg.get("bot_token", "").strip(),
|
||
chat_id = tg.get("chat_id", "").strip(),
|
||
)
|
||
|
||
# ── Markt / Trading / Trailing ────────────────────────────────────
|
||
self.data = MT5Data()
|
||
self.trader = TradeManager()
|
||
self.trader.history = self.history
|
||
self.trail = TrailingManager(self.trader)
|
||
self.trail.set_notify(self._on_trail_event)
|
||
# (Dry-Run-Auto-Trader entfernt 2026-07-23 — war Verlierer im Backtest,
|
||
# UI-Toggle längst weg. `min_conf`-Anzeigekonstante bleibt, s. unten.)
|
||
|
||
# ── Analyse-Module ────────────────────────────────────────────────
|
||
self.tu = TradersUnionProvider()
|
||
self.wave = WaveRecommender(self.tu, mt5.TIMEFRAME_M5)
|
||
try:
|
||
self.wave.set_breakout_k(float(self.cfg["trading"].get("breakout_k", "1.0")))
|
||
except (TypeError, ValueError):
|
||
pass
|
||
try:
|
||
self.wave.set_entry_room(float(self.cfg["trading"].get("entry_room_atr", "0.6")))
|
||
except (TypeError, ValueError):
|
||
pass
|
||
# Tageszeit-Gate aus Config (Komma-Liste von Stunden; leer = AUS). Fehlt der
|
||
# Key → gemessener Default (im wave_rec-Konstanten-Set). User-Vorgabe 2026-07-22:
|
||
# ini auf leer → Gate aus, obwohl 12/16 gemessen negativ sind.
|
||
_dh = self.cfg["trading"].get("dead_hours", None)
|
||
if _dh is not None:
|
||
self.wave.set_dead_hours([h.strip() for h in _dh.split(",") if h.strip()])
|
||
self.elliott = ElliottAnalyzer(mt5.TIMEFRAME_M5)
|
||
self.structure = MarketStructure() # Marktstruktur (M30) — reine Anzeige
|
||
self.patterns = PatternDetector() # visuelle Chartmuster (M30) — reine Anzeige
|
||
# Echte Liquiditäts-Wände vom HL-Orderbuch (Anzeige im MT5-Chart, kein Signal).
|
||
# Braucht das HL-Dashboard auf 8001; ist es aus, bleibt der Chart unverändert.
|
||
from core.hl_walls import HLWalls
|
||
_t2 = self.cfg["trading"]
|
||
self.hl_walls = HLWalls(
|
||
url=_t2.get("hl_walls_url", "http://localhost:8001/api/walls"),
|
||
enabled=_t2.get("export_hl_walls", "true").lower() == "true")
|
||
self.candles = CandleLogger(HISTORY_DB_FILE) # M1-Candle-Log (Daten-Sammlung)
|
||
# News: eigener Fetch-Loop (versorgt UI /api/news UND Agent-Kontext)
|
||
tcfg = self.cfg["translation"]
|
||
translator = NewsTranslator(
|
||
enabled = tcfg.get("enabled", "true").lower() == "true",
|
||
target = tcfg.get("target_language", "de"),
|
||
provider = tcfg.get("provider", "google"),
|
||
)
|
||
self.news = NewsFetcher(translator=translator)
|
||
self._news_refresh_s = max(60, int(self.cfg["news"].get("refresh_min", "10")) * 60)
|
||
self.agent = TradingAgent(
|
||
self.cfg, data=self.data, trader=self.trader, trail=self.trail,
|
||
tu=self.tu, wave=self.wave, history=self.history,
|
||
news=self.news, elliott=self.elliott)
|
||
# Tägliche WTI-Intraday-Level per Web-Recherche (z.ai/GLM + web_search).
|
||
# Nur Kontext → S/R + Anzeige, NICHT Handelsrichtung.
|
||
zc = self.cfg["zai"] if self.cfg.has_section("zai") else {}
|
||
self.daily = DailyLevels(zc.get("api_key", ""),
|
||
zc.get("base_url", "https://api.z.ai/api/paas/v4"),
|
||
zc.get("model", "glm-4.5-flash"))
|
||
self._daily_last_run: str | None = None # ISO-Datum des letzten Laufs
|
||
self._daily_fails = 0
|
||
self._weekly_last_run = None # (ISO-Jahr, KW) des letzten B4-Reports
|
||
# Versand-Status überlebt Neustarts — sonst schickt jeder restart_server.bat
|
||
# nach 07:30 den Tages-/Wochenreport ERNEUT (war real: 3× am 07.07.)
|
||
import os as _os
|
||
self._report_state_path = _os.path.join(
|
||
_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))),
|
||
"report_state.json")
|
||
self._load_report_state()
|
||
# Kurslücken-Analyse (D1): Fill-Level als S/R-Magnete (Kontext/Konfidenz,
|
||
# keine eigene Handelsrichtung). Refresh gedrosselt (~10 min) im Trend-Loop.
|
||
self.gaps = GapAnalyzer(refresh_s=600)
|
||
|
||
self.require_token = self.cfg["web"].get("require_token", "true").strip().lower() == "true"
|
||
self._tf_select = self.cfg["trading"].get("tf_select", "heuristic").strip()
|
||
self._tf_min = self.cfg["trading"].get("tf_min", "M5").strip().upper()
|
||
if self._tf_min not in _ATR_TF_MAP:
|
||
self._tf_min = "M5"
|
||
# Obergrenze der Heuristik-TF (Daytrading: nur M1–M15). M30/H1 bleiben als
|
||
# Kontext (Filter/Konfluenz), werden aber NICHT mehr als Basis-TF gewählt.
|
||
self._tf_max = self.cfg["trading"].get("tf_max", "H1").strip().upper()
|
||
if self._tf_max not in _ATR_TF_MAP:
|
||
self._tf_max = "H1"
|
||
self._wave_tf_label = "M5"
|
||
self._last_rec: dict = {}
|
||
self._agent_fail_cnt = 0
|
||
self._close_alert_ts = 0.0 # letzter CLOSE-Telegram-Push (5-min-Cooldown)
|
||
self._close_alert_ticket = None # Ticket, auf das sich der Cooldown bezieht
|
||
self._close_alert_count = 0 # wie oft der Flip-Close-Alarm gefeuert hat
|
||
self._auto_sr_close = self.cfg["trading"].get("auto_sr_close", "true").lower() == "true"
|
||
try:
|
||
self._sr_close_pbreak = float(self.cfg["trading"].get("sr_close_pbreak", "0.60"))
|
||
except (TypeError, ValueError):
|
||
self._sr_close_pbreak = 0.60
|
||
self._sr_close_pbreak = max(0.30, min(0.90, self._sr_close_pbreak)) # sinnvolles Band
|
||
self._sr_close_count = 0 # wie oft der Bot am S/R automatisch schloss
|
||
self._sr_close_last_ticket = None # verhindert Doppel-Auslösung je Ticket
|
||
# Autonomer Entry auf den gemessen-validierten Squeeze-Ausbruch (nur FLAT).
|
||
# ⚠ ECHTE Order — Default AUS (Autonomie nie stillschweigend an). `[trading]
|
||
# auto_squeeze=true` aktiviert. Dedup über (dir, level) des Ausbruchs.
|
||
self._auto_squeeze = self.cfg["trading"].get("auto_squeeze", "false").lower() == "true"
|
||
self._squeeze_skip_night = self.cfg["trading"].get("auto_squeeze_skip_night", "true").lower() == "true"
|
||
# Stop-&-Reverse nur, wenn explizit an (Default aus) — der Reverse ist unbelegt;
|
||
# der flat-Entry bleibt davon unberührt (der validierte Teil).
|
||
self._squeeze_reverse = self.cfg["trading"].get("auto_squeeze_reverse", "false").lower() == "true"
|
||
self._squeeze_last_sig = None # Dedup Entry (flat)
|
||
self._squeeze_reverse_sig = None # Dedup Reverse-Close (Gegen-Trade)
|
||
self._squeeze_entry_count = 0
|
||
# ── Autonomer Entry auf die EMPFEHLUNG (User-Wunsch 2026-07-30) ───────────
|
||
# ⚠ GEMESSEN NICHT tragfähig (H1 negativ, s. backtest_auto_signal.py) — auf
|
||
# ausdrücklichen User-Wunsch gebaut, Default AUS, mit den besten Parametern.
|
||
_t = self.cfg["trading"]
|
||
self._auto_signal = _t.get("auto_signal", "false").lower() == "true"
|
||
try:
|
||
self._auto_signal_min_conf = int(float(_t.get("auto_signal_min_conf", "75")))
|
||
except (TypeError, ValueError):
|
||
self._auto_signal_min_conf = 75
|
||
self._auto_signal_skip_night = _t.get("auto_signal_skip_night", "true").lower() == "true"
|
||
self._signal_last_sig = None # Dedup: (dir, Signal-Episode) 1× handeln
|
||
self._signal_entry_count = 0
|
||
# 15-Minuten-Regel für BOT-eröffnete Trades (gemessen kalibriert: 0,5×ATR)
|
||
try:
|
||
self._adverse15_atr = max(0.0, float(_t.get("adverse_15min_atr", "0.5")))
|
||
except (TypeError, ValueError):
|
||
self._adverse15_atr = 0.5
|
||
self._bot_open_ticket = None # Ticket, das der Bot selbst eröffnet hat
|
||
self._adverse15_done_ticket = None # 15-Min-Prüfung je Ticket nur 1×
|
||
self._adverse15_count = 0
|
||
self._sr_close_ts = 0.0 # Zeit + Richtung des letzten S/R-Closes
|
||
self._sr_close_dir = None # → Cooldown gegen Gleichrichtungs-Roundtrip
|
||
self._open_pos_dir = None # Richtung der aktuell offenen Position (LONG/SHORT)
|
||
self._pos_close_ts = 0.0 # Zeit + Richtung des letzten Positions-Close (JEDE
|
||
self._pos_close_dir = None # Art) → Squeeze-Wiedereinstiegs-Sperre (gegen die
|
||
# Open→Instant-Close-Kaskade bei zu engem Notfall-Stop)
|
||
self._sr_close_min_gain_armed_ticket = None # Ticket, für das die %-Schwelle schon
|
||
# aus echter Margin berechnet wurde (Retry-Schutz,
|
||
# s. `_check_auto_close`)
|
||
try:
|
||
self._sr_close_min_gain = max(0.0, float(
|
||
self.cfg["trading"].get("sr_close_min_gain", "0")))
|
||
except (TypeError, ValueError):
|
||
self._sr_close_min_gain = 0.0
|
||
# Gewinn-Close automatisch auf X% des Einsatzes (Margin) DIESER Position setzen,
|
||
# sobald sie eröffnet wird (User-Vorgabe 2026-07-23) — überschreibt den manuell
|
||
# gemerkten/persistierten Wert bei jeder ECHT NEUEN Position (nicht beim
|
||
# Wiedererkennen nach Neustart, s. `_check_auto_close`). 0 = aus (dann bleibt der
|
||
# manuell gesetzte/gemerkte Wert wie bisher maßgeblich).
|
||
try:
|
||
self._sr_close_min_gain_pct = max(0.0, float(
|
||
self.cfg["trading"].get("sr_close_min_gain_pct", "1.0")))
|
||
except (TypeError, ValueError):
|
||
self._sr_close_min_gain_pct = 1.0
|
||
self._pb_state = None # geglättetes P(break) + Hysterese-Zone je Level
|
||
self._stop_pb_state = None # dito für die Gegen-/Stop-Seiten-Anzeige
|
||
# (gemessen `analyze_pbreak_flicker.py`: mom3 der
|
||
# laufenden Kerze ließ P ~7–15 Pp je M5-Bar springen)
|
||
# UI-Einstellungen neustart-fest (Fix 2026-07-19): Toggles/Werte, die über
|
||
# die App gesetzt werden (Auto-Squeeze, S/R-Auto-Close, Mindestgewinn),
|
||
# überschreiben nach dem Laden die ini-Defaults — vorher war z. B. ein am
|
||
# Handy gesetzter Mindestgewinn nach jedem restart_server.bat wieder weg.
|
||
import os as _os2
|
||
self._runtime_state_path = _os2.path.join(
|
||
_os2.path.dirname(_os2.path.dirname(_os2.path.abspath(__file__))),
|
||
"runtime_state.json")
|
||
self._load_runtime_state()
|
||
self._mql5_files_dir = None # <terminal>\MQL5\Files (lazy, für den Indikator)
|
||
self._export_mql5 = self.cfg["trading"].get("export_mql5_levels", "true").lower() == "true"
|
||
self._draw_last = {"r": None, "s": None} # letzte gezeichnete S/R (Hysterese)
|
||
try: # WHT-Quellensteuer (Broker behält % je GEWINN ein) → Netto-Anzeige
|
||
self._wht_pct = max(0.0, float(self.cfg["trading"].get("wht_pct", "0") or 0))
|
||
except (TypeError, ValueError):
|
||
self._wht_pct = 0.0
|
||
self._draw_cache: tuple[float, dict] | None = None # (ts, result) TTL ~1 s —
|
||
# 4 Aufrufer (Snapshot 2×, pos_loop, CSV, Charts): EIN Ergebnis je Sekunde
|
||
# garantiert identische Linien in Kachel/Chart/Close-Meldung + spart Lock-Last
|
||
self._emergency_loss: float | None = None # aktiver Notfall-Stop (Verlust)
|
||
self._last_pos_ticket = None # für Auto-Arm beim Öffnen
|
||
# Mindest-Notfall-Stop (Kontowährung): ein von Hand gesetzter Wert UNTER
|
||
# diesem Minimum wird als „aus" behandelt statt scharfgestellt — sonst killt
|
||
# ein Vertipper (z. B. „1") jeden Trade sofort, weil die Schwelle unter dem
|
||
# Spread liegt UND per _emergency_remember auf ALLE Folge-Trades vererbt wird
|
||
# (real 2026-07-27: −1-€-Stop schloss Trades in derselben Sekunde). 0 = aus.
|
||
try:
|
||
self._emergency_min = max(0.0, float(self.cfg["trading"].get("emergency_min_eur", "3") or 0))
|
||
except (TypeError, ValueError):
|
||
self._emergency_min = 3.0
|
||
# Beim Öffnen einer neuen Position wird der Notfall-Stop automatisch auf
|
||
# den ZULETZT genutzten Wert gesetzt (gemerkt über Trades hinweg). Start-
|
||
# wert = [trading] auto_emergency_loss (0 = Auto-Arm aus).
|
||
try:
|
||
self._emergency_remember = float(self.cfg["trading"].get("auto_emergency_loss", "10") or 0)
|
||
except (TypeError, ValueError):
|
||
self._emergency_remember = 10.0
|
||
# Auto-Notfall-Stop als % des Kontos (User-Vorgabe 2026-07-15): ist
|
||
# `[trading] auto_emergency_pct` > 0, wird beim Öffnen der Stop auf
|
||
# pct% der Balance gesetzt (skaliert mit dem Konto) STATT auf den
|
||
# gemerkten Fixwert. 0 = aus (Fixwert-Verhalten wie bisher).
|
||
try:
|
||
self._emergency_pct = max(0.0, float(self.cfg["trading"].get("auto_emergency_pct", "0") or 0))
|
||
except (TypeError, ValueError):
|
||
self._emergency_pct = 0.0
|
||
# Notfall-Stop als % der EINSATZ-Margin dieser Position (User-Vorgabe
|
||
# 2026-07-23, `[trading] auto_emergency_margin_pct=3.0`) — dritter, margin-
|
||
# basierter Modus, hat Vorrang vor dem Balance-%-Modus (`_emergency_pct`)
|
||
# und dem Fixwert, wenn >0. Retry-sicher wie `_sr_close_min_gain_pct`
|
||
# armiert (s. `_check_auto_close`): `order_calc_margin` kann im ersten Tick
|
||
# nach dem Öffnen noch 0 liefern. ⚠ Bekannte Kopplungs-Regel (CLAUDE.md):
|
||
# ein %-Notfall-Stop ist unter Margin-Sizing enger als der 2×ATR-SL — bewusste
|
||
# User-Vorgabe trotzdem, kein Gegen-Argument wiederholen.
|
||
try:
|
||
self._emergency_margin_pct = max(0.0, float(
|
||
self.cfg["trading"].get("auto_emergency_margin_pct", "0") or 0))
|
||
except (TypeError, ValueError):
|
||
self._emergency_margin_pct = 0.0
|
||
self._emergency_margin_armed_ticket = None # Retry-Schutz wie bei sr_close_min_gain
|
||
# ── Circuit Breaker (Tagesverlust-Stopp, User-Vorgabe 2026-07-27) ──────────
|
||
# `[trading] daily_loss_limit_pct` > 0: erreicht der TAGES-P&L (realisiert heute
|
||
# + offene Position) −pct% der Balance, schließt der Bot die Position und
|
||
# eröffnet KEINE neuen Auto-Trades mehr bis zum nächsten Tag. Überlebens-Netz
|
||
# gegen Verlust-Kaskaden (früher abgelehnt; mit dem Autonomie-Ziel reaktiviert
|
||
# — unter 80 %-Margin schützt es v. a. gegen die Serie, nicht den Einzeltrade,
|
||
# dessen 2×ATR-SL allein schon >pct% verlieren kann). 0 = aus.
|
||
try:
|
||
self._cb_limit_pct = max(0.0, float(self.cfg["trading"].get("daily_loss_limit_pct", "0") or 0))
|
||
except (TypeError, ValueError):
|
||
self._cb_limit_pct = 0.0
|
||
self._circuit_tripped_date = None # Berlin-Datum, an dem der Breaker griff
|
||
self._cb_realized = (0.0, 0.0) # (ts, realisierter Tages-P&L) gecacht ~30 s
|
||
# Gewinn-mitnehmen (Take-Profit) — symmetrisch zum Notfall-Stop: schließt
|
||
# bei P&L ≥ +Wert. Gemerkter Wert über Trades hinweg (0 = Auto-Arm aus).
|
||
self._takeprofit: float | None = None
|
||
try:
|
||
self._takeprofit_remember = float(self.cfg["trading"].get("auto_takeprofit", "0") or 0)
|
||
except (TypeError, ValueError):
|
||
self._takeprofit_remember = 0.0
|
||
# Notfall-Stop/TP überleben Neustarts: sonst re-armiert ein restart_server.bat
|
||
# eine OFFENE Position auf die 2%-Automatik und verwirft eine manuelle Über-
|
||
# steuerung (real 2026-07-17 14:30: −25 € → −19,42 €, dann Notfall-Close bei
|
||
# −19,64). Persistiert {ticket, loss, tp}; in _check_auto_close wiederhergestellt,
|
||
# solange dasselbe Ticket noch offen ist.
|
||
self._emergency_state_path = _os.path.join(
|
||
_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))),
|
||
"emergency_state.json")
|
||
self._emergency_state_ticket = None
|
||
self._emergency_state_loss = None
|
||
self._emergency_state_tp = None
|
||
self._emergency_state_trail = None # Trailing-Zustand per-Ticket (neustart-fest)
|
||
self._load_emergency_state()
|
||
# Startup-Schonfrist: bis zu diesem Zeitpunkt kein server-seitiger Auto-Close
|
||
# (in start() gesetzt). Konfigurierbar via [trading] startup_close_grace_s.
|
||
try:
|
||
self._startup_grace_s = max(0.0, float(
|
||
self.cfg["trading"].get("startup_close_grace_s", str(_STARTUP_GRACE_S))))
|
||
except (TypeError, ValueError):
|
||
self._startup_grace_s = _STARTUP_GRACE_S
|
||
self._no_autoclose_until = 0.0
|
||
self._running = False
|
||
self._connected = False
|
||
self._error: str | None = None
|
||
self._threads: list[threading.Thread] = []
|
||
# Snapshot-Cache: ein berechneter Snapshot wird über alle WS-Clients +
|
||
# HTTP-Aufrufe geteilt (TTL) → SQLite/Verdict nur 1×/TTL statt N×/s.
|
||
self._snap_cache: dict | None = None
|
||
self._snap_cache_ts: float = 0.0
|
||
self._snap_lock = threading.Lock()
|
||
self._sqmon_cache: dict | None = None # Auto-Squeeze-Monitor (B4), TTL 60 s
|
||
self._sqmon_cache_ts: float = 0.0
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# LIFECYCLE
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
def start(self) -> bool:
|
||
"""Verbindet MT5, übernimmt offene Positionen und startet die Loops."""
|
||
preferred = self.cfg["trading"].get("last_symbol", "").strip() or None
|
||
if not self.data.connect(preferred_symbol=preferred):
|
||
self._error = self.data.error
|
||
log.error(f"MT5-Connect fehlgeschlagen: {self.data.error}")
|
||
return False
|
||
self._connected = True
|
||
self._adopt_positions()
|
||
|
||
# Start-Timeframe: fester tf_select hat Vorrang, sonst letzte Wahl aus
|
||
# der Config; Heuristik/Agent justieren danach laufend.
|
||
if self._tf_select.upper() in _ATR_TF_MAP:
|
||
saved_tf = self._tf_select.upper()
|
||
else:
|
||
saved_tf = self.cfg["trading"].get("atr_tf", "M5").strip().upper()
|
||
if saved_tf not in _ATR_TF_MAP:
|
||
saved_tf = "M5"
|
||
# Start-TF in das erlaubte Band [tf_min … tf_max] klemmen (Heuristik-Modus)
|
||
if self._tf_select == "heuristic":
|
||
order = list(_ATR_TF_MAP.keys())
|
||
lo = order.index(self._tf_min); hi = order.index(self._tf_max)
|
||
r = order.index(saved_tf)
|
||
saved_tf = order[min(max(r, lo), hi)]
|
||
self._wave_tf_label = saved_tf
|
||
const = _ATR_TF_MAP[saved_tf]
|
||
self.wave.set_timeframe(const)
|
||
self.elliott.set_timeframe(const)
|
||
self.trail.set_atr_tf_override(const)
|
||
|
||
threading.Thread(target=self.trader.reconcile_open_trades,
|
||
daemon=True, name="reconcile").start()
|
||
# RSS-Feeds im Hintergrund validieren (ausgefallene werden ersetzt)
|
||
threading.Thread(target=self.news.validate_feeds,
|
||
daemon=True, name="feed-validator").start()
|
||
|
||
# Startup-Schonfrist scharf: die nächsten `_startup_grace_s` s schließt der
|
||
# Bot keinen laufenden Trade selbst (S/R-Close/Notfall/Time-Stop/Reverse) —
|
||
# verhindert Sofort-Close eines adoptierten Trades auf noch-instabilen Daten.
|
||
self._no_autoclose_until = time.time() + self._startup_grace_s
|
||
self.trail.set_no_close_until(self._no_autoclose_until)
|
||
if self._startup_grace_s > 0:
|
||
log.info(f"Startup-Schonfrist: {self._startup_grace_s:.0f} s kein Auto-Close")
|
||
|
||
self._running = True
|
||
# _tu_loop stillgelegt (2026-07-19): TU ist aus Empfehlung/Verdict/Copilot
|
||
# raus UND die Dashboard-Kachel ist ausgeblendet → der 5-min-Scrape lief
|
||
# für niemanden. Reaktivieren = self._tu_loop wieder in die Liste.
|
||
for loop in (self._price_loop, self._trend_loop, self._pos_loop,
|
||
self._news_loop, self._tf_loop, self._agent_loop,
|
||
self._daily_loop):
|
||
t = threading.Thread(target=loop, daemon=True, name=loop.__name__)
|
||
t.start()
|
||
self._threads.append(t)
|
||
|
||
log.info(f"Engine gestartet · Symbol {self.data.symbol} · TF {saved_tf}")
|
||
return True
|
||
|
||
def stop(self):
|
||
self._running = False
|
||
try:
|
||
self.data.disconnect()
|
||
except Exception:
|
||
pass
|
||
|
||
def _adopt_positions(self):
|
||
"""Beim Start vorhandene MT5-Positionen übernehmen (wie main() im Widget)."""
|
||
try:
|
||
all_pos = mt5.positions_get() or []
|
||
log.info(f"Sofort-Adoption: {len(all_pos)} offene Position(en)")
|
||
if all_pos:
|
||
own = [p for p in all_pos if getattr(p, "magic", 0) == MAGIC]
|
||
pick = own[0] if own else all_pos[0]
|
||
if pick.symbol != self.data.symbol:
|
||
log.info(f"Symbol-Switch beim Start: {self.data.symbol} → "
|
||
f"{pick.symbol} (offene Position T={pick.ticket})")
|
||
ok, msg = self.data.switch_symbol(pick.symbol)
|
||
if not ok:
|
||
log.warning(f"Symbol-Switch fehlgeschlagen: {msg}")
|
||
self.trader.refresh(self.data.symbol)
|
||
except Exception as e:
|
||
log.warning(f"Sofort-Adoption: {e}", exc_info=True)
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# LOOPS (jeder MT5-Call sichert sich intern den globalen mt5_lock)
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
def _price_loop(self):
|
||
cnt = 0
|
||
while self._running:
|
||
cnt += 1
|
||
try:
|
||
self.data.fetch_price()
|
||
s = self.data.snapshot()
|
||
if s.get("bid") and s.get("ask") and self.data.symbol:
|
||
try:
|
||
self.trail.on_price(self.data.symbol, s["bid"], s["ask"])
|
||
except Exception as te:
|
||
log.warning(f"trail.on_price: {te}")
|
||
except Exception as e:
|
||
log.error(f"_price_loop#{cnt}: {e}", exc_info=True)
|
||
time.sleep(_PRICE_S)
|
||
|
||
def _trend_loop(self):
|
||
while self._running:
|
||
try:
|
||
self.data.fetch_trend()
|
||
if self.data.symbol:
|
||
# S/R-Kontext an das Wellenmodul geben (für die Konfidenz)
|
||
try:
|
||
srl = self._sr_levels(self.data.snapshot())
|
||
self.wave.set_sr_context(srl.get("resistance"),
|
||
srl.get("support"))
|
||
except Exception as se:
|
||
log.warning(f"set_sr_context: {se}")
|
||
try: self.gaps.maybe_refresh(self.data.symbol)
|
||
except Exception as ge: log.warning(f"gaps.maybe_refresh: {ge}")
|
||
try: self.wave.refresh_market(self.data.symbol)
|
||
except Exception as we: log.warning(f"wave.refresh_market: {we}")
|
||
try: self.elliott.refresh_market(self.data.symbol)
|
||
except Exception as ee: log.warning(f"elliott.refresh_market: {ee}")
|
||
try: self.structure.refresh_market(self.data.symbol)
|
||
except Exception as se2: log.warning(f"structure.refresh_market: {se2}")
|
||
try: self.patterns.refresh_market(self.data.symbol)
|
||
except Exception as pe2: log.warning(f"patterns.refresh_market: {pe2}")
|
||
try: self.candles.log(self.data.symbol) # M1-Candle-Log (~55 s)
|
||
except Exception as ce: log.warning(f"candles.log: {ce}")
|
||
try: self._evaluate_pbreak_predictions() # ~alle 5 min, self-throttled
|
||
except Exception as pe: log.warning(f"pbreak_predictions: {pe}")
|
||
self._run_analysis()
|
||
except Exception as e:
|
||
log.error(f"_trend_loop: {e}", exc_info=True)
|
||
self._sleep(_TREND_S)
|
||
|
||
def _pos_loop(self):
|
||
while self._running:
|
||
try:
|
||
if self.data.symbol:
|
||
self.trader.refresh(self.data.symbol)
|
||
self._check_circuit_breaker() # Tagesverlust-Stopp (Überleben zuerst)
|
||
self._check_auto_close()
|
||
self._check_adverse15() # 15-Min-Regel (nur Bot-Trades)
|
||
self._check_sr_close()
|
||
self._check_auto_squeeze() # autonomer Entry auf Squeeze-Ausbruch
|
||
self._check_auto_signal() # autonomer Entry auf die Empfehlung
|
||
except Exception as e:
|
||
log.error(f"_pos_loop: {e}", exc_info=True)
|
||
self._sleep(_POS_S)
|
||
|
||
def set_emergency(self, value) -> float | None:
|
||
"""Notfall-Verlust-Schwelle setzen/löschen (Kontowährung, als positiver
|
||
Betrag gespeichert). None/leer/0 → aus. Rückgabe = aktiver Wert."""
|
||
try:
|
||
v = abs(float(value)) if value not in (None, "", "0", 0) else None
|
||
if v is not None and v <= 0:
|
||
v = None
|
||
except (TypeError, ValueError):
|
||
v = None
|
||
# Vertipper-Schutz: ein Wert unter dem Minimum (Spread-Region) würde den
|
||
# Trade sofort schließen UND sich auf alle Folge-Trades vererben → als „aus"
|
||
# behandeln (Fix 2026-07-27, real: −1-€-Stop killte jeden Trade sofort).
|
||
if v is not None and self._emergency_min > 0 and v < self._emergency_min:
|
||
log.warning(f"Notfall-Stop −{v:.2f} < Minimum {self._emergency_min:.2f} € "
|
||
f"(zu eng, würde sofort schließen) → als AUS behandelt")
|
||
v = None
|
||
self._emergency_loss = v
|
||
if v is not None:
|
||
self._emergency_remember = v # für die nächste Position merken
|
||
else:
|
||
self._emergency_remember = 0.0 # „aus" schaltet auch den Auto-Arm ab
|
||
log.info(f"Notfall-Stop {('gesetzt: -' + format(v, '.2f')) if v else 'aus'}")
|
||
self._save_emergency_state() # Handeingabe neustart-fest (Fix 2026-07-17)
|
||
return self._emergency_loss
|
||
|
||
def set_takeprofit(self, value) -> float | None:
|
||
"""Gewinn-Ziel setzen/löschen (Kontowährung, positiver Betrag). None/leer/0
|
||
→ aus. Gemerkt für die nächste Position. Rückgabe = aktiver Wert."""
|
||
try:
|
||
v = abs(float(value)) if value not in (None, "", "0", 0) else None
|
||
if v is not None and v <= 0:
|
||
v = None
|
||
except (TypeError, ValueError):
|
||
v = None
|
||
self._takeprofit = v
|
||
if v is not None:
|
||
self._takeprofit_remember = v
|
||
else:
|
||
self._takeprofit_remember = 0.0 # „aus" schaltet auch den Auto-Arm ab
|
||
log.info(f"Gewinn-Ziel {('gesetzt: +' + format(v, '.2f')) if v else 'aus'}")
|
||
self._save_emergency_state() # Handeingabe neustart-fest (Fix 2026-07-17)
|
||
return self._takeprofit
|
||
|
||
def get_bars(self, tf_label: str, n: int = 90) -> dict:
|
||
"""OHLC-Bars + EMA12/50 + Trend je Timeframe für den Charts-Tab. Läuft unter
|
||
`mt5_lock`. Trend = EMA12 vs EMA50 mit Totband (_TREND_DEADBAND×ATR)."""
|
||
import MetaTrader5 as mt5
|
||
from core.mt5_utils import mt5_lock
|
||
from core.wave_rec import _ema_series, _atr, _EMA_FAST, _EMA_SLOW, _TREND_DEADBAND
|
||
tf_map = {"M1": mt5.TIMEFRAME_M1, "M5": mt5.TIMEFRAME_M5, "M15": mt5.TIMEFRAME_M15,
|
||
"M30": mt5.TIMEFRAME_M30, "H1": mt5.TIMEFRAME_H1}
|
||
tf = tf_map.get(tf_label); sym = self.data.symbol
|
||
if tf is None or not sym:
|
||
return {"tf": tf_label, "bars": [], "error": "kein TF/Symbol"}
|
||
n = max(30, min(int(n), 200))
|
||
try:
|
||
with mt5_lock(timeout=5) as got:
|
||
if not got:
|
||
return {"tf": tf_label, "bars": [], "error": "busy"}
|
||
rates = mt5.copy_rates_from_pos(sym, tf, 0, n + _EMA_SLOW)
|
||
except Exception as e:
|
||
return {"tf": tf_label, "bars": [], "error": str(e)}
|
||
if rates is None or len(rates) == 0:
|
||
return {"tf": tf_label, "bars": [], "error": "keine Daten"}
|
||
closes = [float(b["close"]) for b in rates]
|
||
highs = [float(b["high"]) for b in rates]
|
||
lows = [float(b["low"]) for b in rates]
|
||
e12 = _ema_series(closes, _EMA_FAST); e50 = _ema_series(closes, _EMA_SLOW)
|
||
atr = _atr(highs, lows, closes) or 0.0
|
||
d = e12[-1] - e50[-1]
|
||
dead = _TREND_DEADBAND * atr if atr else 0.0
|
||
trend = "flat" if abs(d) <= dead else ("up" if d > 0 else "down")
|
||
k = min(n, len(rates))
|
||
bars = [{"t": int(b["time"]), "o": float(b["open"]), "h": float(b["high"]),
|
||
"l": float(b["low"]), "c": float(b["close"])} for b in rates[-k:]]
|
||
# S/R-Linien fürs Chart-Overlay: echte Pivot-Struktur (`_draw_levels`,
|
||
# identisch zum Auto-Close-System) — NICHT mehr die stale [zones]-Config.
|
||
try:
|
||
dl = self._draw_levels()
|
||
levels = {"res": dl.get("res", []), "sup": dl.get("sup", []), "zones": []}
|
||
except Exception:
|
||
levels = {"res": [], "sup": [], "zones": []}
|
||
# Regressionskanal — nur M30 (dort ist die Marktstruktur berechnet), als
|
||
# Linien-Arrays deckungsgleich mit den Bars (Overlay im Charts-Tab).
|
||
channel = None
|
||
patterns = []
|
||
if tf_label == "M30":
|
||
try:
|
||
from core.structure import channel_series
|
||
channel = channel_series(closes, atr, k)
|
||
except Exception:
|
||
channel = None
|
||
# Visuelle Chartmuster (M30) fürs Overlay — Trigger-/Ziel-Preislinien.
|
||
try:
|
||
patterns = (self.patterns.snapshot() or {}).get("patterns") or []
|
||
except Exception:
|
||
patterns = []
|
||
return {"tf": tf_label, "symbol": sym, "trend": trend, "bars": bars,
|
||
"ema12": [round(x, 3) for x in e12[-k:]],
|
||
"ema50": [round(x, 3) for x in e50[-k:]],
|
||
"levels": levels, "channel": channel, "patterns": patterns,
|
||
"broker_utc_offset": 3 * 3600}
|
||
|
||
def _in_startup_grace(self) -> bool:
|
||
"""True in den ersten `_startup_grace_s` s nach Bot-Start — dann schließt der
|
||
Bot keinen laufenden Trade selbst (Daten noch nicht stabil nach Neustart)."""
|
||
return time.time() < self._no_autoclose_until
|
||
|
||
# ── Circuit Breaker (Tagesverlust-Stopp) ─────────────────────────────────────
|
||
def _day_realized_cached(self) -> float:
|
||
"""Realisierter Tages-P&L (heute geschlossene Trades), gecacht ~30 s —
|
||
damit die SQLite-Abfrage nicht jeden 1-s-Tick läuft."""
|
||
now = time.time()
|
||
if now - self._cb_realized[0] < 30:
|
||
return self._cb_realized[1]
|
||
try:
|
||
v = float((self.history.stats_overview("today") or {}).get("total_pnl") or 0.0)
|
||
except Exception:
|
||
v = self._cb_realized[1]
|
||
self._cb_realized = (now, v)
|
||
return v
|
||
|
||
def _circuit_halted(self) -> bool:
|
||
"""True, wenn der Circuit Breaker HEUTE (Berlin) bereits ausgelöst hat →
|
||
keine neuen Auto-Trades bis zum nächsten Tag."""
|
||
if self._cb_limit_pct <= 0 or not self._circuit_tripped_date:
|
||
return False
|
||
return self._circuit_tripped_date == datetime.now(_BERLIN).date()
|
||
|
||
def _check_circuit_breaker(self):
|
||
"""Tagesverlust-Stopp (User-Vorgabe 2026-07-27): erreicht der Tages-P&L
|
||
(realisiert heute + offene Position) −`daily_loss_limit_pct`% der Balance,
|
||
schließt der Bot die Position und sperrt neue Auto-Trades bis zum nächsten
|
||
Tag. Wirkt auch bei gesperrtem Handy. Fail-safe."""
|
||
if self._cb_limit_pct <= 0:
|
||
return
|
||
if self._in_startup_grace(): # nach Neustart nicht auf halb-init Daten feuern
|
||
return
|
||
today = datetime.now(_BERLIN).date()
|
||
if self._circuit_tripped_date and self._circuit_tripped_date != today:
|
||
self._circuit_tripped_date = None # neuer Tag → Reset
|
||
log.info("Circuit Breaker zurückgesetzt (neuer Handelstag)")
|
||
if self._circuit_tripped_date == today:
|
||
return # heute schon ausgelöst
|
||
bal = float(self.data.balance or 0.0)
|
||
if bal <= 0:
|
||
return
|
||
ps = self.trader.snapshot()
|
||
open_pnl = float(ps.get("pnl") or 0.0)
|
||
day_total = self._day_realized_cached() + open_pnl
|
||
limit_eur = self._cb_limit_pct / 100.0 * bal
|
||
if day_total > -limit_eur:
|
||
return
|
||
self._circuit_tripped_date = today
|
||
log.warning(f"🛑 CIRCUIT BREAKER: Tages-P&L {day_total:+.2f} ≤ -{limit_eur:.2f} € "
|
||
f"({self._cb_limit_pct:.0f}% von {bal:.0f}) → Position schließen, "
|
||
f"keine Auto-Trades mehr heute")
|
||
sym = self.data.symbol or ""
|
||
tg = self.cfg["telegram"]
|
||
tg_on = (tg.get("enabled", "false").lower() == "true" and tg.get("bot_token"))
|
||
if ps.get("ticket"):
|
||
err = self.trader.close(reason="circuit_breaker")
|
||
if err:
|
||
log.error(f"Circuit-Breaker-Close fehlgeschlagen: {err}")
|
||
if tg_on:
|
||
try:
|
||
send_telegram(f"🛑 <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
|
||
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 _check_auto_squeeze(self):
|
||
"""Autonomer Entry auf den gemessen-validierten Volatilitäts-Squeeze-Ausbruch
|
||
(`wave.squeeze.state == 'active'`), NUR wenn flat. Feuert **1× je Ausbruch**
|
||
(Dedup über dir+level; re-armt, sobald der Squeeze klärt). Emergency-Stop
|
||
armiert beim Öffnen automatisch (2 % Konto, via `_check_auto_close`). Fail-safe."""
|
||
if not self._auto_squeeze:
|
||
return
|
||
if self._circuit_halted(): # Tagesverlust-Stopp aktiv → heute keine Auto-Trades
|
||
return
|
||
try:
|
||
sq = (self.wave.snapshot() or {}).get("squeeze") or {}
|
||
if sq.get("state") != "active":
|
||
self._squeeze_last_sig = None # Ausbruch geklärt → re-armen
|
||
return
|
||
d = sq.get("dir"); lvl = sq.get("level")
|
||
if d not in ("LONG", "SHORT") or lvl is None:
|
||
return
|
||
sig = (d, round(float(lvl), 2))
|
||
# ── Dead-Hour-Guard (0–7 Uhr Berlin, 2026-07-17): nachts KOMPLETT inaktiv —
|
||
# kein Entry UND kein Reverse (der Squeeze ist in dünner Nacht-Liquidität in
|
||
# BEIDE Richtungen unzuverlässig; die 3 Nacht-Squeezes waren die ganze
|
||
# Verlustquelle). Setzt keine Dedup-Marker → feuert nach 08:00, falls noch
|
||
# aktiv. `auto_squeeze_skip_night=false` schaltet aus.
|
||
if self._squeeze_skip_night and datetime.now(_BERLIN).hour in _SQUEEZE_NIGHT:
|
||
return
|
||
|
||
ps = self.trader.snapshot()
|
||
ticket = ps.get("ticket")
|
||
if ticket:
|
||
pos_dir = "LONG" if ps.get("order_type") == 0 else "SHORT"
|
||
if pos_dir == d:
|
||
self._squeeze_last_sig = sig # gleiche Richtung → schon dabei
|
||
return
|
||
# ── Stop-&-Reverse (Default AUS, `auto_squeeze_reverse`): unbelegt +
|
||
# dreht am Ausbruch-Extrem (real 2026-07-17 18:55). Ist er aus, bleibt
|
||
# der Gegen-Trade unangetastet — Drehen ist dann MANUELLE Entscheidung.
|
||
# `_squeeze_last_sig` NICHT setzen → schließt der Gegen-Trade (SL/manuell),
|
||
# kann der flat-Entry auf denselben Ausbruch noch feuern.
|
||
if not self._squeeze_reverse:
|
||
return
|
||
if self._in_startup_grace(): # Startup-Schonfrist: kein Reverse-Close nach Neustart
|
||
return
|
||
# Trade läuft GEGEN den Ausbruch → sofort schließen. Der nächste (flat)
|
||
# Tick eröffnet dann in Ausbruchsrichtung über den Entry-Pfad. Dedup über
|
||
# reverse_sig (kein Doppel-Close, während die Position noch schließt).
|
||
if sig == self._squeeze_reverse_sig:
|
||
return
|
||
self._squeeze_reverse_sig = sig
|
||
sym = self.data.symbol or ""
|
||
pnl = ps.get("pnl")
|
||
log.info(f"🔄 AUTO-SQUEEZE-REVERSE: {pos_dir}-Position gegen {d}-Ausbruch "
|
||
f"@ {lvl} (P&L {pnl:+.2f} EUR) → sofort schließen, dann drehen")
|
||
err = self.trader.close(reason="squeeze_reverse")
|
||
tg = self.cfg["telegram"]
|
||
tg_on = (tg.get("enabled", "false").lower() == "true" and tg.get("bot_token"))
|
||
if err:
|
||
log.error(f"Auto-Squeeze-Reverse-Close fehlgeschlagen: {err}")
|
||
self._squeeze_reverse_sig = None # Fehlschlag → nächster Tick erneut
|
||
elif tg_on:
|
||
try: send_telegram(f"🔄 <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
|
||
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 (gemessene Kostenfalle 0–7 Uhr Berlin) — kein Dedup-Marker,
|
||
# damit das Signal nach 08:00 noch feuern kann, falls es dann noch steht.
|
||
if self._auto_signal_skip_night and datetime.now(_BERLIN).hour in _SQUEEZE_NIGHT:
|
||
return
|
||
ps = self.trader.snapshot()
|
||
if ps.get("ticket"):
|
||
# Position offen: läuft sie in Signalrichtung, gilt die Episode als
|
||
# „schon dabei" (kein Nachkauf); Gegen-Position wird NICHT gedreht
|
||
# (unbelegt — dieselbe Begründung wie beim Squeeze-Reverse).
|
||
pos_dir = "LONG" if ps.get("order_type") == 0 else "SHORT"
|
||
if pos_dir == d:
|
||
self._signal_last_sig = d
|
||
return
|
||
if d == self._signal_last_sig:
|
||
return # diese Episode schon gehandelt
|
||
# Wiedereinstiegs-Sperren (wie beim Squeeze): nach S/R-Close bzw. nach
|
||
# JEDEM Close nicht sofort gleichgerichtet neu eröffnen.
|
||
now = time.time()
|
||
if (d == self._sr_close_dir and now - self._sr_close_ts < _SQUEEZE_SR_COOLDOWN_S):
|
||
return
|
||
if (d == self._pos_close_dir and now - self._pos_close_ts < _SQUEEZE_REENTRY_COOLDOWN_S):
|
||
return
|
||
self._signal_last_sig = d
|
||
if isinstance(self._last_rec, dict):
|
||
self._last_rec["setup"] = f"AUTOSIG_{d}"
|
||
log.info(f"🎯 AUTO-SIGNAL-ENTRY: {d} (Konfidenz {conf}% ≥ "
|
||
f"{self._auto_signal_min_conf}%) — eröffne selbständig")
|
||
err = (self.open_long(source="auto_signal") if d == "LONG"
|
||
else self.open_short(source="auto_signal"))
|
||
sym = self.data.symbol or ""
|
||
tg = self.cfg["telegram"]
|
||
tg_on = (tg.get("enabled", "false").lower() == "true" and tg.get("bot_token"))
|
||
if err:
|
||
log.error(f"Auto-Signal-Entry fehlgeschlagen: {err}")
|
||
self._signal_last_sig = None # Fehlschlag → nächster Tick erneut
|
||
if tg_on:
|
||
try: send_telegram(f"⚠ <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
|
||
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)
|
||
except Exception as e:
|
||
log.warning(f"log_recommendation: {e}")
|
||
|
||
# Verdict-Stimmen-Logging (2026-07-24, Idee 2b): Modul-Votes 1×/min in die
|
||
# DB (`verdict_votes`), damit die UNBELEGTEN Stimmen (KI-Copilot, Elliott)
|
||
# nach ein paar Wochen datenbasiert bewertet werden können (behalten /
|
||
# Gewicht senken / raus wie TU). Erste Messung (analyze_verdict_calibration.
|
||
# py, 2026-07-24): Copilot nicht robust prädiktiv, n aber klein → sammeln.
|
||
try:
|
||
vd = self._verdict(s, rec, self.wave.snapshot(), self.tu.snapshot(),
|
||
self.agent.snapshot(), self.elliott.snapshot())
|
||
vv = {v["name"]: v for v in vd.get("votes") or []}
|
||
def _v(name, key="vote"):
|
||
return (vv.get(name) or {}).get(key, 0)
|
||
self.history.log_verdict_votes(
|
||
headline=vd.get("headline") or "WARTEN",
|
||
conf=int(vd.get("conf") or 0), bias=float(vd.get("bias") or 0.0),
|
||
tf=vd.get("tf") or "", wave=_v("Welle"), m30=_v("M30"), h1=_v("H1"),
|
||
ki=_v("KI-Copilot"), ki_w=float(_v("KI-Copilot", "weight") or 0.0),
|
||
elliott=_v("Elliott"), elliott_w=float(_v("Elliott", "weight") or 0.0),
|
||
squeeze=_v("Squeeze"), news_score=_news)
|
||
except Exception as e:
|
||
log.debug(f"log_verdict_votes: {e}")
|
||
|
||
# Kontext für Trade-Logging (set_open_context beim Öffnen)
|
||
self._last_rec = {
|
||
"rec_signal": rec.get("signal"),
|
||
"rec_score": rec.get("score"),
|
||
"ai_sentiment": None,
|
||
"ai_confidence": None,
|
||
"setup": rec.get("setup"),
|
||
"regime": None,
|
||
"rsi": s.get("rsi_m15"),
|
||
"news_score": None,
|
||
}
|
||
|
||
def _check_close_alert(self, rec: dict):
|
||
"""Telegram-CLOSE-Alarm NUR bei Signal-Flip gegen die Position (echter
|
||
Wende-Grund). Max. 1×/5 min (`_CLOSE_ALERT_COOLDOWN_S`) — sonst spammt es.
|
||
Auch bei gesperrtem Handy. (S/R-Ziel-Alarm entfernt — stupste zum verfrühten
|
||
Gewinner-Kappen; den Gewinner führt das Trailing.)"""
|
||
ps = self.trader.snapshot()
|
||
ticket = ps.get("ticket")
|
||
if not ticket:
|
||
return # keine Position → kein Alarm; Cooldown NICHT zurücksetzen
|
||
# (ein kurz flackerndes Ticket darf den Cooldown nicht killen)
|
||
if ticket != self._close_alert_ticket:
|
||
self._close_alert_ticket = ticket
|
||
self._close_alert_ts = 0.0 # NUR bei echter neuer Position sofort melden
|
||
is_long = ps.get("order_type") == mt5.ORDER_TYPE_BUY
|
||
sig = rec.get("signal")
|
||
pnl = ps.get("pnl") or 0.0
|
||
|
||
# Close-Alarm NUR bei Signal-FLIP gegen die Position (echter Wende-Grund).
|
||
# Der frühere S/R-Ziel-Alarm wurde entfernt — er stupste zum verfrühten
|
||
# Kappen der Gewinner (Verlust-Schiefe); den Gewinner führt das Trailing.
|
||
reason = None
|
||
if pnl > 0: # Close-Alarm NUR bei Trade im Plus
|
||
if (is_long and sig == "SHORT") or (not is_long and sig == "LONG"):
|
||
reason = f"Empfehlung dreht auf {sig} ({rec.get('conf_pct')}%) — Gewinn sichern"
|
||
|
||
if not reason:
|
||
return
|
||
if time.time() - self._close_alert_ts < _CLOSE_ALERT_COOLDOWN_S:
|
||
return # innerhalb des 5-min-Cooldowns — kein Spam
|
||
self._close_alert_ts = time.time()
|
||
self._close_alert_count += 1
|
||
log.info(f"🔔 Flip-Close-Alarm #{self._close_alert_count}: "
|
||
f"{self.data.symbol or ''} {'LONG' if is_long else 'SHORT'} "
|
||
f"P&L {pnl:+.2f} — {reason}")
|
||
tg = self.cfg["telegram"]
|
||
if tg.get("enabled", "false").lower() == "true" and tg.get("bot_token"):
|
||
try:
|
||
send_telegram(
|
||
f"🔔 <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
|
||
# Hysterese: nur wechseln, wenn klar besser (kein Flattern)
|
||
if best and best != cur and \
|
||
scores.get(best, 0) > scores.get(cur, 0) * 1.2:
|
||
self._apply_wave_tf(best, source=f"Heuristik {scores}")
|
||
except Exception as e:
|
||
log.warning(f"_tf_loop: {e}")
|
||
if self._sleep(60):
|
||
return
|
||
|
||
def _load_report_state(self):
|
||
"""Letzte Report-Versanddaten laden (überlebt Neustarts, s. __init__)."""
|
||
try:
|
||
import json
|
||
with open(self._report_state_path, encoding="utf-8") as f:
|
||
st = json.load(f)
|
||
self._daily_last_run = st.get("daily") or None
|
||
wk = st.get("weekly")
|
||
self._weekly_last_run = tuple(wk) if wk else None
|
||
except FileNotFoundError:
|
||
pass
|
||
except Exception as e:
|
||
log.warning(f"report_state laden: {e}")
|
||
|
||
def _save_report_state(self):
|
||
try:
|
||
import json
|
||
with open(self._report_state_path, "w", encoding="utf-8") as f:
|
||
json.dump({"daily": self._daily_last_run,
|
||
"weekly": list(self._weekly_last_run)
|
||
if self._weekly_last_run else None}, f)
|
||
except Exception as e:
|
||
log.warning(f"report_state speichern: {e}")
|
||
|
||
def _load_runtime_state(self):
|
||
"""UI-gesetzte Einstellungen laden (überleben Neustarts). Nur Keys, die
|
||
auch in der Datei stehen, überschreiben die ini-Defaults."""
|
||
try:
|
||
import json
|
||
with open(self._runtime_state_path, encoding="utf-8") as f:
|
||
st = json.load(f)
|
||
if "auto_squeeze" in st:
|
||
self._auto_squeeze = bool(st["auto_squeeze"])
|
||
if "auto_signal" in st:
|
||
self._auto_signal = bool(st["auto_signal"])
|
||
if "auto_sr_close" in st:
|
||
self._auto_sr_close = bool(st["auto_sr_close"])
|
||
if "sr_close_min_gain" in st:
|
||
self._sr_close_min_gain = max(0.0, float(st["sr_close_min_gain"] or 0))
|
||
log.info(f"Runtime-State geladen: BRK={'AN' if self._auto_squeeze else 'aus'} · "
|
||
f"S/R-Close={'AN' if self._auto_sr_close else 'aus'} · "
|
||
f"MinGewinn={self._sr_close_min_gain:+.2f}")
|
||
except FileNotFoundError:
|
||
pass
|
||
except Exception as e:
|
||
log.warning(f"runtime_state laden: {e}")
|
||
|
||
def _save_runtime_state(self):
|
||
try:
|
||
import json, os
|
||
tmp = self._runtime_state_path + ".tmp"
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
json.dump({"auto_squeeze": self._auto_squeeze,
|
||
"auto_signal": self._auto_signal,
|
||
"auto_sr_close": self._auto_sr_close,
|
||
"sr_close_min_gain": self._sr_close_min_gain}, f)
|
||
os.replace(tmp, self._runtime_state_path)
|
||
except Exception as e:
|
||
log.warning(f"runtime_state speichern: {e}")
|
||
|
||
def _load_emergency_state(self):
|
||
"""Aktiven Notfall-Stop/Take-Profit + zugehöriges Ticket laden (überlebt
|
||
Neustarts). Wird in `_check_auto_close` nur angewandt, solange dasselbe
|
||
Ticket noch offen ist — sonst armiert die neue Position normal."""
|
||
try:
|
||
import json
|
||
with open(self._emergency_state_path, encoding="utf-8") as f:
|
||
st = json.load(f)
|
||
self._emergency_state_ticket = st.get("ticket")
|
||
self._emergency_state_loss = st.get("loss")
|
||
self._emergency_state_tp = st.get("tp")
|
||
self._emergency_state_trail = st.get("trail")
|
||
except FileNotFoundError:
|
||
pass
|
||
except Exception as e:
|
||
log.warning(f"emergency_state laden: {e}")
|
||
|
||
def _save_emergency_state(self):
|
||
"""Notfall-Stop/TP + Ticket atomar persistieren (bei Auto-Arm UND manuellem
|
||
Set) — damit ein Neustart/Wiedererkennen die Schwelle nicht auf die Automatik
|
||
zurücksetzt (Fix 2026-07-17)."""
|
||
try:
|
||
import json, os
|
||
tmp = self._emergency_state_path + ".tmp"
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
json.dump({"ticket": self._last_pos_ticket,
|
||
"loss": self._emergency_loss,
|
||
"tp": self._takeprofit,
|
||
"trail": bool(self.trail.enabled)}, f)
|
||
os.replace(tmp, self._emergency_state_path)
|
||
self._emergency_state_ticket = self._last_pos_ticket
|
||
self._emergency_state_loss = self._emergency_loss
|
||
self._emergency_state_tp = self._takeprofit
|
||
self._emergency_state_trail = bool(self.trail.enabled)
|
||
except Exception as e:
|
||
log.warning(f"emergency_state speichern: {e}")
|
||
|
||
def _daily_loop(self):
|
||
"""Holt 1×/Tag ab 07:30 Berlin die Tages-Level (Web-Recherche). Fail-safe:
|
||
bei Fehlschlag bis zu 3 Versuche, dann Tag abhaken (alte Level bleiben)."""
|
||
from datetime import datetime
|
||
from zoneinfo import ZoneInfo
|
||
berlin = ZoneInfo("Europe/Berlin")
|
||
if self._sleep(10):
|
||
return
|
||
while self._running:
|
||
try:
|
||
now = datetime.now(berlin)
|
||
today = now.date().isoformat()
|
||
due = (self._daily_last_run != today
|
||
and (now.hour, now.minute) >= (7, 30))
|
||
if due and self.data.symbol:
|
||
price = (self.data.snapshot() or {}).get("bid")
|
||
if self.daily.is_configured() and price:
|
||
self.daily.run(price) # best-effort, fail-safe
|
||
ok = self._send_daily_report() # Telegram + E-Mail
|
||
if ok or self._daily_fails >= 2:
|
||
self._daily_last_run = today; self._daily_fails = 0
|
||
self._save_report_state()
|
||
else:
|
||
self._daily_fails += 1
|
||
# B4-Wochenreport: montags ab 07:30, 1×/Woche (Track B)
|
||
wk = now.isocalendar()[:2]
|
||
if (now.weekday() == 0 and (now.hour, now.minute) >= (7, 30)
|
||
and self._weekly_last_run != wk):
|
||
if self._send_weekly_report():
|
||
self._weekly_last_run = wk
|
||
self._save_report_state()
|
||
except Exception as e:
|
||
log.warning(f"_daily_loop: {e}")
|
||
if self._sleep(120):
|
||
return
|
||
|
||
def _send_daily_report(self) -> bool:
|
||
"""Tagesreport = heutige Web-Prognose + Vortags-Bilanz → Telegram +
|
||
E-Mail (trading@hocks.eu). True, wenn min. ein Kanal sendete. Fail-safe."""
|
||
from datetime import datetime, timedelta
|
||
try:
|
||
st = self.history.stats_overview("yesterday") or {}
|
||
except Exception as e:
|
||
log.warning(f"daily stats: {e}"); st = {}
|
||
n = st.get("n_trades", 0); pnl = st.get("total_pnl", 0.0) or 0.0
|
||
wr = st.get("winrate", 0.0) or 0.0; pf = st.get("profit_factor")
|
||
cur = (self.data.snapshot() or {}).get("currency") or "EUR"
|
||
yday = (datetime.now() - timedelta(days=1)).strftime("%d.%m.%Y")
|
||
pf_s = f"{pf:.2f}" if pf else "—"
|
||
|
||
dl = self.daily.snapshot()
|
||
if dl.get("support") is not None:
|
||
fc = (f"Bias {dl['bias']} · Support {dl['support']:.2f} · "
|
||
f"Widerstand {dl['res_lo']:.2f}–{dl['res_hi']:.2f}\n{dl.get('summary','')}")
|
||
else:
|
||
fc = f"nicht verfügbar ({dl.get('error') or '—'})"
|
||
|
||
txt = (f"📊 <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}")
|
||
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"<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)
|
||
err = fn(sym)
|
||
if err:
|
||
return err
|
||
# Vom BOT eröffnete Trades markieren → nur für die greift die 15-Min-Regel
|
||
# (manuelle Trades bleiben in der Diskretion des Users, User-Vorgabe).
|
||
if source in ("auto_squeeze", "auto_signal"):
|
||
try:
|
||
self._bot_open_ticket = self.trader.snapshot().get("ticket")
|
||
self._adverse15_done_ticket = None
|
||
except Exception:
|
||
pass
|
||
# Trailing automatisch aktivieren (wie _after_trade im Widget)
|
||
if not self.trail.enabled:
|
||
self.trail.set_setup(self._last_rec.get("setup"))
|
||
self.trail.toggle(sym)
|
||
return None
|
||
|
||
def close(self) -> str | None:
|
||
return self.trader.close(reason="manual")
|
||
|
||
def toggle_trail(self) -> bool:
|
||
sym = self.data.symbol
|
||
if sym:
|
||
return self.trail.toggle(sym)
|
||
return self.trail.enabled
|
||
|
||
def _sr_levels(self, market: dict) -> dict:
|
||
"""Nächste Widerstandslinie ÜBER Preis (Short) und Unterstützung UNTER
|
||
Preis (Long) — kombiniert dynamische S/R-Levels + die [zones]-Config."""
|
||
bid = market.get("bid")
|
||
if not bid:
|
||
return {"resistance": None, "support": None,
|
||
"resistances": [], "supports": []}
|
||
sr = market.get("sr") or {}
|
||
above, below = [], []
|
||
for r in (sr.get("resistances") or []):
|
||
p = r.get("price")
|
||
if p: (above if p > bid else below).append(p)
|
||
for s in (sr.get("supports") or []):
|
||
p = s.get("price")
|
||
if p: (above if p > bid else below).append(p)
|
||
# Stehende Zonen aus der Config (beide Kanten als Linien)
|
||
try:
|
||
for z in parse_zones(self.cfg):
|
||
for edge in (z["lo"], z["hi"]):
|
||
(above if edge > bid else below).append(edge)
|
||
except Exception:
|
||
pass
|
||
# Tages-Level aus der Morgen-Recherche (Kontext)
|
||
try:
|
||
for lv in self.daily.zone_lines():
|
||
(above if lv > bid else below).append(lv)
|
||
except Exception:
|
||
pass
|
||
# Offene Kurslücken (D1-Gaps) als Fill-Magnete (Kontext/Konfidenz)
|
||
try:
|
||
for lv in self.gaps.zone_lines():
|
||
(above if lv > bid else below).append(lv)
|
||
except Exception:
|
||
pass
|
||
above = sorted(set(round(p, 3) for p in above))
|
||
below = sorted(set(round(p, 3) for p in below), reverse=True)
|
||
return {
|
||
"resistance": above[0] if above else None, # nächster Widerstand (Short)
|
||
"support": below[0] if below else None, # nächste Unterstützung (Long)
|
||
"resistances": above[:3],
|
||
"supports": below[:3],
|
||
}
|
||
|
||
def _draw_levels(self) -> dict:
|
||
"""S/R-Linien fürs ZEICHNEN (PWA-Charts + MQL5-CSV): echte Kursstruktur =
|
||
rohe M5-Pivots (`wave.pb_levels`, identisch zum P(break)-/Auto-Close-System),
|
||
geclustert (~0,5×ATR). Zeichnet **nur die NÄCHSTE je Seite** (2 Linien total,
|
||
deckungsgleich mit den Dashboard-Werten `sr-res`/`sr-sup`) — sonst tauchten
|
||
beim Kurslauf die weiter entfernten Linien laufend auf/ab (User-Feedback
|
||
2026-07-14). Bugfix davor: Linien kamen aus `_sr_levels` (stale [zones]-Config
|
||
+ Tageslevel + Gaps) — Uralt-Level statt aktueller Struktur. Ergebnis wird
|
||
~1 s gecacht (Konsistenz über alle Anzeigen + weniger Lock-Last)."""
|
||
now = time.time()
|
||
dc = self._draw_cache
|
||
if dc and (now - dc[0]) < 1.0:
|
||
return dc[1]
|
||
try:
|
||
ws = self.wave.snapshot() or {}
|
||
lv = ws.get("pb_levels") or {}
|
||
atr = (ws.get("pb_feats") or {}).get("atr") or 0.2
|
||
bid = (self.data.snapshot() or {}).get("bid")
|
||
if not bid:
|
||
return {"res": [], "sup": []}
|
||
# Cluster-Toleranz 0,5×ATR: nahe Pivot-Zwillinge (z. B. 80,50 + 80,59)
|
||
# verschmelzen zu EINER Linie.
|
||
def cluster(vals):
|
||
out = []
|
||
for v in sorted(vals):
|
||
if out and v - out[-1][-1] <= 0.5 * atr:
|
||
out[-1].append(v)
|
||
else:
|
||
out.append([v])
|
||
return [round(sum(g) / len(g), 3) for g in out]
|
||
# Fix 2026-07-23 (User-Frage „Widerstand/Support-Retest von der falschen
|
||
# Seite"): Widerstand/Support werden NACH AKTUELLER KURSPOSITION gewählt
|
||
# (über/unter bid), NICHT mehr strikt nach Entstehungstyp (Swing-Hoch `ph`
|
||
# vs. Swing-Tief `pl`). Vorher blieb ein gebrochenes Level für IMMER im
|
||
# falschen Topf hängen: ein Swing-Tief, das der Kurs unterschritt und von
|
||
# UNTEN erneut ansteuerte, tauchte nie als Widerstand auf (kam nur aus `ph`);
|
||
# ein Swing-Hoch, das der Kurs überschritt und von OBEN erneut ansteuerte
|
||
# (Retest), tauchte nie als Support auf (kam nur aus `pl`) — 2 von 4 Retest-
|
||
# Varianten hatten dadurch nie eine Durchbruchwahrscheinlichkeit.
|
||
# ⚠ ph/pl bleiben GETRENNT geclustert (nicht vorher zusammenlegen!) — ein
|
||
# erster Versuch clusterte den kombinierten Rohpool direkt, was in dichten
|
||
# Zonen zu Ketten-Mega-Clustern führte (doppelte Punktdichte → die einfache
|
||
# Nachbar-Kette lief über >1×ATR durch). Erst NACH dem separaten Clustern
|
||
# (unveränderte Granularität wie vorher) werden die beiden fertigen
|
||
# Linien-Listen zusammengeführt und rein nach Position zu bid gewählt.
|
||
ph_c = cluster(lv.get("ph") or [])
|
||
pl_c = cluster(lv.get("pl") or [])
|
||
both = ph_c + pl_c
|
||
# NUR die nächste je Seite (2 Linien total = wie im Dashboard)
|
||
r = min((p for p in both if p > bid), default=None)
|
||
s = max((p for p in both if p < bid), default=None)
|
||
# Hysterese „klebrig" (Fix 2026-07-21): die gezeichnete Linie bleibt
|
||
# stehen, solange der Kurs sie nicht WIRKLICH durchbricht (bid > R+0,1×ATR
|
||
# bzw. bid < S−0,1×ATR). Ein neuer, näherer Pivot löst KEINEN Wechsel aus
|
||
# — genau das war die Zitter-Quelle (auf M5 bilden sich laufend neue Pivots).
|
||
# Ausnahme: ist die alte Linie weit weg (>1,5×ATR) UND ein deutlich näheres
|
||
# Level existiert, wird sie übernommen (sonst bleibt sie irrelevant hängen).
|
||
def hold(new, old, is_res):
|
||
if old is None:
|
||
return new
|
||
m = 0.10 * atr
|
||
broken = (bid > old + m) if is_res else (bid < old - m)
|
||
if broken:
|
||
return new # echter Durchbruch → nächste Linie (kann None sein)
|
||
if (new is not None and abs(old - bid) > 1.5 * atr
|
||
and abs(new - bid) < abs(old - bid) - 0.5 * atr):
|
||
return new # alte Linie veraltet/weit weg → näheres Level
|
||
return old # sonst: Linie HALTEN (kein Springen)
|
||
r = hold(r, self._draw_last.get("r"), True)
|
||
s = hold(s, self._draw_last.get("s"), False)
|
||
self._draw_last = {"r": r, "s": s}
|
||
out = {"res": [r] if r is not None else [],
|
||
"sup": [s] if s is not None else []}
|
||
self._draw_cache = (now, out)
|
||
return out
|
||
except Exception:
|
||
return {"res": [], "sup": []}
|
||
|
||
def _dash_sr(self, market: dict) -> dict:
|
||
"""S/R fürs Dashboard — GLEICHE Pivot-Quelle wie die Chart-Linien
|
||
(`_draw_levels`), damit Position-Kachel und Chart übereinstimmen."""
|
||
dl = self._draw_levels()
|
||
res = dl.get("res") or []; sup = dl.get("sup") or []
|
||
return {"resistance": (res[0] if res else None),
|
||
"support": (sup[0] if sup else None),
|
||
"resistances": res, "supports": sup}
|
||
|
||
def _write_levels_file(self):
|
||
"""Schreibt S/R als CSV nach `<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 ''}"]
|
||
for p in dl.get("res", []): lines.append(f"R;{p}")
|
||
for p in dl.get("sup", []): lines.append(f"S;{p}")
|
||
# Regressionskanal (M30) als Trendlinien-Anker → MQL5 zieht OBJ_TREND
|
||
# (Format CH;<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
|
||
# Zonen NICHT mehr aus der (statischen) [zones]-Config — die dynamische
|
||
# Handels-Range zeichnet der Indikator selbst aus den Chart-Kerzen (TF-genau).
|
||
path = os.path.join(self._mql5_files_dir, "sr_levels.csv")
|
||
with open(path + ".tmp", "w", encoding="ascii", errors="ignore") as f:
|
||
f.write("\n".join(lines) + "\n")
|
||
os.replace(path + ".tmp", path)
|
||
except Exception as e:
|
||
log.debug(f"write_levels: {e}")
|
||
|
||
def _verdict(self, market, wave_signal, wave_snap, tu, agent, elliott) -> dict:
|
||
"""Gesamtempfehlung aus den Modulen für die Widget-Grafik.
|
||
|
||
Headline = die **validierte Welle** (einziges Modul mit belegtem Edge);
|
||
die übrigen Module bilden einen gewichteten Konsens-Balken. REINE
|
||
ANZEIGE — ändert NICHT die Order-Logik (die hört nur auf die Welle).
|
||
Votes: +1 LONG, −1 SHORT, 0 neutral.
|
||
"""
|
||
def word(v):
|
||
return "auf" if v > 0 else "ab" if v < 0 else "flach"
|
||
|
||
bid = (market or {}).get("bid")
|
||
votes = []
|
||
|
||
def add(name, vote, weight, detail):
|
||
votes.append({"name": name, "vote": int(vote),
|
||
"weight": weight, "detail": detail})
|
||
|
||
# 1) Welle — primär (validierter Edge)
|
||
wsig = (wave_signal or {}).get("signal")
|
||
wv = 1 if wsig == "LONG" else -1 if wsig == "SHORT" else 0
|
||
add("Welle", wv, 3.0, f"{wsig or '—'} · {(wave_signal or {}).get('conf_pct', 0)}%")
|
||
|
||
# 2/3) M30 / H1 — die EMA-Signale, die die Welle tatsächlich nutzt
|
||
m30 = int((wave_snap or {}).get("htf_trend", 0) or 0)
|
||
h1 = int((wave_snap or {}).get("h1_trend", 0) or 0)
|
||
add("M30", m30, 1.5, word(m30))
|
||
add("H1", h1, 1.5, word(h1))
|
||
|
||
# 4) TradersUnion ENTFERNT aus dem Verdict-Konsens (User-Vorgabe 2026-07-06):
|
||
# lagt + nicht backtestbar + Standard-Indikator-Konfluenz = kein Edge. Bleibt
|
||
# nur noch reine Anzeige (Snapshot `tu`), zählt NICHT mehr in Bias/Agree.
|
||
|
||
# 5) KI-Copilot — OHNE Aussage (kein advisory/bias, z. B. Agent aus) → Gewicht 0
|
||
# (stimmt nicht mit ab, Chip bleibt sichtbar). Explizites "NEUTRAL" = echte
|
||
# Neutral-Stimme mit Gewicht.
|
||
adv = (agent or {}).get("advisory") or {}
|
||
ab = adv.get("bias")
|
||
av = 1 if ab == "LONG" else -1 if ab == "SHORT" else 0
|
||
add("KI-Copilot", av, 1.0 if ab else 0.0,
|
||
f"{ab or '—'} · {adv.get('confidence', 0)}%")
|
||
|
||
# 6) Elliott/FVG — nur stimmberechtigt, wenn ein Ziel existiert (sonst w=0)
|
||
ev, edet, ew = 0, "—", 0.0
|
||
tgt = (elliott or {}).get("target")
|
||
if tgt is not None and bid:
|
||
ev = -1 if tgt < bid else 1 if tgt > bid else 0
|
||
edet = f"{(elliott or {}).get('wave', '—')} → {tgt:.3f}"
|
||
ew = 1.0
|
||
add("Elliott", ev, ew, edet)
|
||
|
||
# 7) Squeeze-Breakout — Event-Signal: stimmt NUR bei `active` mit (dann 2,0,
|
||
# gemessen 2-Stichproben-positiv, unter der Welle=3,0). armed/keine → Gewicht 0
|
||
# — sonst dämpft das fast immer neutrale Modul die Bias-Nadel dauerhaft
|
||
# (Logikfehler bis 2026-07-15: w=2,0 stand permanent im Nenner → Nadel ~×0,8).
|
||
sq = (wave_snap or {}).get("squeeze") or {}
|
||
sq_active = sq.get("state") == "active"
|
||
sqv = (1 if sq.get("dir") == "LONG" else -1) if sq_active else 0
|
||
add("Squeeze", sqv, 2.0 if sq_active else 0.0,
|
||
f"{sq.get('state') or '—'}" + (f" {sq.get('dir')}" if sq.get("dir") else ""))
|
||
|
||
# Bias nur über stimmberechtigte Module (weight>0); agree/total = wie viele
|
||
# der ANDEREN stimmberechtigten Module die Wellen-Richtung teilen (die Welle
|
||
# zählte sich vorher selbst mit — „3/6 für LONG" war geschönt).
|
||
wsum = sum(v["weight"] for v in votes) or 1.0
|
||
bias = sum(v["weight"] * v["vote"] for v in votes) / wsum
|
||
others = [v for v in votes if v["name"] != "Welle" and v["weight"] > 0]
|
||
agree = sum(1 for v in others if wv != 0 and v["vote"] == wv)
|
||
return {
|
||
"headline": wsig, "conf": (wave_signal or {}).get("conf_pct", 0),
|
||
"tf": (wave_snap or {}).get("tf"), # Zeitebene, für die die Empfehlung gilt
|
||
"bias": round(bias, 3), "agree": agree, "total": len(others),
|
||
"votes": votes,
|
||
}
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# SNAPSHOT (ein JSON-fähiger Zustand für /api/snapshot + WebSocket)
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
_SNAP_TTL_S = 0.8 # geteilter Snapshot gilt so lange als frisch
|
||
|
||
def _alignment_cached(self) -> dict | None:
|
||
"""Ausrichtungs-Split (mit/gegen/ohne Empfehlung, letzte 40 Trades) für den
|
||
Order-Bestätigungsdialog — ehrliche eigene Zahlen statt generischem Hinweis
|
||
(2026-07-24, Verhaltens-Hebel: Gegen-Signal-Trades sind die gemessene
|
||
Kern-Leckage). DB-Query gecacht (~120 s), Snapshot bleibt billig."""
|
||
now = time.time()
|
||
if now - getattr(self, "_align_ts", 0.0) > 120:
|
||
try:
|
||
self._align_cache = self.history.alignment_stats(40)
|
||
except Exception as e:
|
||
log.debug(f"alignment_stats: {e}")
|
||
self._align_cache = None
|
||
self._align_ts = now
|
||
return getattr(self, "_align_cache", None)
|
||
|
||
def snapshot_cached(self) -> dict:
|
||
"""Teilt EINEN frisch berechneten Snapshot über alle WS-Clients/HTTP-
|
||
Aufrufe (TTL ~0,8 s). Spart SQLite/Verdict-Last bei mehreren Geräten —
|
||
unabhängig von der Client-Anzahl wird nur ~1×/TTL gerechnet."""
|
||
now = time.time()
|
||
with self._snap_lock:
|
||
if self._snap_cache and (now - self._snap_cache_ts) < self._SNAP_TTL_S:
|
||
return self._snap_cache
|
||
snap = self.snapshot() # teuer → außerhalb des Locks rechnen
|
||
with self._snap_lock:
|
||
self._snap_cache = snap
|
||
self._snap_cache_ts = time.time()
|
||
return snap
|
||
|
||
def _fetch_m5_monitor(self, days: int = 30):
|
||
"""M5-Bars für den Squeeze-Monitor (unter mt5_lock, aus der Server-Instanz)."""
|
||
import MetaTrader5 as mt5
|
||
from core.mt5_utils import mt5_lock
|
||
sym = self.data.symbol
|
||
if not sym:
|
||
return None
|
||
with mt5_lock(timeout=5) as got:
|
||
if not got:
|
||
return None
|
||
bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, days * 288 + 30)
|
||
si = mt5.symbol_info(sym)
|
||
point = si.point if si else 0.01
|
||
if bars is None or len(bars) == 0:
|
||
return None
|
||
return {"H": [float(b["high"]) for b in bars], "L": [float(b["low"]) for b in bars],
|
||
"C": [float(b["close"]) for b in bars],
|
||
"SP": [float(b["spread"]) * point for b in bars]}
|
||
|
||
def _evaluate_pbreak_predictions(self, window_min: int = 60):
|
||
"""Wertet offene Abprall/Durchbruch-Vorhersagen anhand von `candles_m1` aus —
|
||
DIESELBE Definition wie beim Modell-Training (`_BRK_ATR=0,5`/`_BRK_W=12` M5-
|
||
Bars = 60 min in `backtest_srclose_prob.py`): erreicht der Kurs zuerst
|
||
`confirm_price` → 'break', zuerst `reject_price` → 'bounce', keins von beiden
|
||
binnen 60 min → 'bounce' (Timeout, wie im Training). Self-throttled (~5 min),
|
||
aus `_trend_loop`. `candles_m1.time` ist rohe Broker-Zeit — Offset nötig."""
|
||
now = time.time()
|
||
if now - getattr(self, "_pbreak_eval_ts", 0.0) < 300:
|
||
return
|
||
self._pbreak_eval_ts = now
|
||
try:
|
||
import sqlite3
|
||
off = self.trader._broker_offset_s()
|
||
con = sqlite3.connect(HISTORY_DB_FILE, timeout=5.0)
|
||
con.row_factory = sqlite3.Row
|
||
pend = con.execute(
|
||
"SELECT id, ts, direction, predicted, confirm_price, reject_price "
|
||
"FROM pbreak_predictions WHERE outcome IS NULL"
|
||
).fetchall()
|
||
for r in pend:
|
||
d = 1 if r["direction"] == "LONG" else -1
|
||
t0_b = r["ts"] + off # Vorhersage-Zeit in Broker-Zeit
|
||
t1_b = t0_b + window_min * 60
|
||
bars = con.execute(
|
||
"SELECT h, l, time FROM candles_m1 WHERE time > ? AND time <= ? "
|
||
"ORDER BY time ASC", (t0_b, t1_b)).fetchall()
|
||
outcome = None
|
||
for b in bars:
|
||
hit_confirm = (b["h"] >= r["confirm_price"]) if d > 0 else (b["l"] <= r["confirm_price"])
|
||
hit_reject = (b["l"] <= r["reject_price"]) if d > 0 else (b["h"] >= r["reject_price"])
|
||
if hit_confirm:
|
||
outcome = "break"; break
|
||
if hit_reject:
|
||
outcome = "bounce"; break
|
||
if outcome is None and time.time() >= (r["ts"] + window_min * 60):
|
||
outcome = "bounce" # Timeout, wie im Training (brk=False)
|
||
if outcome:
|
||
correct = 1 if r["predicted"] == outcome else 0
|
||
con.execute(
|
||
"UPDATE pbreak_predictions SET outcome=?, outcome_ts=?, correct=? WHERE id=?",
|
||
(outcome, int(time.time()), correct, r["id"]))
|
||
con.commit(); con.close()
|
||
except Exception as e:
|
||
log.debug(f"pbreak_predictions auswerten: {e}")
|
||
|
||
def squeeze_monitor(self) -> dict:
|
||
"""Auto-Squeeze-Monitor (B4) für das Dashboard: (1) LIVE-Trades (echt, DB —
|
||
das Gesamtsystem inkl. realer Exits) und (2) die MECHANISCHE Neuberechnung
|
||
der Squeeze-Regel auf ~30 Tagen M5 (Entry-Edge ISOLIERT, Backtest-Exit) gegen
|
||
die Erwartung (ØR +0,14…+0,23 · PF>1). Cache TTL 60 s (M5-Fetch+Scan)."""
|
||
import sqlite3
|
||
now = time.time()
|
||
if self._sqmon_cache and now - self._sqmon_cache_ts < 60:
|
||
return self._sqmon_cache
|
||
exp = {"or_lo": 0.14, "or_hi": 0.23}
|
||
|
||
# ── 1) Live-Trades (echt) — SQUEEZE_* aus der DB ──────────────────────
|
||
live = {"n": 0}
|
||
try:
|
||
con = sqlite3.connect(HISTORY_DB_FILE); con.row_factory = sqlite3.Row
|
||
rows = [r for r in con.execute(
|
||
"SELECT direction, entry_price, exit_price, sl_at_entry, pnl, entry_time "
|
||
"FROM trades WHERE setup LIKE 'SQUEEZE%' AND exit_time IS NOT NULL")]
|
||
con.close()
|
||
pnls = [r["pnl"] for r in rows if r["pnl"] is not None]
|
||
if pnls:
|
||
n = len(pnls); wins = [p for p in pnls if p > 0]; losses = [p for p in pnls if p < 0]
|
||
Rs = []
|
||
for r in rows: # ØR-Näherung via SL-Distanz (SL≈2×ATR)
|
||
d = 1 if r["direction"] in ("BUY", "LONG") else -1
|
||
sld = abs((r["entry_price"] or 0) - (r["sl_at_entry"] or 0))
|
||
if sld > 0 and r["exit_price"] and r["entry_price"]:
|
||
Rs.append(2.0 * (r["exit_price"] - r["entry_price"]) * d / sld)
|
||
day, night = [], []
|
||
for r in rows:
|
||
if r["pnl"] is None: continue
|
||
h = (datetime.fromtimestamp(r["entry_time"], _BERLIN).hour
|
||
if r["entry_time"] else 12)
|
||
(night if h in _SQUEEZE_NIGHT else day).append(r["pnl"])
|
||
live = {
|
||
"n": n, "wr": round(100 * len(wins) / n),
|
||
"avg_win": round(sum(wins) / len(wins), 2) if wins else 0.0,
|
||
"avg_loss": round(sum(losses) / len(losses), 2) if losses else 0.0,
|
||
"ratio": (round((sum(wins) / len(wins)) / abs(sum(losses) / len(losses)), 2)
|
||
if wins and losses else None),
|
||
"sum_eur": round(sum(pnls), 2),
|
||
"oR": round(sum(Rs) / len(Rs), 3) if Rs else None,
|
||
"day": {"n": len(day), "sum": round(sum(day), 2)},
|
||
"night": {"n": len(night), "sum": round(sum(night), 2)},
|
||
}
|
||
except Exception as e:
|
||
live = {"n": 0, "error": str(e)[:100]}
|
||
|
||
# ── 2) Mechanik (B4) — Squeeze-Regel neu gerechnet, Entry-Edge isoliert ──
|
||
mech = {"error": "keine M5-Daten"}
|
||
try:
|
||
m5 = self._fetch_m5_monitor()
|
||
if m5 and len(m5["C"]) >= 300:
|
||
import backtest_breakout_squeeze as BQ
|
||
from core.wave_rec import _SQ_N, _SQ_MULT, _SQ_K
|
||
BQ._N = _SQ_N; BQ._K = _SQ_K
|
||
H, L, C, SP = m5["H"], m5["L"], m5["C"], m5["SP"]
|
||
A = BQ._atr_series(H, L, C)
|
||
sq, _ = BQ.scan(H, L, C, A, SP, 1.0, 0, len(C), _SQ_MULT)
|
||
n = len(sq)
|
||
if n:
|
||
w = sum(1 for x in sq if x > 0); s = sum(sq)
|
||
up = sum(x for x in sq if x > 0); dn = -sum(x for x in sq if x < 0)
|
||
mech = {"n": n, "wr": round(100 * w / n), "oR": round(s / n, 3),
|
||
"pf": round(up / dn, 2) if dn > 0 else 99.9, "sum": round(s)}
|
||
else:
|
||
mech = {"n": 0}
|
||
except Exception as e:
|
||
mech = {"error": str(e)[:100]}
|
||
|
||
# ── 3) Verdict aus der Mechanik (isoliert = apples-to-apples zum Backtest) ──
|
||
verdict = "watch"
|
||
if mech.get("n", 0) >= 6 and mech.get("oR") is not None:
|
||
if mech["oR"] > 0 and mech.get("pf", 0) > 1:
|
||
verdict = "ontrack" if mech["oR"] >= exp["or_lo"] else "weak"
|
||
else:
|
||
verdict = "drift"
|
||
|
||
res = {"live": live, "mech": mech, "expect": exp, "verdict": verdict,
|
||
"params": {"box": None}}
|
||
try:
|
||
from core.wave_rec import _SQ_N, _SQ_MULT, _SQ_K
|
||
res["params"] = {"box": _SQ_N, "k": _SQ_K, "mult": _SQ_MULT}
|
||
except Exception:
|
||
pass
|
||
self._sqmon_cache = res; self._sqmon_cache_ts = now
|
||
return res
|
||
|
||
def _compact_stats(self, period: str) -> dict:
|
||
"""Kompakte Kennzahlen für die Live-Statistik-Kachel (WR, PF, Ø-Gewinn/
|
||
-Verlust, Verhältnis, Trades, PnL) — aus `stats_overview`."""
|
||
try:
|
||
ov = self.history.stats_overview(period) or {}
|
||
except Exception:
|
||
return {}
|
||
if ov.get("error"):
|
||
return {}
|
||
aw = ov.get("avg_win", 0.0) or 0.0
|
||
al = ov.get("avg_loss", 0.0) or 0.0
|
||
pf = ov.get("profit_factor")
|
||
return {
|
||
"n": ov.get("n_trades", 0) or 0,
|
||
"wr": round(ov.get("winrate", 0.0) or 0.0),
|
||
"pf": round(pf, 2) if pf is not None else None,
|
||
"avg_win": round(aw, 2),
|
||
"avg_loss": round(al, 2),
|
||
"ratio": round(aw / abs(al), 2) if al < 0 else None,
|
||
"pnl": round(ov.get("total_pnl", 0.0) or 0.0, 2),
|
||
}
|
||
|
||
def snapshot(self) -> dict:
|
||
try:
|
||
last_trades = self.history.last_closed_trades(5)
|
||
except Exception as e:
|
||
log.warning(f"last_closed_trades: {e}")
|
||
last_trades = []
|
||
market = self.data.snapshot()
|
||
wave_signal = self.wave.signal()
|
||
wave_snap = self.wave.snapshot()
|
||
elliott = self.elliott.snapshot()
|
||
tu = self.tu.snapshot()
|
||
agent = self.agent.snapshot()
|
||
position = self.trader.snapshot()
|
||
# Tages-P/L: realisiert (heute geschlossene Trades) + offene Position
|
||
try:
|
||
day_realized = float((self.history.stats_overview("today") or {})
|
||
.get("total_pnl") or 0.0)
|
||
except Exception as e:
|
||
log.warning(f"day stats: {e}"); day_realized = 0.0
|
||
open_pnl = float(position.get("pnl") or 0.0)
|
||
day_pl = {"realized": round(day_realized, 2), "open": round(open_pnl, 2),
|
||
"total": round(day_realized + open_pnl, 2)}
|
||
# Circuit Breaker — Status für die UI (Tagesverlust-Stopp)
|
||
_bal = float(self.data.balance or 0.0)
|
||
_cb_limit_eur = round(self._cb_limit_pct / 100.0 * _bal, 2) if self._cb_limit_pct > 0 else None
|
||
circuit_breaker = {
|
||
"enabled": self._cb_limit_pct > 0, "limit_pct": self._cb_limit_pct,
|
||
"limit_eur": _cb_limit_eur, "day_total": day_pl["total"],
|
||
"tripped": self._circuit_halted(),
|
||
}
|
||
sr_close_hint = self._sr_close_hint(market, position, wave_snap)
|
||
stop_approach = self._stop_approach_hint(market, position, wave_snap)
|
||
return {
|
||
"ts": time.time(),
|
||
"connected": self._connected,
|
||
"running": self._running,
|
||
"error": self._error,
|
||
"symbol": self.data.symbol,
|
||
"wave_tf": self._wave_tf_label,
|
||
"min_conf": _MIN_CONF_DISPLAY,
|
||
"auth_required": self.require_token,
|
||
"market": market,
|
||
# Dashboard-S/R = GLEICHE Quelle wie die Chart-Linien (`_draw_levels` =
|
||
# rohe M5-Pivots), damit Kachel und Chart nicht mehr auseinanderlaufen.
|
||
# `_sr_levels` (Misch-Set) bleibt intern für Strategie/Konfidenz.
|
||
"sr_levels": self._dash_sr(market),
|
||
"session": session_state(),
|
||
"position": position,
|
||
"day_pl": day_pl,
|
||
"circuit_breaker": circuit_breaker,
|
||
"emergency_loss": self._emergency_loss,
|
||
"emergency_remember": self._emergency_remember,
|
||
"emergency_pct": self._emergency_pct,
|
||
"emergency_margin_pct": self._emergency_margin_pct,
|
||
"wht_pct": self._wht_pct,
|
||
"takeprofit": self._takeprofit,
|
||
"takeprofit_remember": self._takeprofit_remember,
|
||
"trail": self.trail.snapshot(),
|
||
"wave_signal": wave_signal,
|
||
"wave": wave_snap,
|
||
"elliott": elliott,
|
||
"tu": tu,
|
||
"agent": agent,
|
||
"verdict": self._verdict(market, wave_signal, wave_snap,
|
||
tu, agent, elliott),
|
||
"daily_levels": self.daily.snapshot(),
|
||
"gaps": self.gaps.snapshot(),
|
||
"structure": self.structure.snapshot(),
|
||
"patterns": self.patterns.snapshot(),
|
||
"stats_compact": {"week": self._compact_stats("week"),
|
||
"all": self._compact_stats("all")},
|
||
"close_alert_count": self._close_alert_count,
|
||
"news_storm": self.news.storm_state(),
|
||
# News-Sentiment-Score (−1..+1) für den Konflikt-Chip (2026-07-24):
|
||
# steht der Headline-Flow stark gegen Signal/Position, zeigt das
|
||
# Frontend eine Warnung (reiner Kontext — Prädiktivität unbelegt,
|
||
# wird via recommendations.news_score gesammelt und später gemessen).
|
||
"news_sentiment": (lambda sc: round(float(sc), 2) if sc is not None else None)(
|
||
(self.news.sentiment or {}).get("score")),
|
||
"alignment": self._alignment_cached(),
|
||
# Live-Handelskosten relativ zur Vola (2026-07-24): Spread/ATR(M5).
|
||
# Gemessen (`backtest_realcosts.py`): Ø 0,265×ATR, Nacht 0,32–0,50 =
|
||
# Kostenfalle, 15–17 Uhr ~0,13 = günstig. Reine Anzeige (Chip) — die
|
||
# informierende Alternative zum abgeschalteten Dead-Hours-Gate.
|
||
"cost_ratio": (lambda b, a, atr: round((a - b) / max(atr, 0.06), 2)
|
||
if (b and a and atr) else None)(
|
||
(market or {}).get("bid"), (market or {}).get("ask"),
|
||
((wave_snap or {}).get("pb_feats") or {}).get("atr")),
|
||
"sr_close_hint": sr_close_hint,
|
||
"stop_approach": stop_approach,
|
||
"auto_sr_close": self._auto_sr_close,
|
||
"sr_close_min_gain": self._sr_close_min_gain,
|
||
"sr_close_pbreak": self._sr_close_pbreak,
|
||
"sr_close_count": self._sr_close_count,
|
||
"auto_squeeze": self._auto_squeeze,
|
||
"squeeze_entry_count": self._squeeze_entry_count,
|
||
"auto_signal": self._auto_signal,
|
||
"auto_signal_min_conf": self._auto_signal_min_conf,
|
||
"signal_entry_count": self._signal_entry_count,
|
||
"adverse15_atr": self._adverse15_atr,
|
||
"adverse15_count": self._adverse15_count,
|
||
"last_trades": last_trades,
|
||
}
|
||
|
||
def _sr_close_hint(self, market, position, wave_snap) -> dict | None:
|
||
"""S/R-Close-Empfehlung mit kalibriertem P(Durchbruch)-Modell (gemessen
|
||
`backtest_srclose_prob.py`, Echtkosten, 2 Hälften): Kurs AM gegenüberliegenden
|
||
Level → P(break) aus `_p_break` schätzen; ist P < `sr_close_pbreak` (Default
|
||
0,60 = User-Regel „laufen lassen wenn >60 %"), prallt das Level wahrscheinlich
|
||
ab → Close schlägt Laufenlassen deutlich (+6000/+7600 R vs. reines Trailing).
|
||
Liefert IMMER die P(break)-Zahl (auch wenn ≥Schwelle → `close=False`) für die
|
||
Live-Anzeige."""
|
||
try:
|
||
if not position.get("ticket"):
|
||
self._pb_state = None
|
||
return None
|
||
is_long = position.get("order_type") == 0
|
||
bid = (market or {}).get("bid")
|
||
# Features FEST aus M5 (`wave.pb_feats`) — Modell wurde auf M5 trainiert;
|
||
# die Wave-TF-Werte (bis M30) verschoben die Skala → P falsch (Bugfix).
|
||
pf = (wave_snap or {}).get("pb_feats") or {}
|
||
atr = pf.get("atr")
|
||
mom6 = pf.get("mom6")
|
||
mom3 = pf.get("mom3")
|
||
ediff = pf.get("ema_diff")
|
||
entry = position.get("entry_price") or bid
|
||
# Level = EXAKT die gezeichnete Linie (`_draw_levels`, geclustert +
|
||
# Hysterese) — für LONG die nächste Resistance, für SHORT die nächste
|
||
# Support. So referenziert die Close-Meldung dasselbe Level, das im
|
||
# Chart/Dashboard steht (vorher rohe pb_levels → Meldung ≠ Linie,
|
||
# User-Feedback 2026-07-15). P(break) auf M5-Features unverändert.
|
||
dl = self._draw_levels()
|
||
if is_long:
|
||
level = (dl.get("res") or [None])[0]
|
||
else:
|
||
level = (dl.get("sup") or [None])[0]
|
||
if not (bid and atr and level) or mom6 is None or mom3 is None or ediff is None:
|
||
self._pb_state = None
|
||
return None
|
||
d = 1 if is_long else -1
|
||
gap = (level - bid) * d # Abstand zum Level (in Preis)
|
||
if gap > 0.6 * atr: # weit weg → gar kein Hinweis
|
||
self._pb_state = None
|
||
return None
|
||
wt = 1.0 if ediff * d > 0 else 0.0
|
||
dist = abs(level - entry) / atr
|
||
p_raw = _p_break(mom6 * d, mom3 * d, wt, dist)
|
||
# ── Anti-Flattern (gemessen `analyze_pbreak_flicker.py`) ─────────────
|
||
# (1) P über ~60 s glätten (Zeit-EMA) — mom3 der laufenden M5-Kerze ließ
|
||
# P sonst 7–15 Pp je Bar springen. Reset bei Level-Wechsel.
|
||
# (2) Hysterese ±5 Pp um die Schwelle: close↔laufen wechselt erst, wenn
|
||
# das geglättete P die Schwelle klar durchstößt (kein Flip-Flop).
|
||
import math as _m
|
||
now = time.time()
|
||
st = self._pb_state
|
||
if not st or abs(st["level"] - level) > 1e-9:
|
||
st = {"level": level, "p": p_raw, "ts": now, "zone": None}
|
||
else:
|
||
a = 1.0 - _m.exp(-max(0.0, now - st["ts"]) / 60.0)
|
||
st["p"] += a * (p_raw - st["p"])
|
||
st["ts"] = now
|
||
p_s = st["p"]
|
||
thr = self._sr_close_pbreak
|
||
zone = st.get("zone")
|
||
if zone is None:
|
||
zone = "close" if p_s < thr else "run"
|
||
elif zone == "close" and p_s >= thr + 0.05:
|
||
zone = "run"
|
||
elif zone == "run" and p_s < thr - 0.05:
|
||
zone = "close"
|
||
st["zone"] = zone
|
||
at_level = gap <= 0.15 * atr # Auslöse-Fenster (wie gemessen: Touch)
|
||
# Bruch-BESTÄTIGUNGS-Preis (Fix 2026-07-22, User-Frage „genauer berechnen"):
|
||
# dieselbe Definition wie beim TRAINING des Modells (`_BRK_ATR=0,5`,
|
||
# `backtest_srclose_prob.py`/`analyze_srwaves.py`) — ein Kurs, der die reine
|
||
# Levelzahl nur um wenige Cent überschreitet (Docht/Rauschen), zählt NICHT
|
||
# als „Durchbruch"; erst level±0,5×ATR gilt als bestätigt. Reale Analyse
|
||
# 2026-07-22: Trade bei Level 85,943 geschlossen, Kurs wischte kurz auf
|
||
# 85,994 (< Bestätigung 86,017), fiel danach zurück → laut Modell-eigener
|
||
# Definition eine ABLEHNUNG, kein Bruch — die Vorhersage war korrekt.
|
||
confirm = round(level + d * 0.5 * atr, 3) # Richtung Durchbruch
|
||
reject = round(level - d * 0.5 * atr, 3) # Richtung Abprall (Gegenbestätigung)
|
||
# Prognose-Logging (User-Wunsch 2026-07-23): EINMAL je frischem Touch
|
||
# (at_level False→True) die aktuelle Abprall/Durchbruch-Vorhersage
|
||
# protokollieren. Auswertung separat gegen `candles_m1`
|
||
# (`_evaluate_pbreak_predictions`, periodisch im Trend-Loop) — dieselbe
|
||
# confirm/reject-Definition wie beim Modell-Training, also fair verglichen.
|
||
if at_level and not st.get("was_at_level"):
|
||
try:
|
||
self.history.log_pbreak_prediction(
|
||
symbol=self.data.symbol or "",
|
||
direction=("LONG" if is_long else "SHORT"),
|
||
level=level, p_break=round(100 * p_s, 1),
|
||
predicted=("bounce" if zone == "close" else "break"),
|
||
price_at_pred=bid, confirm_price=confirm,
|
||
reject_price=reject, atr=atr)
|
||
except Exception as le:
|
||
log.debug(f"pbreak_prediction log: {le}")
|
||
st["was_at_level"] = at_level
|
||
self._pb_state = st
|
||
return {"level": round(level, 3), "p_break": round(100 * p_s),
|
||
"mom": round(mom3 * d, 2), "at_level": at_level,
|
||
"gap_atr": round(gap / atr, 2), "confirm": confirm,
|
||
# Auto-Close NUR exakt am Level; die Anzeige (0,15–0,6×ATR davor)
|
||
# ist reine Vorwarnung mit geglättetem P(break)
|
||
"close": at_level and zone == "close"}
|
||
except Exception as e:
|
||
log.debug(f"sr_close_hint: {e}")
|
||
return None
|
||
|
||
def _stop_approach_hint(self, market, position, wave_snap) -> dict | None:
|
||
"""REINE ANZEIGE: P(Durchbruch) für das Level, dem sich der Kurs auf der
|
||
**Gegen-/Stop-Seite** der offenen Position nähert (LONG → Support darunter,
|
||
SHORT → Resistance darüber). Beantwortet die User-Frage „mein Trade läuft auf
|
||
ein Level zu — bricht es durch (Verlust läuft) oder prallt es ab (erholt sich)?".
|
||
NICHT der S/R-Auto-Close (der wirkt nur auf der Ziel-Seite im Plus) — hier wird
|
||
NICHTS geschlossen. Kalibrierung der Gegen-Seite gemessen
|
||
(`backtest_pbreak_calibration.py`, 2026-07-24): AUC 0,68/0,72 out-of-sample in
|
||
beiden Hälften, im niedrigen P-Bereich sauber kalibriert; im hohen (≥0,5) gegen
|
||
den Trend leicht KONSERVATIV (unterschätzt die Bruch-Rate ~10–13 Pp) → die Zahl
|
||
ist eine belastbare, eher vorsichtige Schätzung. Feature-/Glättungs-Logik
|
||
identisch zu `_sr_close_hint`, nur Level = Gegen-Level und Richtung = -d."""
|
||
try:
|
||
if not position.get("ticket"):
|
||
self._stop_pb_state = None
|
||
return None
|
||
is_long = position.get("order_type") == 0
|
||
bid = (market or {}).get("bid")
|
||
pf = (wave_snap or {}).get("pb_feats") or {}
|
||
atr = pf.get("atr"); mom6 = pf.get("mom6"); mom3 = pf.get("mom3")
|
||
ediff = pf.get("ema_diff")
|
||
entry = position.get("entry_price") or bid
|
||
dl = self._draw_levels()
|
||
# Gegen-Level: LONG → Support darunter, SHORT → Resistance darüber
|
||
if is_long:
|
||
level = (dl.get("sup") or [None])[0]
|
||
else:
|
||
level = (dl.get("res") or [None])[0]
|
||
if not (bid and atr and level) or mom6 is None or mom3 is None or ediff is None:
|
||
self._stop_pb_state = None
|
||
return None
|
||
dd = -1 if is_long else 1 # Break-Richtung GEGEN den Trade
|
||
gap = (level - bid) * dd # >0 solange Kurs noch vor dem Level
|
||
if gap <= 0 or gap > 0.6 * atr: # weit weg / schon durch → kein Hinweis
|
||
self._stop_pb_state = None
|
||
return None
|
||
wt = 1.0 if ediff * dd > 0 else 0.0
|
||
dist = abs(level - entry) / atr
|
||
p_raw = _p_break(mom6 * dd, mom3 * dd, wt, dist)
|
||
import math as _m
|
||
now = time.time(); st = self._stop_pb_state
|
||
if not st or abs(st["level"] - level) > 1e-9:
|
||
st = {"level": level, "p": p_raw, "ts": now}
|
||
else:
|
||
a = 1.0 - _m.exp(-max(0.0, now - st["ts"]) / 60.0)
|
||
st["p"] += a * (p_raw - st["p"]); st["ts"] = now
|
||
self._stop_pb_state = st
|
||
p_s = st["p"]
|
||
confirm = round(level + dd * 0.5 * atr, 3) # Durchbruch bestätigt ab
|
||
reject = round(level - dd * 0.5 * atr, 3) # Abprall bestätigt ab
|
||
return {"level": round(level, 3),
|
||
"side": ("sup" if is_long else "res"),
|
||
"p_break": round(100 * p_s),
|
||
"at_level": gap <= 0.15 * atr,
|
||
"gap_atr": round(gap / atr, 2),
|
||
"confirm": confirm, "reject": reject}
|
||
except Exception as e:
|
||
log.debug(f"stop_approach_hint: {e}")
|
||
return None
|