User-Fund: "Modul-Konsens: stark LONG · 4/4 Module fuer WARTEN" - dabei sind
9 Module sichtbar. Zwei echte Fehler.
(a) FALSCHER BEZUG: der Text lautete "... Module fuer ${sig}" mit der
HEADLINE als Bezug. Seit dem ref-Fix vom 30.07. zaehlt `agree` bei WARTEN
aber die Uebereinstimmung mit der BIAS-Richtung - deshalb stand dort
"stark LONG ... fuer WARTEN". Die Richtung kommt jetzt als verdict.ref_dir
vom Backend, statt im Frontend aus `bias` nachgebaut zu werden (dort galt
ein +-0,05-Totband, im Backend `bias > 0` - die Nachbildung waere am Rand
auseinandergelaufen; dieselbe Sorte Divergenz wie die fuenf
Deployment-Drift-Faelle desselben Tages).
(b) ANDERER NENNER ALS DIE CHIPS: `total` zaehlt nur Module MIT Aussage
(Gewicht>0, ohne die Welle). Nach den Gewichts-Fixes des Tages schweigen
deutlich mehr Module -> "4/4" bei 9 sichtbaren Chips. Neu ist
verdict.silent (Module mit Gewicht 0) dabei, sodass die Summe aufgeht.
Live verifiziert: "Modul-Konsens: leicht LONG · 3 von 5 stimmberechtigten
fuer LONG (4 ohne Aussage)" - 5 + 4 = 9. Sonderfall "kein Modul mit klarer
Aussage" abgedeckt. v=125.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3059 lines
171 KiB
Python
3059 lines
171 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` wurde am 2026-07-31 hier entfernt (letzter Nutzer war `_sr_levels`,
|
||
# s. dort): die `[zones]`-Config stammte aus einem ~10 $ tieferen Kursregime und
|
||
# lieferte der Wellen-Konfidenz Phantom-Level. Die Funktion bleibt in `core/config.py`
|
||
# und die `[zones]`-Sektion in der ini erhalten — reaktivierbar, falls je gewollt.
|
||
from core.logger import get_logger
|
||
from core.history import HistoryLogger
|
||
from core.mt5data import MT5Data
|
||
from core.trader import TradeManager
|
||
from core.trailing import TrailingManager
|
||
from core.tu_rating import TradersUnionProvider
|
||
from core.wave_rec import WaveRecommender
|
||
from core.elliott import ElliottAnalyzer
|
||
from core.agent import TradingAgent
|
||
from core.news import NewsFetcher, NewsTranslator
|
||
from core.notify import send_telegram
|
||
from core.mailer import send_graph_mail
|
||
from core.mt5_utils import mt5_lock
|
||
from core.market_hours import session_state
|
||
from core.daily_levels import DailyLevels
|
||
from core.gaps import GapAnalyzer
|
||
from core.structure import MarketStructure
|
||
from core.patterns import PatternDetector
|
||
from core.candles import CandleAnatomy
|
||
from core.cone import build as _cone_build
|
||
from core.candle_logger import CandleLogger
|
||
from core.wave_rec import (_atr as _wr_atr, _ema_last,
|
||
_EMA_FAST, _EMA_SLOW, _TREND_DEADBAND, _STRETCH_MAX)
|
||
|
||
log = get_logger("engine")
|
||
|
||
_ATR_TF_MAP = {"M1": mt5.TIMEFRAME_M1, "M5": mt5.TIMEFRAME_M5,
|
||
"M15": mt5.TIMEFRAME_M15, "M30": mt5.TIMEFRAME_M30,
|
||
"H1": mt5.TIMEFRAME_H1}
|
||
|
||
# Loop-Intervalle (Sekunden) — entsprechen den *_REFRESH_MS-Werten des Widgets
|
||
_PRICE_S = 0.5
|
||
_TREND_S = 5.0
|
||
_POS_S = 1.0
|
||
_TU_S = 300.0 # TU ist nur noch Snapshot-Anzeige (aus Empfehlung/Verdict/
|
||
# Copilot entfernt) — 5 min statt 60 s reicht, spart Scrape+Log
|
||
_AGENT_WARMUP = 20.0 # erste Beurteilung erst, wenn Daten geladen sind
|
||
_CLOSE_ALERT_COOLDOWN_S = 300.0 # CLOSE-Telegram max. 1×/5 min (kein Spam, wenn
|
||
# der Kurs ums Signal-Level pendelt)
|
||
_MIN_CONF_DISPLAY = 70 # Konfidenz-Schwelle für die "Order ohne Signal"-
|
||
# Warnung im Frontend (nur Anzeige-Konstante — war
|
||
# bis 2026-07-23 auf dem entfernten Dry-Run-Auto-
|
||
# Trader beheimatet, hier als eigener Wert erhalten)
|
||
_BERLIN = ZoneInfo("Europe/Berlin")
|
||
_OB_IMB_MIN = 0.30 # Orderbuch-Imbalance darunter = zu schwach fuer eine Stimme
|
||
_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)
|
||
_TRADE_MARKS = 6 # so viele letzte Trades im MQL5-Chart markieren
|
||
# (Kerze umranden: Eröffnung hellgrün, Close dunkelgrün)
|
||
_STARTUP_GRACE_S = 60.0 # nach Bot-Start schließt der Bot 60 s lang KEINEN
|
||
# laufenden Trade server-seitig (S/R-Close/Notfall/
|
||
# Time-Stop/Reverse) — die Daten (Preis/ATR/P(break)/
|
||
# Level) stabilisieren sich erst; verhindert den
|
||
# Sofort-Close direkt nach restart_server.bat.
|
||
# Broker-SL bleibt unberührt (läuft am Broker).
|
||
|
||
# ── Kalibriertes P(Durchbruch)-Modell (logistische Regression, auf H1 trainiert,
|
||
# auf H2 KALIBRIERT geprüft: AUC 0,71, predicted≈echt; `analyze_srwaves.py`).
|
||
# Merkmale (in Positionsrichtung): [mom6, mom3, mit-Trend(0/1), Level-Distanz/ATR].
|
||
# Standardisierung (mu/sd) + Gewichte (intercept + 4) aus `backtest_srclose_prob.py`.
|
||
# P(break)-Modell — auf **M30-Level** trainiert (Umstellung 2026-07-30, gemessen
|
||
# `backtest_level_tf.py`): M30-Pivots schlagen die alten M5-Pivots in BEIDEN Hälften
|
||
# (AUC out-of-sample 0,721 vs 0,710 · R-Ertrag der Close-Regel ~2× · Level 1,95 statt
|
||
# 0,61×ATR entfernt = die „S/R-Linien kleben aneinander"-Beobachtung des Users).
|
||
# Ablauf: out-of-sample validiert (H1→H2), danach auf ALLEN 80k Bars nachtrainiert
|
||
# (34.771 Touches, Basisrate 39,4 %, Kalibrierung trifft: 23/25 · 38/36 · 58/59 · 86/87).
|
||
# ⚠ Die Merkmale bleiben M5-basiert (mom6/mom3 auf M5-Bars) — nur die LEVEL sind M30.
|
||
# Alte M5-Werte (falls je zurück): MU (0.7482,0.7036,0.9112,0.7072) ·
|
||
# SD (1.1597,0.9687,0.2844,0.6403) · W (-0.5853,0.1192,0.7820,0.1204,-0.2994).
|
||
# ⚠⚠ NEU TRAINIERT 2026-07-31 auf einer LIVE-SPIEGELNDEN Stichprobe
|
||
# (`backtest_pbreak_retrain.py` + `backtest_pbreak_rvalue.py`). GRUND: die alten
|
||
# Werte waren auf einer Situation trainiert, die live gar nicht vorkommt.
|
||
# Training ALT: Level beim Entry FIXIERT (≥0,3×ATR weg), warten auf den ersten
|
||
# Touch nach einem echten ANLAUF → mom3 ≈ 1,45×ATR (ein Schub)
|
||
# LIVE: `_draw_levels` wählt das nächste Level JEDE SEKUNDE NEU (Hysterese)
|
||
# → der Kurs steht neben einem Level, das gerade erst das nächste
|
||
# wurde, ohne Anlauf → mom3 ≈ 0,15×ATR
|
||
# Gemessen (aus `candles_m1` rekonstruiert, n=2275): mom3 live 0,149 vs. ALT-µ 1,4523
|
||
# (−1,303) · mom6 0,181 vs 1,6565 (−1,476). Der daraus folgende z-Versatz (−1,05)
|
||
# erklärt die gesamte Fehlkalibrierung: das Modell sagte live IMMER ~23 % (real 41 %),
|
||
# lag damit stets unter der Schwelle → **das Gate war seit Inbetriebnahme nie aktiv**
|
||
# (91 % aller Berührungen geschlossen = der 2× verworfene pauschale S/R-Close).
|
||
# KONTROLLE auf der live-spiegelnden Stichprobe: ALT erreicht dort **AUC 0,368**
|
||
# (schlechter als Zufall!), NEU **0,654** out-of-sample (Fit H1 → H2).
|
||
# R-Ertrag (Echtkosten, Baseline = nur Trailing): NEU schlägt ALT in BEIDEN Hälften
|
||
# bei JEDER Schwelle; gegen die Baseline gewinnt es bei 0,35/0,45/0,55 in beiden
|
||
# (bei 0,60 kippt H1). Bestwert **0,35** (H1 +357, H2 +1418 gegen Baseline) →
|
||
# `sr_close_pbreak` entsprechend auf 0,35 gesetzt.
|
||
# ⚠ Das Gewicht von mom3 DREHT das Vorzeichen (+1,4330 → −0,5420): im Anlauf-Kontext
|
||
# hieß Schwung „Level bricht", im Klebe-am-Level-Kontext heißt er „Level hält".
|
||
# Deshalb war ALT live schlechter als eine Münze und nicht bloß nutzlos.
|
||
# Final auf allen 80k Bars gefittet (n=76.542 Events, Basisrate 37,8 %), nachdem
|
||
# H1→H2 out-of-sample validiert war — gleiches Vorgehen wie beim ersten Modell.
|
||
# Alte M30-Anlauf-Werte (Rückweg): MU (1.6565,1.4523,0.9465,1.9133) ·
|
||
# SD (2.5291,2.4177,0.2250,2.1257) · W (-0.3264,0.4719,1.4330,0.0943,-0.1885).
|
||
_PB_MU = (0.1962, 0.2071, 0.5300, 3.2624)
|
||
_PB_SD = (1.3109, 0.9579, 0.4991, 3.5649)
|
||
_PB_W = (-0.5216, 0.0712, -0.5420, 0.0047, 0.0386) # [b0, mom6, mom3, wt, dist]
|
||
|
||
def _p_break(mom6: float, mom3: float, wt: float, dist: float) -> float:
|
||
"""P(Level-Durchbruch) ∈ (0,1) aus den 4 standardisierten Merkmalen."""
|
||
import math
|
||
feat = (mom6, mom3, wt, dist)
|
||
z = _PB_W[0]
|
||
for k in range(4):
|
||
z += _PB_W[k + 1] * (feat[k] - _PB_MU[k]) / _PB_SD[k]
|
||
return 1.0 / (1.0 + math.exp(-max(-30.0, min(30.0, z))))
|
||
|
||
|
||
class TradingEngine:
|
||
"""Headless-Pendant zu OilWidget: hält die core-Objekte, fährt die
|
||
Refresh-Loops als Threads und liefert einen aggregierten Snapshot."""
|
||
|
||
def __init__(self, cfg=None):
|
||
self.cfg = cfg or load_config()
|
||
try:
|
||
set_margin_buffer(float(self.cfg["trading"].get("margin_buffer_pct", "90")))
|
||
except Exception as e:
|
||
log.warning(f"Margin-Buffer aus Config: {e}")
|
||
try:
|
||
set_risk_per_trade(float(self.cfg["trading"].get("risk_pct", "1.5")))
|
||
except Exception as e:
|
||
log.warning(f"Risiko/Trade aus Config: {e}")
|
||
|
||
# ── History + Telegram ────────────────────────────────────────────
|
||
self.history = HistoryLogger(HISTORY_DB_FILE)
|
||
tg = self.cfg["telegram"]
|
||
self.history.configure_telegram(
|
||
enabled = tg.get("enabled", "false").strip().lower() == "true",
|
||
token = tg.get("bot_token", "").strip(),
|
||
chat_id = tg.get("chat_id", "").strip(),
|
||
)
|
||
|
||
# ── 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
|
||
# Kerzen-Anatomie (M5) — REINE ANZEIGE. Die Lehrbuch-Deutung ist gemessen
|
||
# invertiert (`backtest_candles.py`), das Handelssignal fiel durch
|
||
# (`backtest_candle_fade.py`) → kein Verdict-Gewicht, kein Trigger.
|
||
self.candle_anat = CandleAnatomy("M5")
|
||
# Echte Liquiditäts-Wände vom HL-Orderbuch (Anzeige im MT5-Chart, kein Signal).
|
||
# Braucht das HL-Dashboard auf 8001; ist es aus, bleibt der Chart unverändert.
|
||
from core.hl_walls import HLWalls
|
||
_t2 = self.cfg["trading"]
|
||
self.hl_walls = HLWalls(
|
||
url=_t2.get("hl_walls_url", "http://localhost:8001/api/walls"),
|
||
enabled=_t2.get("export_hl_walls", "true").lower() == "true")
|
||
self.candles = CandleLogger(HISTORY_DB_FILE) # M1-Candle-Log (Daten-Sammlung)
|
||
# Wirtschaftskalender (Handbuch-Regel „keine Trades vor High-Impact-News").
|
||
# ⚠ NICHT gemessen (Events zu selten für 2-Stichproben) → sperrt nur die
|
||
# AUTONOMEN Entries + warnt in der Checkliste, blockt keine manuelle Order.
|
||
# Der gemessene EIA-Blackout (Mi 15:30–16:30) bleibt unabhängig davon aktiv.
|
||
from core.econ_calendar import EconCalendar
|
||
self.calendar = EconCalendar(
|
||
before_min=int(_t2.get("news_blackout_before_min", "15")),
|
||
after_min =int(_t2.get("news_blackout_after_min", "15")),
|
||
enabled =_t2.get("news_blackout", "true").lower() == "true")
|
||
# News: eigener Fetch-Loop (versorgt UI /api/news UND Agent-Kontext)
|
||
tcfg = self.cfg["translation"]
|
||
translator = NewsTranslator(
|
||
enabled = tcfg.get("enabled", "true").lower() == "true",
|
||
target = tcfg.get("target_language", "de"),
|
||
provider = tcfg.get("provider", "google"),
|
||
)
|
||
self.news = NewsFetcher(translator=translator)
|
||
self._news_refresh_s = max(60, int(self.cfg["news"].get("refresh_min", "10")) * 60)
|
||
self.agent = TradingAgent(
|
||
self.cfg, data=self.data, trader=self.trader, trail=self.trail,
|
||
tu=self.tu, wave=self.wave, history=self.history,
|
||
news=self.news, elliott=self.elliott)
|
||
# Tägliche WTI-Intraday-Level per Web-Recherche (z.ai/GLM + web_search).
|
||
# Nur Kontext → S/R + Anzeige, NICHT Handelsrichtung.
|
||
zc = self.cfg["zai"] if self.cfg.has_section("zai") else {}
|
||
self.daily = DailyLevels(zc.get("api_key", ""),
|
||
zc.get("base_url", "https://api.z.ai/api/paas/v4"),
|
||
zc.get("model", "glm-4.5-flash"))
|
||
self._daily_last_run: str | None = None # ISO-Datum des letzten Laufs
|
||
self._daily_fails = 0
|
||
self._weekly_last_run = None # (ISO-Jahr, KW) des letzten B4-Reports
|
||
# Versand-Status überlebt Neustarts — sonst schickt jeder restart_server.bat
|
||
# nach 07:30 den Tages-/Wochenreport ERNEUT (war real: 3× am 07.07.)
|
||
import os as _os
|
||
self._report_state_path = _os.path.join(
|
||
_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))),
|
||
"report_state.json")
|
||
self._load_report_state()
|
||
# Kurslücken-Analyse (D1): Fill-Level als S/R-Magnete (Kontext/Konfidenz,
|
||
# keine eigene Handelsrichtung). Refresh gedrosselt (~10 min) im Trend-Loop.
|
||
self.gaps = GapAnalyzer(refresh_s=600)
|
||
|
||
self.require_token = self.cfg["web"].get("require_token", "true").strip().lower() == "true"
|
||
self._tf_select = self.cfg["trading"].get("tf_select", "heuristic").strip()
|
||
self._tf_min = self.cfg["trading"].get("tf_min", "M5").strip().upper()
|
||
if self._tf_min not in _ATR_TF_MAP:
|
||
self._tf_min = "M5"
|
||
# Obergrenze der Heuristik-TF (Daytrading: nur M1–M15). M30/H1 bleiben als
|
||
# Kontext (Filter/Konfluenz), werden aber NICHT mehr als Basis-TF gewählt.
|
||
self._tf_max = self.cfg["trading"].get("tf_max", "H1").strip().upper()
|
||
if self._tf_max not in _ATR_TF_MAP:
|
||
self._tf_max = "H1"
|
||
self._wave_tf_label = "M5"
|
||
# Mindest-Verweildauer der TF-Heuristik (Fix 2026-07-31, s. `_tf_loop`):
|
||
# verhindert das Hin-und-Her, wenn der Score einer TF im Trend auf 0,0 fällt
|
||
# (Überdehnung) und nach dem Rücksetzer zurückspringt. 0 = aus.
|
||
try:
|
||
self._tf_min_dwell_s = float(self.cfg["trading"].get("tf_min_dwell_s", "900"))
|
||
except (TypeError, ValueError):
|
||
self._tf_min_dwell_s = 900.0
|
||
self._tf_last_switch = 0.0
|
||
self._last_rec: dict = {}
|
||
self._agent_fail_cnt = 0
|
||
self._close_alert_ts = 0.0 # letzter CLOSE-Telegram-Push (5-min-Cooldown)
|
||
self._close_alert_ticket = None # Ticket, auf das sich der Cooldown bezieht
|
||
self._close_alert_count = 0 # wie oft der Flip-Close-Alarm gefeuert hat
|
||
self._auto_sr_close = self.cfg["trading"].get("auto_sr_close", "true").lower() == "true"
|
||
try:
|
||
self._sr_close_pbreak = float(self.cfg["trading"].get("sr_close_pbreak", "0.60"))
|
||
except (TypeError, ValueError):
|
||
self._sr_close_pbreak = 0.60
|
||
self._sr_close_pbreak = max(0.30, min(0.90, self._sr_close_pbreak)) # sinnvolles Band
|
||
self._sr_close_count = 0 # wie oft der Bot am S/R automatisch schloss
|
||
self._sr_close_last_ticket = None # verhindert Doppel-Auslösung je Ticket
|
||
# Autonomer Entry auf den gemessen-validierten Squeeze-Ausbruch (nur FLAT).
|
||
# ⚠ ECHTE Order — Default AUS (Autonomie nie stillschweigend an). `[trading]
|
||
# auto_squeeze=true` aktiviert. Dedup über (dir, level) des Ausbruchs.
|
||
self._auto_squeeze = self.cfg["trading"].get("auto_squeeze", "false").lower() == "true"
|
||
self._squeeze_skip_night = self.cfg["trading"].get("auto_squeeze_skip_night", "true").lower() == "true"
|
||
# 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._news_block_logged = None # Dedup für die News-Blackout-Logzeile
|
||
self._pos_close_dir = None # Art) → Squeeze-Wiedereinstiegs-Sperre (gegen die
|
||
# Open→Instant-Close-Kaskade bei zu engem Notfall-Stop)
|
||
self._sr_close_min_gain_armed_ticket = None # Ticket, für das die %-Schwelle schon
|
||
# aus echter Margin berechnet wurde (Retry-Schutz,
|
||
# s. `_check_auto_close`)
|
||
try:
|
||
self._sr_close_min_gain = max(0.0, float(
|
||
self.cfg["trading"].get("sr_close_min_gain", "0")))
|
||
except (TypeError, ValueError):
|
||
self._sr_close_min_gain = 0.0
|
||
# Gewinn-Close automatisch auf X% des Einsatzes (Margin) DIESER Position setzen,
|
||
# sobald sie eröffnet wird (User-Vorgabe 2026-07-23) — überschreibt den manuell
|
||
# gemerkten/persistierten Wert bei jeder ECHT NEUEN Position (nicht beim
|
||
# Wiedererkennen nach Neustart, s. `_check_auto_close`). 0 = aus (dann bleibt der
|
||
# manuell gesetzte/gemerkte Wert wie bisher maßgeblich).
|
||
try:
|
||
self._sr_close_min_gain_pct = max(0.0, float(
|
||
self.cfg["trading"].get("sr_close_min_gain_pct", "1.0")))
|
||
except (TypeError, ValueError):
|
||
self._sr_close_min_gain_pct = 1.0
|
||
self._pb_state = None # geglättetes P(break) + Hysterese-Zone je Level
|
||
self._stop_pb_state = None # dito für die Gegen-/Stop-Seiten-Anzeige
|
||
# (gemessen `analyze_pbreak_flicker.py`: mom3 der
|
||
# laufenden Kerze ließ P ~7–15 Pp je M5-Bar springen)
|
||
# UI-Einstellungen neustart-fest (Fix 2026-07-19): Toggles/Werte, die über
|
||
# die App gesetzt werden (Auto-Squeeze, S/R-Auto-Close, Mindestgewinn),
|
||
# überschreiben nach dem Laden die ini-Defaults — vorher war z. B. ein am
|
||
# Handy gesetzter Mindestgewinn nach jedem restart_server.bat wieder weg.
|
||
import os as _os2
|
||
self._runtime_state_path = _os2.path.join(
|
||
_os2.path.dirname(_os2.path.dirname(_os2.path.abspath(__file__))),
|
||
"runtime_state.json")
|
||
self._load_runtime_state()
|
||
self._mql5_files_dir = None # <terminal>\MQL5\Files (lazy, für den Indikator)
|
||
self._export_trade_marks = self.cfg["trading"].get("export_trade_marks", "false").lower() == "true"
|
||
self._export_mql5 = self.cfg["trading"].get("export_mql5_levels", "true").lower() == "true"
|
||
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.candle_anat.refresh_market(self.data.symbol)
|
||
except Exception as ka: log.warning(f"candles.refresh_market: {ka}")
|
||
try: self.candles.log(self.data.symbol) # M1-Candle-Log (~55 s)
|
||
except Exception as ce: log.warning(f"candles.log: {ce}")
|
||
try: self._evaluate_pbreak_predictions() # ~alle 5 min, self-throttled
|
||
except Exception as pe: log.warning(f"pbreak_predictions: {pe}")
|
||
# Wirtschaftskalender (self-throttled ~1 h, außerhalb des mt5_lock
|
||
# unkritisch — reiner HTTP-Fetch mit Timeout)
|
||
try: self.calendar.maybe_refresh()
|
||
except Exception as ke: log.warning(f"calendar.refresh: {ke}")
|
||
self._run_analysis()
|
||
except Exception as e:
|
||
log.error(f"_trend_loop: {e}", exc_info=True)
|
||
self._sleep(_TREND_S)
|
||
|
||
def _pos_loop(self):
|
||
while self._running:
|
||
try:
|
||
if self.data.symbol:
|
||
self.trader.refresh(self.data.symbol)
|
||
self._check_circuit_breaker() # Tagesverlust-Stopp (Überleben zuerst)
|
||
self._check_auto_close()
|
||
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
|
||
# ── News-Blackout (Handbuch-Regel, NICHT gemessen): 15 min um ein
|
||
# High-Impact-Event (FOMC/NFP/CPI/EIA/OPEC) kein autonomer Entry. Setzt
|
||
# KEINEN Dedup-Marker → nach dem Fenster feuert der Ausbruch, falls noch
|
||
# aktiv. Gilt nur für den Bot; manuelle Orders bleiben frei.
|
||
_bl = self.calendar.blackout()
|
||
if _bl:
|
||
if self._news_block_logged != _bl["title"]:
|
||
self._news_block_logged = _bl["title"]
|
||
log.info(f"📅 News-Blackout — kein Auto-Entry: {_bl['title']} "
|
||
f"({_bl['impact']}, in {_bl['minutes']:.0f} min)")
|
||
return
|
||
self._news_block_logged = None
|
||
|
||
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
|
||
# News-Blackout (s. `_check_auto_squeeze`) — kein Dedup-Marker.
|
||
if self.calendar.blackout():
|
||
return
|
||
ps = self.trader.snapshot()
|
||
if ps.get("ticket"):
|
||
# Position offen: läuft sie in Signalrichtung, gilt die Episode als
|
||
# „schon dabei" (kein Nachkauf); Gegen-Position wird NICHT gedreht
|
||
# (unbelegt — dieselbe Begründung wie beim Squeeze-Reverse).
|
||
pos_dir = "LONG" if ps.get("order_type") == 0 else "SHORT"
|
||
if pos_dir == d:
|
||
self._signal_last_sig = d
|
||
return
|
||
if d == self._signal_last_sig:
|
||
return # diese Episode schon gehandelt
|
||
# Wiedereinstiegs-Sperren (wie beim Squeeze): nach S/R-Close bzw. nach
|
||
# JEDEM Close nicht sofort gleichgerichtet neu eröffnen.
|
||
now = time.time()
|
||
if (d == self._sr_close_dir and now - self._sr_close_ts < _SQUEEZE_SR_COOLDOWN_S):
|
||
return
|
||
if (d == self._pos_close_dir and now - self._pos_close_ts < _SQUEEZE_REENTRY_COOLDOWN_S):
|
||
return
|
||
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,
|
||
# Stufe-2-Telemetrie (2026-07-31): WELCHES Gate blockt gerade?
|
||
# Ohne das war live nicht feststellbar, warum der Bot zu 93 %
|
||
# WARTEN sagt (Backtest-Erwartung ~43 %).
|
||
block=rec.get("block"))
|
||
except Exception as e:
|
||
log.warning(f"log_recommendation: {e}")
|
||
|
||
# Verdict-Stimmen-Logging (2026-07-24, Idee 2b): Modul-Votes 1×/min in die
|
||
# DB (`verdict_votes`), damit die UNBELEGTEN Stimmen (KI-Copilot, Elliott)
|
||
# nach ein paar Wochen datenbasiert bewertet werden können (behalten /
|
||
# Gewicht senken / raus wie TU). Erste Messung (analyze_verdict_calibration.
|
||
# py, 2026-07-24): Copilot nicht robust prädiktiv, n aber klein → sammeln.
|
||
try:
|
||
vd = self._verdict(s, rec, self.wave.snapshot(), self.tu.snapshot(),
|
||
self.agent.snapshot(), self.elliott.snapshot(),
|
||
self.patterns.snapshot())
|
||
vv = {v["name"]: v for v in vd.get("votes") or []}
|
||
def _v(name, key="vote"):
|
||
return (vv.get(name) or {}).get(key, 0)
|
||
self.history.log_verdict_votes(
|
||
headline=vd.get("headline") or "WARTEN",
|
||
conf=int(vd.get("conf") or 0), bias=float(vd.get("bias") or 0.0),
|
||
tf=vd.get("tf") or "", wave=_v("Welle"), m30=_v("M30"), h1=_v("H1"),
|
||
ki=_v("KI-Copilot"), ki_w=float(_v("KI-Copilot", "weight") or 0.0),
|
||
elliott=_v("Elliott"), elliott_w=float(_v("Elliott", "weight") or 0.0),
|
||
squeeze=_v("Squeeze"), news_score=_news,
|
||
# Die drei ab 2026-07-30 ergänzten Stimmen MITLOGGEN — nur so ist in
|
||
# ein paar Wochen datenbasiert entscheidbar, ob sie bleiben (Muster:
|
||
# Kontrolltest zeigt kleinen Zusatz) bzw. ob die UNGEMESSENEN
|
||
# Orderbuch-/Liq-Trend-Stimmen überhaupt Prädiktivität haben.
|
||
pattern=_v("Muster"), pattern_w=float(_v("Muster", "weight") or 0.0),
|
||
orderbook=_v("Orderbuch"),
|
||
orderbook_w=float(_v("Orderbuch", "weight") or 0.0),
|
||
liqtrend=_v("Liq-Trend"),
|
||
liqtrend_w=float(_v("Liq-Trend", "weight") or 0.0))
|
||
except Exception as e:
|
||
log.debug(f"log_verdict_votes: {e}")
|
||
|
||
# Kontext für Trade-Logging (set_open_context beim Öffnen)
|
||
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
|
||
# ── Mindest-Verweildauer (Fix 2026-07-31) ────────────────────
|
||
# Die 1,2×-Hysterese unten ist WIRKUNGSLOS, sobald der Score der
|
||
# aktuellen TF auf exakt 0,0 fällt — und genau das tut er, sobald
|
||
# sie überdehnt ist (`_choose_tf`). Im Trend kippt M5 dadurch
|
||
# regelmäßig raus (Score 0,00) und nach dem nächsten Rücksetzer
|
||
# wieder rein (1,93): real 33 Wechsel/Tag, Median-Abstand 5 min.
|
||
# Zusammen mit dem `_pend`-Reset (dort gefixt) hielt das die
|
||
# Breakout-Bestätigung dauerhaft offen → 93 % WARTEN live gegen
|
||
# ~43 % Backtest-Erwartung. Die Verweildauer gibt einer TF Zeit,
|
||
# ihr Signal überhaupt fertig zu bestätigen. 0 = aus.
|
||
now = time.time()
|
||
dwell_ok = (self._tf_min_dwell_s <= 0 or
|
||
(now - self._tf_last_switch) >= self._tf_min_dwell_s)
|
||
# Hysterese: nur wechseln, wenn klar besser (kein Flattern)
|
||
if dwell_ok and best and best != cur and \
|
||
scores.get(best, 0) > scores.get(cur, 0) * 1.2:
|
||
self._tf_last_switch = now
|
||
self._apply_wave_tf(best, source=f"Heuristik {scores}")
|
||
except Exception as e:
|
||
log.warning(f"_tf_loop: {e}")
|
||
if self._sleep(60):
|
||
return
|
||
|
||
def _load_report_state(self):
|
||
"""Letzte Report-Versanddaten laden (überlebt Neustarts, s. __init__)."""
|
||
try:
|
||
import json
|
||
with open(self._report_state_path, encoding="utf-8") as f:
|
||
st = json.load(f)
|
||
self._daily_last_run = st.get("daily") or None
|
||
wk = st.get("weekly")
|
||
self._weekly_last_run = tuple(wk) if wk else None
|
||
except FileNotFoundError:
|
||
pass
|
||
except Exception as e:
|
||
log.warning(f"report_state laden: {e}")
|
||
|
||
def _save_report_state(self):
|
||
try:
|
||
import json
|
||
with open(self._report_state_path, "w", encoding="utf-8") as f:
|
||
json.dump({"daily": self._daily_last_run,
|
||
"weekly": list(self._weekly_last_run)
|
||
if self._weekly_last_run else None}, f)
|
||
except Exception as e:
|
||
log.warning(f"report_state speichern: {e}")
|
||
|
||
def _load_runtime_state(self):
|
||
"""UI-gesetzte Einstellungen laden (überleben Neustarts). Nur Keys, die
|
||
auch in der Datei stehen, überschreiben die ini-Defaults."""
|
||
try:
|
||
import json
|
||
with open(self._runtime_state_path, encoding="utf-8") as f:
|
||
st = json.load(f)
|
||
if "auto_squeeze" in st:
|
||
self._auto_squeeze = bool(st["auto_squeeze"])
|
||
if "auto_signal" in st:
|
||
self._auto_signal = bool(st["auto_signal"])
|
||
if "auto_sr_close" in st:
|
||
self._auto_sr_close = bool(st["auto_sr_close"])
|
||
if "sr_close_min_gain" in st:
|
||
self._sr_close_min_gain = max(0.0, float(st["sr_close_min_gain"] or 0))
|
||
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)
|
||
# ⚠ [zones]-CONFIG ENTFERNT (2026-07-31, Modul-Audit). Vorher flossen hier
|
||
# beide Kanten jeder `[zones]`-Zone als S/R-Linien ein — 16 Kanten aus einem
|
||
# ~10 $ TIEFEREN Kursregime (Zonen bei 72,90–77,00 bei einem Kurs von 85,3).
|
||
# Aus dem CHART wurden sie schon am 2026-07-14 entfernt, WEIL sie stale sind;
|
||
# in die Wellen-Konfidenz liefen sie aber weiter.
|
||
# Realer Fall beim Audit (Kurs 85,342): `market.sr` hatte GAR KEINE
|
||
# Widerstände (M15-Cluster sind in frischen Trends oft leer) → der nächste
|
||
# „Widerstand" kam aus der stale `fvg`-Zone bei **88,00** (2,66 $ weg),
|
||
# während der echte bei **85,315** lag (0,03 $ weg). Der Konfidenz-Abzug
|
||
# „dicht unter Widerstand" (−12) greift nur innerhalb 0,5×ATR und feuerte
|
||
# deshalb NIE, obwohl er hätte feuern müssen.
|
||
# Entfernen ist KEINE Strategieänderung, sondern das Streichen von Daten aus
|
||
# einem anderen Preisregime: fehlt ein Level, gibt es weder Bonus noch Abzug —
|
||
# das ist ehrlicher als ein falsches Level.
|
||
# ⚠ OFFEN (messpflichtig, NICHT hier gemacht): die Wellen-Konfidenz stattdessen
|
||
# aus `_draw_levels` (M30, dieselbe Quelle wie Chart + S/R-Close) speisen. Das
|
||
# würde den −12-Abzug tatsächlich auslösen → Konfidenz sinkt → das 55%-Gate
|
||
# verschiebt sich. Strategieänderung, gehört gemessen.
|
||
# Tages-Level aus der Morgen-Recherche (Kontext)
|
||
try:
|
||
for lv in self.daily.zone_lines():
|
||
(above if lv > bid else below).append(lv)
|
||
except Exception:
|
||
pass
|
||
# Offene Kurslücken (D1-Gaps) als Fill-Magnete (Kontext/Konfidenz)
|
||
try:
|
||
for lv in self.gaps.zone_lines():
|
||
(above if lv > bid else below).append(lv)
|
||
except Exception:
|
||
pass
|
||
above = sorted(set(round(p, 3) for p in above))
|
||
below = sorted(set(round(p, 3) for p in below), reverse=True)
|
||
return {
|
||
"resistance": above[0] if above else None, # nächster Widerstand (Short)
|
||
"support": below[0] if below else None, # nächste Unterstützung (Long)
|
||
"resistances": above[:3],
|
||
"supports": below[:3],
|
||
}
|
||
|
||
def _draw_levels(self) -> dict:
|
||
"""S/R-Linien fürs ZEICHNEN (PWA-Charts + MQL5-CSV) UND für den
|
||
P(break)-Auto-Close: echte Kursstruktur = rohe **M30**-Pivots
|
||
(`wave.pb_levels30`), geclustert (~0,5×ATR).
|
||
|
||
⚠ **TF-Umstellung M5→M30 am 2026-07-30** (User: „die S/R-Linien liegen oft
|
||
sehr nahe beieinander"), gemessen `backtest_level_tf.py` über 80k M5-Bars in
|
||
2 Halbjahren — M30 gewinnt in BEIDEN: Level-Abstand **1,95 statt 0,61×ATR**
|
||
(nur 28 % statt 71 % näher als 1×ATR), AUC out-of-sample 0,721 vs 0,710,
|
||
R-Ertrag der Close-Regel **rund doppelt** (+12.424/+13.047 vs +6.698/+7.749).
|
||
Das P(break)-Modell (`_PB_MU/_SD/_W`) wurde dafür auf M30-Leveln NACHTRAINIERT.
|
||
Fallback auf die alten M5-Level (`pb_levels`), falls M30 noch nicht da ist.
|
||
⚠ NICHT betroffen: das **Entry-Raum-Gate** nutzt weiter `pb_levels` (M5) —
|
||
`entry_room_atr=0.6` ist darauf kalibriert.
|
||
|
||
Zeichnet **nur die NÄCHSTE je Seite** (2 Linien total,
|
||
deckungsgleich mit den Dashboard-Werten `sr-res`/`sr-sup`) — sonst tauchten
|
||
beim Kurslauf die weiter entfernten Linien laufend auf/ab (User-Feedback
|
||
2026-07-14). Bugfix davor: Linien kamen aus `_sr_levels` (stale [zones]-Config
|
||
+ Tageslevel + Gaps) — Uralt-Level statt aktueller Struktur. Ergebnis wird
|
||
~1 s gecacht (Konsistenz über alle Anzeigen + weniger Lock-Last)."""
|
||
now = time.time()
|
||
dc = self._draw_cache
|
||
if dc and (now - dc[0]) < 1.0:
|
||
return dc[1]
|
||
try:
|
||
ws = self.wave.snapshot() or {}
|
||
# M30-Level (gemessen besser); M5 nur als Fallback beim Kaltstart, bis
|
||
# der erste M30-Turn-Fetch durch ist.
|
||
lv = ws.get("pb_levels30") or ws.get("pb_levels") or {}
|
||
atr = (ws.get("pb_feats") or {}).get("atr") or 0.2
|
||
bid = (self.data.snapshot() or {}).get("bid")
|
||
if not bid:
|
||
return {"res": [], "sup": []}
|
||
# 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 ''}"]
|
||
# P(Durchbruch) je Linie mitgeben (User-Wunsch 2026-07-30) → der Indikator
|
||
# schreibt sie ins Label: „Widerstand 84.536 · Durchbruch 34%".
|
||
# Richtung: Widerstand = Bruch nach OBEN (+1), Support = nach UNTEN (−1);
|
||
# Merkmale identisch zu `_sr_close_hint`/`_stop_approach_hint` (M5-Momentum,
|
||
# `wt` = EMA-Richtung stimmt mit der Bruchrichtung, `dist` = Abstand/ATR).
|
||
# ⚠ Das Modell ist auf GENAU diese M30-Level trainiert (Nachtraining
|
||
# 2026-07-30) — Kalibrierung out-of-sample geprüft (AUC 0,72).
|
||
def _pb_for(level, dd):
|
||
try:
|
||
pf = (self.wave.snapshot() or {}).get("pb_feats") or {}
|
||
atr = pf.get("atr"); m6 = pf.get("mom6"); m3 = pf.get("mom3")
|
||
ed = pf.get("ema_diff")
|
||
b = (self.data.snapshot() or {}).get("bid")
|
||
if not (atr and b) or None in (m6, m3, ed):
|
||
return None
|
||
wt = 1.0 if ed * dd > 0 else 0.0
|
||
return round(_p_break(m6 * dd, m3 * dd, wt, abs(level - b) / atr) * 100)
|
||
except Exception:
|
||
return None
|
||
for p in dl.get("res", []):
|
||
_pb = _pb_for(p, 1)
|
||
lines.append(f"R;{p}" + (f";{_pb}" if _pb is not None else ""))
|
||
for p in dl.get("sup", []):
|
||
_pb = _pb_for(p, -1)
|
||
lines.append(f"S;{p}" + (f";{_pb}" if _pb is not None else ""))
|
||
# Regressionskanal (M30) als Trendlinien-Anker → MQL5 zieht OBJ_TREND
|
||
# (Format CH;<U|M|L>;t1;p1;t2;p2 mit Broker-Zeiten).
|
||
try:
|
||
cl = (self.structure.snapshot() or {}).get("channel_line")
|
||
if cl and cl.get("t1") and cl.get("t2"):
|
||
for kind, key in (("U", "upper"), ("M", "mid"), ("L", "lower")):
|
||
pts = cl.get(key)
|
||
if pts and len(pts) == 2:
|
||
lines.append(f"CH;{kind};{cl['t1']};{pts[0]};{cl['t2']};{pts[1]}")
|
||
except Exception:
|
||
pass
|
||
# Chartmuster-Linien (PT;…) 2026-07-30 auf User-Wunsch ENTFERNT — die
|
||
# Kachel/PWA-Anzeige bleibt, im MT5-Chart wurde es zu voll. Der Indikator
|
||
# (v1.12) hat den PT-Handler ebenfalls nicht mehr.
|
||
# ECHTE Liquiditäts-Wände von Hyperliquid (größtes L2-Level je Seite,
|
||
# basis-korrigiert auf CFD-Niveau) — Format LQ;<B|A>;preis;size.
|
||
# Anders als R/S NICHT aus Pivots geschätzt, sondern tatsächlich ruhende
|
||
# Orders. REINE ANZEIGE (Übertragbarkeit auf den CFD wird gerade gemessen).
|
||
# Format: LQ;<B|A>;preis;size;<U|D>;<1|0> (Pfeil-Richtung = Wirkrichtung
|
||
# der Wand nach der Lesart „sie hält"; letztes Feld 1 = die GRÖSSERE der
|
||
# beiden Wände). Zusätzlich LT;t1;p1;t2;p2;<U|D> = Liquiditäts-Trendlinie
|
||
# (Regression über den volumengewichteten Wand-Schwerpunkt) — Zeiten
|
||
# werden hier in BROKER-Zeit umgerechnet, weil MT5-Charts die brauchen.
|
||
try:
|
||
w = self.hl_walls.walls()
|
||
if w:
|
||
dom = w.get("dominant")
|
||
ab = "U" if (w.get("arrow_bid") == "up") else "D"
|
||
aa = "D" if (w.get("arrow_ask") == "down") else "U"
|
||
lines.append(f"LQ;B;{w['bid']};{w['bid_sz']:.0f};{ab};{1 if dom=='B' else 0}")
|
||
lines.append(f"LQ;A;{w['ask']};{w['ask_sz']:.0f};{aa};{1 if dom=='A' else 0}")
|
||
tr = w.get("trend")
|
||
if tr and tr.get("t1") and tr.get("t2"):
|
||
boff = self.trader._broker_offset_s()
|
||
lines.append(f"LT;{int(tr['t1'])+boff};{tr['p1']};"
|
||
f"{int(tr['t2'])+boff};{tr['p2']};"
|
||
f"{'U' if tr.get('dir')=='up' else 'D'}")
|
||
except Exception:
|
||
pass
|
||
# Liquiditätszonen (Equal Highs/Lows, M30): EQ;<H|L>;lo;hi;<broker_time>
|
||
# → goldenes Band ab dem ersten Pivot, nach rechts verlängert.
|
||
# ⚠ REINE ANZEIGE (als Signal gemessen verworfen, s. backtest_liquidity_sweep).
|
||
try:
|
||
zs = (self.wave.snapshot() or {}).get("liq_zones") or {}
|
||
boff_z = self.trader._broker_offset_s()
|
||
for key, tag in (("eqh", "H"), ("eql", "L")):
|
||
z = zs.get(key)
|
||
if z and z.get("lo") and z.get("hi"):
|
||
# `t` ist ROHE Broker-Zeit aus copy_rates → NICHT nochmal
|
||
# umrechnen (anders als die HL-Trendlinie, die lokal tickt).
|
||
lines.append(f"EQ;{tag};{z['lo']};{z['hi']};{int(z['t'])}")
|
||
except Exception:
|
||
pass
|
||
# KONSOLIDIERUNGS-BOX (M5, dieselbe wie der validierte Squeeze):
|
||
# CO;lo;hi;<0|1> — letztes Feld 1 = komprimiert (≤2,5×ATR, Ausbruch steht
|
||
# bevor), 0 = nur die aktuelle 10-Bar-Spanne. ⚠ REINE ANZEIGE: die
|
||
# Konsolidierungs-SETUPS (False Breakout, Distribution, Accumulation,
|
||
# Retest) sind gemessen ALLE verworfen (`backtest_consolidation.py`);
|
||
# validiert ist nur der Squeeze-AUSBRUCH. Die Box zeigt also, WO gerade
|
||
# gestaut wird — nicht, wohin es geht.
|
||
try:
|
||
_sq = (self.wave.snapshot() or {}).get("squeeze") or {}
|
||
_lo, _hi = _sq.get("box_lo"), _sq.get("box_hi")
|
||
if _lo and _hi and _hi > _lo:
|
||
_tight = 1 if _sq.get("state") in ("armed", "active") else 0
|
||
lines.append(f"CO;{_lo};{_hi};{_tight}")
|
||
except Exception:
|
||
pass
|
||
# Aktueller Kurs für die fette Anzeige oben rechts (User-Wunsch): PX;bid
|
||
try:
|
||
_bid = (self.data.snapshot() or {}).get("bid")
|
||
if _bid:
|
||
lines.append(f"PX;{_bid}")
|
||
except Exception:
|
||
pass
|
||
# WAHRSCHEINLICHKEITS-KEGEL: CN;<minuten>;<lo>;<hi> je Horizont (30/60/120),
|
||
# aufsteigend — der Indikator verbindet sie vom aktuellen Bar aus zu zwei
|
||
# sich öffnenden Pfaden. ⚠ Streuung, NICHT Richtung: der Kegel ist um den
|
||
# aktuellen Kurs zentriert und sagt nur, wie weit der Kurs plausibel läuft.
|
||
# Out-of-sample kalibriert (`analyze_cone.py`), 80 %-Band trifft real ~76 %.
|
||
try:
|
||
_atr5 = float(((self.wave.snapshot() or {}).get("pb_feats") or {})
|
||
.get("atr") or 0.0)
|
||
_cn = _cone_build(_bid, _atr5, bands=(80,))
|
||
for lv in (_cn or {}).get("levels", []):
|
||
lines.append(f"CN;{lv['minutes']};{lv['lo']};{lv['hi']}")
|
||
except Exception:
|
||
pass
|
||
# G/V der offenen Position, direkt unter dem Kurs (User-Wunsch 2026-07-30):
|
||
# PL;wert;waehrung. **NETTO** wie im Dashboard — bei Gewinn abzüglich der
|
||
# WHT-Quellensteuer, die der Broker einbehält (`[trading] wht_pct`); bei
|
||
# Verlust unverändert (da fällt keine Steuer an). Ohne Position keine Zeile
|
||
# → der Indikator löscht das Label.
|
||
try:
|
||
_ps = self.trader.snapshot()
|
||
if _ps.get("ticket") and _ps.get("pnl") is not None:
|
||
_p = float(_ps["pnl"])
|
||
if _p > 0 and self._wht_pct > 0:
|
||
_p *= (1.0 - self._wht_pct / 100.0)
|
||
_cur = (self.data.snapshot() or {}).get("currency") or ""
|
||
lines.append(f"PL;{_p:.2f};{_cur}")
|
||
except Exception:
|
||
pass
|
||
# Trade-Marker: TR;<O|C>;<broker_time> → der Indikator umrandet die Kerze,
|
||
# in der eröffnet (hellgrün) bzw. geschlossen (dunkelgrün) wurde
|
||
# (User-Vorgabe 2026-07-30). Offene Position + die letzten Trades des Tages.
|
||
# ⚠ Per Default AUS (User-Wunsch 2026-07-30: „entferne die Umrandungen
|
||
# für Trade open und close" — kurz nach dem Einbau wieder verworfen).
|
||
# Code bleibt reaktivierbar via `[trading] export_trade_marks=true`;
|
||
# spart im Aus-Zustand die DB-Abfrage im ~5-s-Export-Takt.
|
||
# ⚠ Kein `raise StopIteration` mehr als Sprung (2026-07-31, Audit):
|
||
# StopIteration IST eine Exception und wurde vom breiten `except` unten
|
||
# gefangen — funktionierte, wäre aber sofort fragil, sobald dort jemand
|
||
# Logging ergänzt (Phantom-Fehler bei JEDEM Export-Takt).
|
||
if self._export_trade_marks:
|
||
try:
|
||
boff = self.trader._broker_offset_s()
|
||
marks: list[tuple[str, int]] = []
|
||
ps = self.trader.snapshot()
|
||
if ps.get("ticket") and ps.get("open_time"):
|
||
marks.append(("O", int(float(ps["open_time"]) + boff)))
|
||
for t in (self.history.last_closed_trades(_TRADE_MARKS) or []):
|
||
if t.get("entry_time"):
|
||
marks.append(("O", int(float(t["entry_time"]) + boff)))
|
||
if t.get("exit_time"):
|
||
marks.append(("C", int(float(t["exit_time"]) + boff)))
|
||
for kind, ts in marks[:2 * _TRADE_MARKS + 1]:
|
||
lines.append(f"TR;{kind};{ts}")
|
||
except Exception:
|
||
pass
|
||
# Zonen NICHT mehr aus der (statischen) [zones]-Config — die dynamische
|
||
# Handels-Range zeichnet der Indikator selbst aus den Chart-Kerzen (TF-genau).
|
||
path = os.path.join(self._mql5_files_dir, "sr_levels.csv")
|
||
with open(path + ".tmp", "w", encoding="ascii", errors="ignore") as f:
|
||
f.write("\n".join(lines) + "\n")
|
||
os.replace(path + ".tmp", path)
|
||
except Exception as e:
|
||
log.debug(f"write_levels: {e}")
|
||
|
||
def _verdict(self, market, wave_signal, wave_snap, tu, agent, elliott,
|
||
snap_parts_patterns=None) -> dict:
|
||
"""Gesamtempfehlung aus den Modulen für die Widget-Grafik.
|
||
|
||
Headline = die **validierte Welle** (einziges Modul mit belegtem Edge);
|
||
die übrigen Module bilden einen gewichteten Konsens-Balken. REINE
|
||
ANZEIGE — ändert NICHT die Order-Logik (die hört nur auf die Welle).
|
||
Votes: +1 LONG, −1 SHORT, 0 neutral.
|
||
"""
|
||
def word(v):
|
||
return "auf" if v > 0 else "ab" if v < 0 else "flach"
|
||
|
||
bid = (market or {}).get("bid")
|
||
votes = []
|
||
|
||
def add(name, vote, weight, detail):
|
||
votes.append({"name": name, "vote": int(vote),
|
||
"weight": weight, "detail": detail})
|
||
|
||
# 1) Welle — primär (validierter Edge). ⚠ Bei WARTEN hat sie KEINE Aussage
|
||
# → Gewicht 0 (Fix 2026-07-30). Vorher stand w=3,0 permanent im Nenner und
|
||
# zog die Bias-Nadel dauerhaft zur Mitte — derselbe Logikfehler, der am
|
||
# 2026-07-15 beim Squeeze behoben wurde, hier übersehen. Das wog schwer:
|
||
# gemessen sind ~91 % der letzten 2000 Empfehlungen WARTEN, die Nadel war
|
||
# also fast immer künstlich gedämpft (real: bias 0,312 statt 0,500).
|
||
wsig = (wave_signal or {}).get("signal")
|
||
wv = 1 if wsig == "LONG" else -1 if wsig == "SHORT" else 0
|
||
add("Welle", wv, 3.0 if wv != 0 else 0.0,
|
||
f"{wsig or '—'} · {(wave_signal or {}).get('conf_pct', 0)}%")
|
||
|
||
# 2/3) M30 / H1 — die EMA-Signale, die die Welle tatsächlich nutzt.
|
||
# ⚠ Gewicht 0 OHNE Aussage (Fix 2026-07-31) — dieselbe Regel wie bei Welle,
|
||
# Squeeze, Elliott und KI; bei der Verdict-Überarbeitung am 30.07. hier
|
||
# übersehen. Eine 0-Stimme bedeutet „EMA-Abstand im Totband" = keine Aussage,
|
||
# nicht „neutral" — mit vollem Gewicht im Nenner zog sie die Bias-Nadel
|
||
# dauerhaft zur Mitte.
|
||
# GEMESSEN an 7.074 geloggten Verdicts (`verdict_votes`): **M30 ist in 57,9 %
|
||
# der Fälle ohne Aussage** (H1 nur in 1,8 %) → Ø-Bias-Betrag 0,371 → **0,441**,
|
||
# Median-Verstärkung **1,30×**, in **33,3 %** der Zeilen deutlich stärkere Nadel.
|
||
# ⚠ Anlass war die User-Frage „H1 aus der Gesamtempfehlung entfernen?". Die
|
||
# Antwort ist NEIN — die vermutete Doppelzählung ist gemessen schwach (bei
|
||
# gerichteter Welle stimmt M30 nur zu 14,9 % mit ihr überein, H1 zu 47 %; M30
|
||
# und H1 untereinander nur zu 33,8 %). Der eigentliche Defekt war dieser hier.
|
||
# REINE ANZEIGE — die Order-Logik hängt an der Wellen-Headline, nicht am Bias.
|
||
m30 = int((wave_snap or {}).get("htf_trend", 0) or 0)
|
||
h1 = int((wave_snap or {}).get("h1_trend", 0) or 0)
|
||
add("M30", m30, 1.5 if m30 != 0 else 0.0, word(m30))
|
||
add("H1", h1, 1.5 if h1 != 0 else 0.0, word(h1))
|
||
|
||
# 4) TradersUnion ENTFERNT aus dem Verdict-Konsens (User-Vorgabe 2026-07-06):
|
||
# lagt + nicht backtestbar + Standard-Indikator-Konfluenz = kein Edge. Bleibt
|
||
# nur noch reine Anzeige (Snapshot `tu`), zählt NICHT mehr in Bias/Agree.
|
||
|
||
# 5) KI-Copilot — Gewicht NUR bei gerichteter Aussage (LONG/SHORT).
|
||
# ⚠ GEÄNDERT 2026-07-31 (Modul-Audit): vorher zählte ein explizites "NEUTRAL"
|
||
# als echte Stimme mit Gewicht 1,0. Gemessen an 7.084 Verdicts ist das aber
|
||
# **in 93,1 % der Fälle** der Zustand — also der Normalzustand des Copiloten,
|
||
# kein abgewogenes Urteil (Elliott zeigt dasselbe Muster nur in 1,3 %).
|
||
# Damit hatte ausgerechnet das Modul mit der SCHWÄCHSTEN Beleglage
|
||
# (`analyze_verdict_calibration.py`: nicht robust prädiktiv) den GRÖSSTEN
|
||
# dämpfenden Einfluss auf die Bias-Nadel. Wirkung der Angleichung:
|
||
# Ø|bias| 0,441 → 0,569 (Median-Verstärkung 1,25×, betrifft 79,5 % der Zeilen);
|
||
# kumuliert mit den Fixes von Welle/Squeeze/M30/H1: 0,370 → 0,569 = 1,54×.
|
||
# Der Chip bleibt sichtbar und zeigt „NEUTRAL", er stimmt nur nicht mehr mit ab.
|
||
adv = (agent or {}).get("advisory") or {}
|
||
ab = adv.get("bias")
|
||
av = 1 if ab == "LONG" else -1 if ab == "SHORT" else 0
|
||
add("KI-Copilot", av, 1.0 if av != 0 else 0.0,
|
||
f"{ab or '—'} · {adv.get('confidence', 0)}%")
|
||
|
||
# 6) Elliott/FVG — nur stimmberechtigt, wenn ein Ziel existiert (sonst w=0)
|
||
ev, edet, ew = 0, "—", 0.0
|
||
tgt = (elliott or {}).get("target")
|
||
if tgt is not None and bid:
|
||
ev = -1 if tgt < bid else 1 if tgt > bid else 0
|
||
edet = f"{(elliott or {}).get('wave', '—')} → {tgt:.3f}"
|
||
# ⚠ Gewicht nur bei GERICHTETEM Ziel (2026-07-31, Modul-Audit): liegt das
|
||
# Ziel exakt auf dem Kurs, ist ev=0 — das ist keine Aussage und darf die
|
||
# Nadel nicht dämpfen. Betraf 1,3 % der Zeilen (klein, aber dieselbe Regel
|
||
# wie bei Welle/Squeeze/M30/H1/KI — Konsistenz statt Sonderfall).
|
||
ew = 1.0 if ev != 0 else 0.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 ""))
|
||
|
||
# 8) Chartmuster — User-Vorgabe 2026-07-30 („berücksichtige die Chartmuster
|
||
# in der Gesamtempfehlung, sobald eins erkannt wird"). Gewicht bewusst KLEIN:
|
||
# der fällige KONTROLLTEST (2026-07-30) zeigt, dass die Muster einen zwar in
|
||
# BEIDEN Hälften positiven, aber kleinen Zusatz über einen GENERISCHEN
|
||
# Breakout hinaus liefern — Muster ØR +0,206/+0,328 vs. beliebiger Swing-Bruch
|
||
# +0,184/+0,220 (Delta nur +0,022 in H1, +0,108 in H2). Der Löwenanteil des
|
||
# Ertrags kommt also vom Breakout+Trailing, nicht vom Muster. Deshalb 1,0
|
||
# (wie Elliott/KI) statt 2,0 (Squeeze) — und `forming` zählt nur halb, weil
|
||
# das Muster dort noch nicht bestätigt ist. Ziel-Trefferquote bleibt niedrig
|
||
# (23–26 %), die Stimme sagt also „Richtungstendenz", nicht „Kursziel".
|
||
pv, pdet, pw = 0, "—", 0.0
|
||
try:
|
||
_pats = (snap_parts_patterns or {}).get("patterns") or []
|
||
_p0 = next((m for m in _pats if m.get("dir") in ("bull", "bear")), None)
|
||
if _p0:
|
||
pv = 1 if _p0["dir"] == "bull" else -1
|
||
_conf = _p0.get("status") == "confirmed"
|
||
pw = 1.0 if _conf else 0.5
|
||
pdet = f"{_p0.get('name', '—')} · {'bestätigt' if _conf else 'bildet sich'}"
|
||
except Exception:
|
||
pass
|
||
add("Muster", pv, pw, pdet)
|
||
|
||
# 9/10) ORDERBUCH-Imbalance + LIQUIDITÄTS-TREND (User-Vorgabe 2026-07-30).
|
||
# ⚠ BEIDE sind UNGEMESSEN — anders als alle anderen Stimmen. Die Übertragbar-
|
||
# keit von Hyperliquid-Orderbuchdaten auf den Pepperstone-CFD wird gerade
|
||
# erst erhoben (`analyze_bookflow.py`, ab ~50k Snapshots ≈ 3 Tage erster Blick).
|
||
# ⚠ Der Lead-Lag-Test (2026-07-30, 72 h) zeigt sogar, dass **Pepperstone
|
||
# FÜHRT** (Peak bei k=−1, r=0,525) und HL folgt — HL ist ein Oracle-basierter
|
||
# HIP-3-Perp, also strukturell Follower. Das dämpft die Erwartung erheblich.
|
||
# Deshalb bewusst das KLEINSTE Gewicht im ganzen Verdict (0,5 je Stimme;
|
||
# zum Vergleich: Welle 3,0 · Squeeze 2,0 · Muster/Elliott/KI 1,0).
|
||
# Beide werden in `verdict_votes` mitgeloggt → in ein paar Wochen ist
|
||
# datenbasiert entscheidbar, ob sie bleiben, mehr Gewicht bekommen oder
|
||
# rausfliegen (wie TU 2026-07-06).
|
||
ov, odet, ow = 0, "—", 0.0
|
||
lv, ldet, lw = 0, "—", 0.0
|
||
try:
|
||
w = self.hl_walls.walls()
|
||
if w:
|
||
imb = w.get("imb5")
|
||
if isinstance(imb, (int, float)) and abs(imb) >= _OB_IMB_MIN:
|
||
ov = 1 if imb > 0 else -1
|
||
ow = 0.5
|
||
odet = f"Imbalance {imb:+.2f}"
|
||
elif isinstance(imb, (int, float)):
|
||
odet = f"Imbalance {imb:+.2f} (zu schwach)"
|
||
tr = w.get("trend") or {}
|
||
if tr.get("dir") in ("up", "down"):
|
||
lv = 1 if tr["dir"] == "up" else -1
|
||
lw = 0.5
|
||
ldet = f"{tr['dir']} {tr.get('slope_per_h', 0):+.2f}$/h"
|
||
except Exception:
|
||
pass
|
||
add("Orderbuch", ov, ow, odet)
|
||
add("Liq-Trend", lv, lw, ldet)
|
||
|
||
# Bias nur über stimmberechtigte Module (weight>0); agree/total = wie viele
|
||
# der ANDEREN stimmberechtigten Module die Wellen-Richtung teilen (die Welle
|
||
# zählte sich vorher selbst mit — „3/6 für LONG" war geschönt).
|
||
wsum = sum(v["weight"] for v in votes) or 1.0
|
||
bias = sum(v["weight"] * v["vote"] for v in votes) / wsum
|
||
others = [v for v in votes if v["name"] != "Welle" and v["weight"] > 0]
|
||
# Referenzrichtung für „agree": normal die Welle. Sagt sie WARTEN (91 % der
|
||
# Zeit), gibt es keine Wellen-Richtung — dann zählt die Übereinstimmung mit
|
||
# der BIAS-Richtung (Fix 2026-07-30). Vorher stand dort immer „0/N", obwohl
|
||
# sich die anderen Module durchaus einig waren.
|
||
ref = wv if wv != 0 else (1 if bias > 0 else -1 if bias < 0 else 0)
|
||
agree = sum(1 for v in others if ref != 0 and v["vote"] == ref)
|
||
return {
|
||
"headline": wsig, "conf": (wave_signal or {}).get("conf_pct", 0),
|
||
"tf": (wave_snap or {}).get("tf"), # Zeitebene, für die die Empfehlung gilt
|
||
"bias": round(bias, 3), "agree": agree, "total": len(others),
|
||
# ⚠ `ref_dir` = WORAUF sich `agree` bezieht (Fix 2026-07-31). Bei WARTEN
|
||
# ist das NICHT die Headline, sondern die Bias-Richtung — das Frontend
|
||
# beschriftete es vorher mit der Headline und schrieb dadurch
|
||
# „stark LONG · 4/4 Module für WARTEN". Die Richtung kommt jetzt VON HIER,
|
||
# statt im Frontend aus `bias` nachgebaut zu werden (dort galt ein
|
||
# ±0,05-Totband, hier `bias > 0` — die Nachbildung wäre am Rand
|
||
# auseinandergelaufen; genau die Sorte Divergenz, die heute mehrfach
|
||
# aufgefallen ist).
|
||
"ref_dir": "LONG" if ref > 0 else "SHORT" if ref < 0 else None,
|
||
# Wie viele Module HABEN gerade keine Aussage (Gewicht 0)? Ohne diese Zahl
|
||
# las sich „4/4" widersprüchlich zu den 9 sichtbaren Chips.
|
||
"silent": sum(1 for v in votes if not v["weight"]),
|
||
"votes": votes,
|
||
}
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# SNAPSHOT (ein JSON-fähiger Zustand für /api/snapshot + WebSocket)
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
_SNAP_TTL_S = 0.8 # geteilter Snapshot gilt so lange als frisch
|
||
|
||
def _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 _loss_streak_today(self) -> tuple[int, int]:
|
||
"""(Verlust-Trades heute, Trades heute) — für den Mental-Check der Entry-
|
||
Checkliste („nach 2 Losses Session beenden"). DB-Query ~120 s gecacht."""
|
||
now = time.time()
|
||
if now - getattr(self, "_streak_ts", 0.0) > 120:
|
||
try:
|
||
import datetime as _dt
|
||
t0 = _dt.datetime.now(_BERLIN).replace(
|
||
hour=0, minute=0, second=0, microsecond=0).timestamp()
|
||
with self.history._connect() as conn:
|
||
rows = conn.execute(
|
||
"SELECT pnl FROM trades WHERE exit_time>=? AND pnl IS NOT NULL",
|
||
(t0,)).fetchall()
|
||
self._streak_cache = (sum(1 for r in rows if (r[0] or 0) < 0), len(rows))
|
||
except Exception as e:
|
||
log.debug(f"loss_streak: {e}")
|
||
self._streak_cache = (0, 0)
|
||
self._streak_ts = now
|
||
return getattr(self, "_streak_cache", (0, 0))
|
||
|
||
def _entry_checklist(self, side: str, snap_parts: dict) -> dict:
|
||
"""Prüft die Trade-Setup-Checkliste (User-Vorgabe 2026-07-30) für eine
|
||
geplante Eröffnung und liefert die VERLETZTEN Punkte zurück.
|
||
|
||
Warum server-seitig: die Daten liegen alle hier (TF-Ampel, Struktur, Level,
|
||
News, Tages-P&L, Trade-Historie) — das Frontend soll nur anzeigen.
|
||
|
||
Jeder Punkt: `state` = "fail" (klar dagegen) | "warn" (Vorsicht) | "ok".
|
||
Reine ENTSCHEIDUNGSHILFE — sie blockt nichts (wie der bisherige Dialog),
|
||
macht aber sichtbar, was gegen den Trade spricht. Die Gewichtung ist NICHT
|
||
aus einem Backtest, sondern aus (a) den im Projekt GEMESSENEN Leckagen
|
||
(Handeln ohne Signal, Nacht/teure Stunden, Verlust-Serien) und (b) der
|
||
vom User vorgegebenen Checkliste."""
|
||
want = "LONG" if side == "long" else "SHORT"
|
||
d = 1 if want == "LONG" else -1
|
||
items: list[dict] = []
|
||
add = lambda k, st, lab, det: items.append(
|
||
{"key": k, "state": st, "label": lab, "detail": det})
|
||
|
||
wave = snap_parts.get("wave") or {}
|
||
market = snap_parts.get("market") or {}
|
||
# ── 1. Signal-Deckung (die gemessene Kern-Leckage) ───────────────────
|
||
sig = (wave.get("signal") or "WARTEN") if isinstance(wave.get("signal"), str) \
|
||
else ((wave.get("signal") or {}).get("signal") or "WARTEN")
|
||
if sig == want:
|
||
add("signal", "ok", "Empfehlung deckt den Trade", f"Signal {sig}")
|
||
elif sig in ("WARTEN", "—", None):
|
||
add("signal", "fail", "Keine Empfehlung (WARTEN)",
|
||
"gemessen: Trades ohne Signal sind die größte Verlustquelle")
|
||
else:
|
||
add("signal", "fail", f"Empfehlung steht GEGEN dich ({sig})",
|
||
"gemessen: 40 % Trefferquote gegen das Signal")
|
||
# ── 2. HTF-Trend klar? (Checklisten-Punkt 1) ─────────────────────────
|
||
turns = wave.get("tf_turns") or {}
|
||
htf = [turns.get(t, {}).get("dir") if isinstance(turns.get(t), dict)
|
||
else turns.get(t) for t in ("M30", "H1")]
|
||
htf = [x for x in htf if x in (1, -1, 0)]
|
||
if htf and all(x == d for x in htf):
|
||
add("htf", "ok", "HTF-Trend (M30+H1) trägt die Richtung", "")
|
||
elif htf and any(x == -d for x in htf):
|
||
add("htf", "fail", "HTF-Trend steht dagegen (M30/H1)",
|
||
"deine Regel: nur MIT dem HTF-Trend, niemals dagegen")
|
||
else:
|
||
add("htf", "warn", "HTF-Trend unklar/seitwärts", "kein klarer M30/H1-Trend")
|
||
# ── 3. Entry-Raum bis zum Gegenlevel (gemessen monoton) ──────────────
|
||
try:
|
||
dl = self._draw_levels()
|
||
atr = float((wave.get("pb_feats") or {}).get("atr") or 0.0) or 0.2
|
||
bid = market.get("bid")
|
||
tgt = (dl.get("res") or [None])[0] if d > 0 else (dl.get("sup") or [None])[0]
|
||
if bid and tgt:
|
||
room = abs(tgt - bid) / atr
|
||
if room < 0.6:
|
||
add("room", "fail", f"Kein Raum: Level nur {room:.1f}×ATR entfernt",
|
||
"gemessen: <0,6×ATR Raum = negativer Erwartungswert")
|
||
elif room < 1.0:
|
||
add("room", "warn", f"Wenig Raum ({room:.1f}×ATR bis zum Level)", "")
|
||
else:
|
||
add("room", "ok", f"Raum bis zum Level: {room:.1f}×ATR", "")
|
||
except Exception:
|
||
pass
|
||
# ── 4. News-Konflikt (Checklisten-Punkt 3) ───────────────────────────
|
||
ns = snap_parts.get("news_sentiment")
|
||
if isinstance(ns, (int, float)) and abs(ns) >= 0.5:
|
||
if (ns > 0) != (d > 0):
|
||
add("news", "fail",
|
||
f"News-Flow steht dagegen ({ns:+.2f})",
|
||
"stark bullische/bärische Schlagzeilen gegen deine Richtung")
|
||
else:
|
||
add("news", "ok", f"News-Flow stützt ({ns:+.2f})", "")
|
||
# ── 5. Kosten (Spread/ATR — gemessen Ø 0,265) ────────────────────────
|
||
cr = snap_parts.get("cost_ratio")
|
||
if isinstance(cr, (int, float)) and cr > 0:
|
||
if cr >= 0.32:
|
||
add("cost", "fail", f"Kosten hoch ({cr:.2f}×ATR)",
|
||
"gemessen: ab ~0,32×ATR frisst der Spread den Edge")
|
||
elif cr >= 0.20:
|
||
add("cost", "warn", f"Kosten erhöht ({cr:.2f}×ATR)", "")
|
||
else:
|
||
add("cost", "ok", f"Kosten günstig ({cr:.2f}×ATR)", "")
|
||
# ── 6. Mental-Check: Verlust-Serie heute (Checklisten-Punkt 5) ───────
|
||
losses, total = self._loss_streak_today()
|
||
day_total = float((snap_parts.get("day_pl") or {}).get("total") or 0.0)
|
||
if losses >= 2:
|
||
bal = float(self.data.balance or 0.0)
|
||
pct = (abs(day_total) / bal * 100.0) if (bal and day_total < 0) else 0.0
|
||
add("streak", "fail",
|
||
f"Heute schon {losses} Verluste ({total} Trades, {day_total:+.0f} €"
|
||
+ (f" = {pct:.0f}% des Kontos)" if pct else ")"),
|
||
"deine Regel: nach 2 Losses Session beenden")
|
||
elif day_total < 0:
|
||
add("streak", "warn", f"Tag im Minus ({day_total:+.0f} €)", "")
|
||
else:
|
||
add("streak", "ok", f"Tagesbilanz {day_total:+.0f} €", "")
|
||
# ── 7. Nacht-/Kostenfalle (gemessen: 0–7 Uhr Berlin) ─────────────────
|
||
try:
|
||
hb = datetime.now(_BERLIN).hour
|
||
if hb in _SQUEEZE_NIGHT:
|
||
add("hour", "fail", f"Nacht-Stunde ({hb}:00 Berlin)",
|
||
"gemessen: 0–7 Uhr = Kostenfalle (Spread ÷ dünner ATR)")
|
||
except Exception:
|
||
pass
|
||
# ── 8. Wirtschaftskalender (Handbuch: „keine Trades vor High-Impact-News")
|
||
# ⚠ NICHT gemessen (zu wenige Events für eine 2-Stichproben-Aussage) —
|
||
# deshalb hier nur „warn", nicht „fail": es soll den Verdict nicht allein
|
||
# auf `stop` kippen. Der Bot selbst pausiert im Fenster (autonome Entries).
|
||
try:
|
||
bl = self.calendar.blackout()
|
||
if bl:
|
||
m = bl["minutes"]
|
||
when = (f"in {m:.0f} min" if m > 0 else f"vor {abs(m):.0f} min")
|
||
add("event", "warn", f"News-Fenster: {bl['title']} ({when})",
|
||
"Handbuch-Regel: 15 min um High-Impact-Events nicht handeln "
|
||
"(im Projekt nicht gegengemessen)")
|
||
else:
|
||
nx = self.calendar.next_event()
|
||
if nx and nx["minutes"] <= 90:
|
||
add("event", "ok",
|
||
f"Nächstes Event: {nx['title']} in {nx['minutes']} min", "")
|
||
except Exception:
|
||
pass
|
||
fails = sum(1 for x in items if x["state"] == "fail")
|
||
warns = sum(1 for x in items if x["state"] == "warn")
|
||
return {"side": want, "items": items, "fails": fails, "warns": warns,
|
||
"verdict": ("stop" if fails >= 2 else "warn" if (fails or warns) else "ok")}
|
||
|
||
def snapshot_cached(self) -> dict:
|
||
"""Teilt EINEN frisch berechneten Snapshot über alle WS-Clients/HTTP-
|
||
Aufrufe (TTL ~0,8 s). Spart SQLite/Verdict-Last bei mehreren Geräten —
|
||
unabhängig von der Client-Anzahl wird nur ~1×/TTL gerechnet."""
|
||
now = time.time()
|
||
with self._snap_lock:
|
||
if self._snap_cache and (now - self._snap_cache_ts) < self._SNAP_TTL_S:
|
||
return self._snap_cache
|
||
snap = self.snapshot() # teuer → außerhalb des Locks rechnen
|
||
with self._snap_lock:
|
||
self._snap_cache = snap
|
||
self._snap_cache_ts = time.time()
|
||
return snap
|
||
|
||
def _fetch_m5_monitor(self, days: int = 30):
|
||
"""M5-Bars für den Squeeze-Monitor (unter mt5_lock, aus der Server-Instanz)."""
|
||
import MetaTrader5 as mt5
|
||
from core.mt5_utils import mt5_lock
|
||
sym = self.data.symbol
|
||
if not sym:
|
||
return None
|
||
with mt5_lock(timeout=5) as got:
|
||
if not got:
|
||
return None
|
||
bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, days * 288 + 30)
|
||
si = mt5.symbol_info(sym)
|
||
point = si.point if si else 0.01
|
||
if bars is None or len(bars) == 0:
|
||
return None
|
||
return {"H": [float(b["high"]) for b in bars], "L": [float(b["low"]) for b in bars],
|
||
"C": [float(b["close"]) for b in bars],
|
||
"SP": [float(b["spread"]) * point for b in bars]}
|
||
|
||
def _evaluate_pbreak_predictions(self, window_min: int = 60):
|
||
"""Wertet offene Abprall/Durchbruch-Vorhersagen anhand von `candles_m1` aus —
|
||
DIESELBE Definition wie beim Modell-Training (`_BRK_ATR=0,5`/`_BRK_W=12` M5-
|
||
Bars = 60 min in `backtest_srclose_prob.py`): erreicht der Kurs zuerst
|
||
`confirm_price` → 'break', zuerst `reject_price` → 'bounce', keins von beiden
|
||
binnen 60 min → 'bounce' (Timeout, wie im Training). Self-throttled (~5 min),
|
||
aus `_trend_loop`. `candles_m1.time` ist rohe Broker-Zeit — Offset nötig."""
|
||
now = time.time()
|
||
if now - getattr(self, "_pbreak_eval_ts", 0.0) < 300:
|
||
return
|
||
self._pbreak_eval_ts = now
|
||
try:
|
||
import sqlite3
|
||
off = self.trader._broker_offset_s()
|
||
con = sqlite3.connect(HISTORY_DB_FILE, timeout=5.0)
|
||
con.row_factory = sqlite3.Row
|
||
pend = con.execute(
|
||
"SELECT id, ts, direction, predicted, confirm_price, reject_price "
|
||
"FROM pbreak_predictions WHERE outcome IS NULL"
|
||
).fetchall()
|
||
for r in pend:
|
||
d = 1 if r["direction"] == "LONG" else -1
|
||
t0_b = r["ts"] + off # Vorhersage-Zeit in Broker-Zeit
|
||
t1_b = t0_b + window_min * 60
|
||
bars = con.execute(
|
||
"SELECT h, l, time FROM candles_m1 WHERE time > ? AND time <= ? "
|
||
"ORDER BY time ASC", (t0_b, t1_b)).fetchall()
|
||
outcome = None
|
||
for b in bars:
|
||
hit_confirm = (b["h"] >= r["confirm_price"]) if d > 0 else (b["l"] <= r["confirm_price"])
|
||
hit_reject = (b["l"] <= r["reject_price"]) if d > 0 else (b["h"] >= r["reject_price"])
|
||
if hit_confirm:
|
||
outcome = "break"; break
|
||
if hit_reject:
|
||
outcome = "bounce"; break
|
||
if outcome is None and time.time() >= (r["ts"] + window_min * 60):
|
||
outcome = "bounce" # Timeout, wie im Training (brk=False)
|
||
if outcome:
|
||
correct = 1 if r["predicted"] == outcome else 0
|
||
con.execute(
|
||
"UPDATE pbreak_predictions SET outcome=?, outcome_ts=?, correct=? WHERE id=?",
|
||
(outcome, int(time.time()), correct, r["id"]))
|
||
con.commit(); con.close()
|
||
except Exception as e:
|
||
log.debug(f"pbreak_predictions auswerten: {e}")
|
||
|
||
def 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)
|
||
# einmal holen — Verdict-Stimme UND Snapshot-Feld nutzen dasselbe Objekt
|
||
_pat_snap = self.patterns.snapshot()
|
||
snap = {
|
||
"ts": time.time(),
|
||
"connected": self._connected,
|
||
"running": self._running,
|
||
"error": self._error,
|
||
"symbol": self.data.symbol,
|
||
"wave_tf": self._wave_tf_label,
|
||
"min_conf": _MIN_CONF_DISPLAY,
|
||
"auth_required": self.require_token,
|
||
"market": market,
|
||
# Dashboard-S/R = GLEICHE Quelle wie die Chart-Linien (`_draw_levels` =
|
||
# rohe M5-Pivots), damit Kachel und Chart nicht mehr auseinanderlaufen.
|
||
# `_sr_levels` (Misch-Set) bleibt intern für Strategie/Konfidenz.
|
||
"sr_levels": self._dash_sr(market),
|
||
"session": session_state(),
|
||
"position": position,
|
||
"day_pl": day_pl,
|
||
"circuit_breaker": circuit_breaker,
|
||
"emergency_loss": self._emergency_loss,
|
||
"emergency_remember": self._emergency_remember,
|
||
"emergency_pct": self._emergency_pct,
|
||
"emergency_margin_pct": self._emergency_margin_pct,
|
||
"wht_pct": self._wht_pct,
|
||
"takeprofit": self._takeprofit,
|
||
"takeprofit_remember": self._takeprofit_remember,
|
||
"trail": self.trail.snapshot(),
|
||
"wave_signal": wave_signal,
|
||
"wave": wave_snap,
|
||
"elliott": elliott,
|
||
"tu": tu,
|
||
"agent": agent,
|
||
"verdict": self._verdict(market, wave_signal, wave_snap,
|
||
tu, agent, elliott, _pat_snap),
|
||
"daily_levels": self.daily.snapshot(),
|
||
"gaps": self.gaps.snapshot(),
|
||
"structure": self.structure.snapshot(),
|
||
"patterns": _pat_snap,
|
||
# Kerzen-Anatomie (M5) — reine Anzeige, KEIN Verdict-Gewicht (gemessen:
|
||
# Lehrbuch-Deutung invertiert, Handelssignal durchgefallen).
|
||
"candle_anat": self.candle_anat.snapshot(),
|
||
# Wahrscheinlichkeits-Kegel (Streuung, NICHT Richtung) — out-of-sample
|
||
# kalibriert (`analyze_cone.py`). Reine Anzeige.
|
||
"cone": _cone_build(
|
||
(market or {}).get("bid"),
|
||
float(((wave_snap or {}).get("pb_feats") or {}).get("atr") or 0.0)),
|
||
"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,
|
||
# Wirtschaftskalender: {blackout, next, enabled, n} — Anzeige + Grund,
|
||
# warum der Bot gerade keine autonome Order eröffnet.
|
||
"calendar": self.calendar.snapshot(),
|
||
}
|
||
# ── Entry-Checkliste je Richtung (User-Vorgabe 2026-07-30: „blende einen
|
||
# Hinweis ein, wenn die Checkliste gegen meine Eröffnung ist"). Wird im
|
||
# Order-Dialog angezeigt; blockt NICHT. Nutzt nur bereits berechnete Teile
|
||
# → kein zusätzlicher MT5-/DB-Zugriff im Snapshot-Pfad.
|
||
try:
|
||
_parts = {"wave": wave_snap, "market": market,
|
||
"news_sentiment": snap.get("news_sentiment"),
|
||
"cost_ratio": snap.get("cost_ratio"), "day_pl": day_pl}
|
||
snap["checklist"] = {"long": self._entry_checklist("long", _parts),
|
||
"short": self._entry_checklist("short", _parts)}
|
||
except Exception as e:
|
||
log.debug(f"entry_checklist: {e}")
|
||
snap["checklist"] = None
|
||
return snap
|
||
|
||
def _sr_close_hint(self, market, position, wave_snap) -> dict | None:
|
||
"""S/R-Close-Empfehlung mit kalibriertem P(Durchbruch)-Modell (gemessen
|
||
`backtest_srclose_prob.py`, Echtkosten, 2 Hälften): Kurs AM gegenüberliegenden
|
||
Level → P(break) aus `_p_break` schätzen; ist P < `sr_close_pbreak` (Default
|
||
0,60 = User-Regel „laufen lassen wenn >60 %"), prallt das Level wahrscheinlich
|
||
ab → Close schlägt Laufenlassen deutlich (+6000/+7600 R vs. reines Trailing).
|
||
Liefert IMMER die P(break)-Zahl (auch wenn ≥Schwelle → `close=False`) für die
|
||
Live-Anzeige."""
|
||
try:
|
||
if not position.get("ticket"):
|
||
self._pb_state = None
|
||
return None
|
||
is_long = position.get("order_type") == 0
|
||
bid = (market or {}).get("bid")
|
||
# Features FEST aus M5 (`wave.pb_feats`) — Modell wurde auf M5 trainiert;
|
||
# die Wave-TF-Werte (bis M30) verschoben die Skala → P falsch (Bugfix).
|
||
pf = (wave_snap or {}).get("pb_feats") or {}
|
||
atr = pf.get("atr")
|
||
mom6 = pf.get("mom6")
|
||
mom3 = pf.get("mom3")
|
||
ediff = pf.get("ema_diff")
|
||
entry = position.get("entry_price") or bid
|
||
# Level = EXAKT die gezeichnete Linie (`_draw_levels`, geclustert +
|
||
# Hysterese) — für LONG die nächste Resistance, für SHORT die nächste
|
||
# Support. So referenziert die Close-Meldung dasselbe Level, das im
|
||
# Chart/Dashboard steht (vorher rohe pb_levels → Meldung ≠ Linie,
|
||
# User-Feedback 2026-07-15). P(break) auf M5-Features unverändert.
|
||
dl = self._draw_levels()
|
||
if is_long:
|
||
level = (dl.get("res") or [None])[0]
|
||
else:
|
||
level = (dl.get("sup") or [None])[0]
|
||
if not (bid and atr and level) or mom6 is None or mom3 is None or ediff is None:
|
||
self._pb_state = None
|
||
return None
|
||
d = 1 if is_long else -1
|
||
gap = (level - bid) * d # Abstand zum Level (in Preis)
|
||
if gap > 0.6 * atr: # weit weg → gar kein Hinweis
|
||
self._pb_state = None
|
||
return None
|
||
wt = 1.0 if ediff * d > 0 else 0.0
|
||
dist = abs(level - entry) / atr
|
||
p_raw = _p_break(mom6 * d, mom3 * d, wt, dist)
|
||
# ── Anti-Flattern (gemessen `analyze_pbreak_flicker.py`) ─────────────
|
||
# (1) P über ~60 s glätten (Zeit-EMA) — mom3 der laufenden M5-Kerze ließ
|
||
# P sonst 7–15 Pp je Bar springen. Reset bei Level-Wechsel.
|
||
# (2) Hysterese ±5 Pp um die Schwelle: close↔laufen wechselt erst, wenn
|
||
# das geglättete P die Schwelle klar durchstößt (kein Flip-Flop).
|
||
import math as _m
|
||
now = time.time()
|
||
st = self._pb_state
|
||
if not st or abs(st["level"] - level) > 1e-9:
|
||
st = {"level": level, "p": p_raw, "ts": now, "zone": None}
|
||
else:
|
||
a = 1.0 - _m.exp(-max(0.0, now - st["ts"]) / 60.0)
|
||
st["p"] += a * (p_raw - st["p"])
|
||
st["ts"] = now
|
||
p_s = st["p"]
|
||
thr = self._sr_close_pbreak
|
||
zone = st.get("zone")
|
||
if zone is None:
|
||
zone = "close" if p_s < thr else "run"
|
||
elif zone == "close" and p_s >= thr + 0.05:
|
||
zone = "run"
|
||
elif zone == "run" and p_s < thr - 0.05:
|
||
zone = "close"
|
||
st["zone"] = zone
|
||
at_level = gap <= 0.15 * atr # Auslöse-Fenster (wie gemessen: Touch)
|
||
# Bruch-BESTÄTIGUNGS-Preis (Fix 2026-07-22, User-Frage „genauer berechnen"):
|
||
# dieselbe Definition wie beim TRAINING des Modells (`_BRK_ATR=0,5`,
|
||
# `backtest_srclose_prob.py`/`analyze_srwaves.py`) — ein Kurs, der die reine
|
||
# Levelzahl nur um wenige Cent überschreitet (Docht/Rauschen), zählt NICHT
|
||
# als „Durchbruch"; erst level±0,5×ATR gilt als bestätigt. Reale Analyse
|
||
# 2026-07-22: Trade bei Level 85,943 geschlossen, Kurs wischte kurz auf
|
||
# 85,994 (< Bestätigung 86,017), fiel danach zurück → laut Modell-eigener
|
||
# Definition eine ABLEHNUNG, kein Bruch — die Vorhersage war korrekt.
|
||
confirm = round(level + d * 0.5 * atr, 3) # Richtung Durchbruch
|
||
reject = round(level - d * 0.5 * atr, 3) # Richtung Abprall (Gegenbestätigung)
|
||
# Prognose-Logging (User-Wunsch 2026-07-23): EINMAL je frischem Touch
|
||
# (at_level False→True) die aktuelle Abprall/Durchbruch-Vorhersage
|
||
# protokollieren. Auswertung separat gegen `candles_m1`
|
||
# (`_evaluate_pbreak_predictions`, periodisch im Trend-Loop) — dieselbe
|
||
# confirm/reject-Definition wie beim Modell-Training, also fair verglichen.
|
||
if at_level and not st.get("was_at_level"):
|
||
try:
|
||
self.history.log_pbreak_prediction(
|
||
symbol=self.data.symbol or "",
|
||
direction=("LONG" if is_long else "SHORT"),
|
||
level=level, p_break=round(100 * p_s, 1),
|
||
predicted=("bounce" if zone == "close" else "break"),
|
||
price_at_pred=bid, confirm_price=confirm,
|
||
reject_price=reject, atr=atr)
|
||
except Exception as le:
|
||
log.debug(f"pbreak_prediction log: {le}")
|
||
st["was_at_level"] = at_level
|
||
self._pb_state = st
|
||
return {"level": round(level, 3), "p_break": round(100 * p_s),
|
||
"mom": round(mom3 * d, 2), "at_level": at_level,
|
||
"gap_atr": round(gap / atr, 2), "confirm": confirm,
|
||
# Auto-Close NUR exakt am Level; die Anzeige (0,15–0,6×ATR davor)
|
||
# ist reine Vorwarnung mit geglättetem P(break)
|
||
"close": at_level and zone == "close"}
|
||
except Exception as e:
|
||
log.debug(f"sr_close_hint: {e}")
|
||
return None
|
||
|
||
def _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
|