""" core/trader.py — TradeManager ============================== Verwaltet offene Positionen, sendet Market-Orders an MT5, loggt Trades in die HistoryLogger-DB. """ from __future__ import annotations import threading import time from typing import TYPE_CHECKING import MetaTrader5 as mt5 from core.config import ( DEVIATION, MAGIC, SL_BUFFER_TICKS, INIT_SL_FALLBACK, INIT_TP_RR, INIT_SL_MIN_ATR, INIT_SL_MAX_ATR, get_risk_per_trade, get_manual_margin, ) from core.mt5_utils import ( mt5_lock, get_tick, get_filling, calc_lots, calc_lots_risk, pivot_low, pivot_high, atr_value, ) from core.logger import get_logger # ⚠ NUR für die Typ-Annotation `'HistoryLogger | None'` (String, wird zur Laufzeit # nie ausgewertet). Unter TYPE_CHECKING importiert = kein Laufzeit-Import und damit # auch kein Zirkel-Risiko. Vorher meldete pyflakes hier zu Recht einen undefinierten # Namen — und eine Prüfung, die dauerhaft einen bekannten Treffer ausgibt, wird # irgendwann ignoriert. if TYPE_CHECKING: # pragma: no cover from core.history import HistoryLogger log_trade = get_logger("trade") log_hist = get_logger("hist") # Klartext für die häufigsten MT5-Order-Retcodes (statt „retcode=10027") _RETCODE_MSG = { 10004: "Requote — Preis hat sich bewegt, nochmal", 10006: "Order abgelehnt", 10013: "Ungültige Anfrage", 10014: "Ungültiges Volumen (Lots)", 10015: "Ungültiger Preis", 10016: "Ungültiger SL/TP", 10017: "Handel deaktiviert", 10018: "Markt geschlossen", 10019: "Nicht genug Geld / Margin", 10020: "Preis verändert — nochmal", 10021: "Kein Preis (Markt zu / kein Tick)", 10024: "Zu viele Anfragen — kurz warten", 10026: "Algo-Handel SERVERSEITIG aus (Broker)", 10027: "⚠ Algo-Trading im MT5-Terminal AUS — 'Algo Trading'-Button aktivieren!", 10030: "Ungültiger Füllmodus", 10031: "Keine Verbindung zum Handelsserver", } def _retcode_msg(res) -> str: rc = res.retcode if res else None return _RETCODE_MSG.get(rc, f"Order-Fehler (retcode={rc})") # ── Broker-Zeitzone ─────────────────────────────────────────────────────────── # Pepperstone liefert Broker-Zeit UTC+3 (ganzjährig) — dieselbe Konstante nutzt # `core/gaps.py` nutzt dieselbe Konstante fuer die Datums-Zuordnung. _BROKER_OFF_DEFAULT_S = 3 * 3600 # Nur ein FRISCHER Tick darf den Offset (neu) bestimmen. Begründung s. # `_broker_offset_s` — die Schwelle liegt unter dem halben Rundungsraster # (1800/2 = 900 s), damit die 30-min-Rundung immer auf den echten Offset fällt. _BROKER_TICK_FRESH_S = 900 class TradeManager: def __init__(self): self.ticket = self.order_type = None self.entry_price = self.lots = self.pnl = self.cur_price = 0.0 self.sl = self.tp = self.margin = 0.0 self.open_time = 0.0 # Öffnungszeit als ECHTE Epoch (Broker-Offset korrigiert) self.symbol = None # Zuletzt als GÜLTIG erkannter Broker-Offset (s. `_broker_offset_s`). # Vorbelegt mit der bekannten Zeitzone, damit auch ein Start bei # geschlossenem Markt (Wochenende) sofort richtig rechnet. self._boff = _BROKER_OFF_DEFAULT_S self._boff_logged = None self.last_error = "" self._lock = threading.Lock() self.history: 'HistoryLogger | None' = None self._open_context: dict = {} self._swap: float = 0.0 self._commission: float = 0.0 self._tick_size: float | None = None self._tick_value: float | None = None self._si_cache: object = None self._si_cache_ts: float = 0.0 def _calc_sl_tp(self, sym, otype, entry_price, atr_tf=None): """`atr_tf` = Zeitebene fuer den ATR-Deckel. None → `SL_TF` (M15, Default). ⚠ SQUEEZE-Trades bekommen seit 2026-08-05 **M5** (User-Entscheidung nach `backtest_sl_basis.py`): der Ertrag ist dort ein Wash (alle Varianten innerhalb eines Standardfehlers), aber der Worst-Case sinkt von −8,83 auf −2,44×ATR_M5 — Faktor 3. Auf der WELLEN-Population ist der weite M15-SL dagegen in beiden Halbjahren besser, deshalb bleibt er dort Default. Die Multiplikatoren (1,8–2,2×ATR) sind unveraendert — nur die Zeitebene, aus der der ATR kommt, wechselt. ⚠⚠ DER TP IST DAVON NICHT BETROFFEN — und das ist Absicht (User-Vorgabe 2026-08-05: „nur fuer SL, der TP soll mit dem Runner mitwachsen"). Der hier zurueckgegebene `tp` wird von KEINEM Aufrufer gesendet: sowohl `_send_locked` als auch `place_stop` setzen ausschliesslich `req["sl"]`. Das Ziel verwaltet `core/trailing.py` dynamisch (`_TP_INIT_ATR`=3,5 x ATR der Wellen-Zeitebene) und zieht es mit dem Lauf nach. Wer hier je den TP mit in die Order legt, koppelt ihn an die SL-Breite und kappt damit genau die Runner, von denen der Squeeze lebt (43 % Treffer, Ertrag im Tail).""" si = mt5.symbol_info(sym) if not si: return 0.0, 0.0, "?" buf = SL_BUFFER_TICKS * (si.trade_tick_size or si.point) # Risiko-Deckel: SL-Distanz max. INIT_SL_MAX_ATR × ATR(M15). # Pivot-SLs lagen teils ~80 Pips weg → Einzelverluste -30..-40 € # bei Durchschnittsgewinnen von ~+4 €. atr = atr_value(sym, atr_tf) if atr_tf else atr_value(sym) max_dist = (INIT_SL_MAX_ATR * atr) if atr else None min_dist = (INIT_SL_MIN_ATR * atr) if atr else None capped = floored = False if otype == mt5.ORDER_TYPE_BUY: piv = pivot_low(sym, entry_price) sl = (piv - buf) if piv else round(entry_price * (1 - INIT_SL_FALLBACK), si.digits) if max_dist and entry_price - sl > max_dist: sl = entry_price - max_dist; capped = True if min_dist and entry_price - sl < min_dist: sl = entry_price - min_dist; floored = True sl = round(sl, si.digits) sl_dist = entry_price - sl tp = round(entry_price + INIT_TP_RR * sl_dist, si.digits) if sl_dist > 0 else 0.0 else: piv = pivot_high(sym, entry_price) sl = (piv + buf) if piv else round(entry_price * (1 + INIT_SL_FALLBACK), si.digits) if max_dist and sl - entry_price > max_dist: sl = entry_price + max_dist; capped = True if min_dist and sl - entry_price < min_dist: sl = entry_price + min_dist; floored = True sl = round(sl, si.digits) sl_dist = sl - entry_price tp = round(entry_price - INIT_TP_RR * sl_dist, si.digits) if sl_dist > 0 else 0.0 _tfn = "M5" if atr_tf == mt5.TIMEFRAME_M5 else "M15" src_str = f"M15-Pivot {piv:.3f}" if piv else "Fallback 1.2%" if atr_tf: src_str += f", ATR aus {_tfn}" if capped: src_str += f", gekappt auf {INIT_SL_MAX_ATR}xATR={max_dist:.3f}" if floored: src_str += f", auf min {INIT_SL_MIN_ATR}xATR={min_dist:.3f} aufgeweitet" return sl, tp, src_str def _send(self, sym, otype, atr_tf=None, buffer_pct=None): with mt5_lock(timeout=15) as got: if not got: self.last_error = "MT5 belegt — bitte gleich nochmal" return None, 0.0 return self._send_locked(sym, otype, atr_tf, buffer_pct) def _send_locked(self, sym, otype, atr_tf=None, buffer_pct=None): tick = get_tick(sym) if not tick: self.last_error = "Kein Tick"; return None, 0.0 price = tick.ask if otype == mt5.ORDER_TYPE_BUY else tick.bid # SL ZUERST bestimmen → daraus risiko-basierte Lot-Größe (Verlust beim # Initial-SL ≈ risk_pct der Equity). Margin bleibt Obergrenze. Fallback auf # margin-basiert, wenn risk_pct=0 oder Daten fehlen. Behebt die großen # EUR-Verluste aus 90 %-Margin × 2×ATR-SL. sl, _tp, sl_src = self._calc_sl_tp(sym, otype, price, atr_tf) risk = get_risk_per_trade() sl_dist = abs(price - sl) if sl else None # Eine manuell gesetzte Einsatz-Margin hat VORRANG vor dem Risiko-Modus # (2026-08-04): sie ist eine ausdrückliche Größenvorgabe des Users, keine # Rechenregel. `calc_lots` setzt sie um und deckelt auf die freie Margin. if get_manual_margin() > 0: risk = 0.0 if risk > 0: # Risiko-Modus: KEIN stiller Fallback auf Margin-Sizing (75 % wäre ein # Vielfaches des gewollten Risikos). Klappt die Risiko-Rechnung nicht # (Daten fehlen / unter Mindestlot), wird der Trade abgelehnt. lots = calc_lots_risk(sym, price, otype, sl_dist, risk) if lots <= 0: self.last_error = ("Risiko-Sizing nicht möglich (unter Mindestlot " "oder Daten fehlen) — Trade abgelehnt") return None, 0.0 else: lots = calc_lots(sym, price, otype, buffer_pct) # margin-basiert (risk_pct=0) if lots <= 0: self.last_error = "Lot-Fehler"; return None, 0.0 req = { "action": mt5.TRADE_ACTION_DEAL, "symbol": sym, "volume": float(lots), "type": otype, "price": float(price), "deviation": DEVIATION, "magic": MAGIC, "comment": f"Widget-{'BUY' if otype == mt5.ORDER_TYPE_BUY else 'SELL'}", "type_filling": get_filling(sym), } if sl: req["sl"] = float(sl) res = mt5.order_send(req) for mode in (mt5.ORDER_FILLING_RETURN, mt5.ORDER_FILLING_IOC, mt5.ORDER_FILLING_FOK): if res and res.retcode != 10030: break req["type_filling"] = mode res = mt5.order_send(req) if res and res.retcode == mt5.TRADE_RETCODE_DONE: log_trade.info( f"{'BUY' if otype == mt5.ORDER_TYPE_BUY else 'SELL'} " f"{lots:.2f}L @ {price:.3f} T={res.order} SL={sl:.3f} ({sl_src})") # tick_size/value/lots sofort cachen — sonst liefert live_pnl() # bis zum ersten Positions-Tick (≤1 s) None und die P&L bleibt leer si = mt5.symbol_info(sym) with self._lock: self.lots = float(lots); self._swap = 0.0; self._commission = 0.0 if si: self._tick_size = si.trade_tick_size or self._tick_size or 0.001 self._tick_value = si.trade_tick_value or self._tick_value or 1.0 return res.order, float(price) self.last_error = _retcode_msg(res) return None, 0.0 # ══════════════════════════════════════════════════════════════════════ # PENDING STOP-ORDERS (Squeeze-Einstieg am Level, 2026-08-05) # ══════════════════════════════════════════════════════════════════════ # WARUM: `backtest_squeeze_entry.py` hat gemessen, dass der Squeeze seinen # gesamten Edge im EINSTIEGSPREIS hat. Die Market-Order auf dem 5-s-Poll füllt # live im Median 0,275×ATR hinter dem Level (Mittel 0,686, Max 4,3) — damit # dreht ØR von +0,456 auf −0,158. Eine ruhende Stop-Order deckelt den Preis # konstruktiv: selbst mit 0,20×ATR unterstellter Fill-Slippage bleibt ØR # +0,244/+0,426 in beiden Halbjahren. def pending_orders(self, sym: str) -> list: """Eigene (magic-gleiche) Pending-Orders des Symbols.""" with mt5_lock(timeout=5) as got: if not got: return [] try: orders = mt5.orders_get(symbol=sym) or () except Exception: return [] return [o for o in orders if getattr(o, "magic", 0) == MAGIC] def place_stop(self, sym: str, otype: int, trigger: float, comment: str = "Widget-SQZ", atr_tf=None, buffer_pct=None) -> tuple[int | None, str]: """Pending BUY_STOP/SELL_STOP am Ausbruchs-Level. → (ticket, Fehlertext). Größe und SL werden mit DERSELBEN Logik bestimmt wie bei der Market-Order (`calc_lots` / `_calc_sl_tp`), nur bezogen auf den Trigger-Preis statt auf den aktuellen Tick — sonst wäre der SL-Abstand ein anderer als validiert. ⚠ Broker-Mindestabstand (`trade_stops_level`) wird geprüft: liegt der Kurs schon zu nah am Trigger, lehnt MT5 die Order mit einem kryptischen retcode ab. Dann meldet die Funktion das sauber, und der Aufrufer entscheidet.""" with mt5_lock(timeout=15) as got: if not got: return None, "MT5 belegt" si = mt5.symbol_info(sym) tick = get_tick(sym) if not si or not tick: return None, "Kein Symbol/Tick" ist_long = otype == mt5.ORDER_TYPE_BUY_STOP trigger = round(float(trigger), si.digits) # Stop-Order muss auf der richtigen Seite des Marktes liegen … bezug = tick.ask if ist_long else tick.bid if (trigger <= bezug) if ist_long else (trigger >= bezug): return None, f"Kurs hat das Level bereits passiert ({bezug:.3f})" # … und den Broker-Mindestabstand einhalten mind = (getattr(si, "trade_stops_level", 0) or 0) * (si.point or 0.001) if mind and abs(trigger - bezug) < mind: return None, (f"unter Broker-Mindestabstand " f"({abs(trigger-bezug):.3f} < {mind:.3f})") sl, _tp, sl_src = self._calc_sl_tp(sym, mt5.ORDER_TYPE_BUY if ist_long else mt5.ORDER_TYPE_SELL, trigger, atr_tf) lots = calc_lots(sym, trigger, mt5.ORDER_TYPE_BUY if ist_long else mt5.ORDER_TYPE_SELL, buffer_pct) if lots <= 0: return None, "Lot-Fehler" req = {"action": mt5.TRADE_ACTION_PENDING, "symbol": sym, "volume": float(lots), "type": otype, "price": float(trigger), "magic": MAGIC, "comment": comment, "type_time": mt5.ORDER_TIME_GTC, "type_filling": get_filling(sym)} if sl: req["sl"] = float(sl) res = mt5.order_send(req) for mode in (mt5.ORDER_FILLING_RETURN, mt5.ORDER_FILLING_IOC, mt5.ORDER_FILLING_FOK): if res and res.retcode != 10030: break req["type_filling"] = mode res = mt5.order_send(req) if res and res.retcode == mt5.TRADE_RETCODE_DONE: log_trade.info(f"PENDING {'BUY_STOP' if ist_long else 'SELL_STOP'} " f"{lots:.2f}L @ {trigger:.3f} T={res.order} " f"SL={sl:.3f} ({sl_src})") return res.order, "" return None, _retcode_msg(res) def cancel_pending(self, ticket: int) -> str: """Eine Pending-Order löschen. → "" bei Erfolg, sonst Fehlertext.""" with mt5_lock(timeout=15) as got: if not got: return "MT5 belegt" res = mt5.order_send({"action": mt5.TRADE_ACTION_REMOVE, "order": int(ticket)}) if res and res.retcode == mt5.TRADE_RETCODE_DONE: log_trade.info(f"PENDING storniert T={ticket}") return "" return _retcode_msg(res) def open_long(self, sym, atr_tf=None, buffer_pct=None): with self._lock: if self.ticket: return "Position bereits offen!" t, e = self._send(sym, mt5.ORDER_TYPE_BUY, atr_tf, buffer_pct) if not t: return self.last_error with self._lock: self.ticket = t; self.order_type = mt5.ORDER_TYPE_BUY self.entry_price = e; self.symbol = sym self._log_open(t, sym, "BUY", e) return "" def open_short(self, sym, atr_tf=None, buffer_pct=None): with self._lock: if self.ticket: return "Position bereits offen!" t, e = self._send(sym, mt5.ORDER_TYPE_SELL, atr_tf, buffer_pct) if not t: return self.last_error with self._lock: self.ticket = t; self.order_type = mt5.ORDER_TYPE_SELL self.entry_price = e; self.symbol = sym self._log_open(t, sym, "SELL", e) return "" def close(self, reason: str = "manual"): with mt5_lock(timeout=15) as got: if not got: return "MT5 belegt — bitte gleich nochmal" return self._close_locked(reason) def _close_locked(self, reason: str = "manual"): with self._lock: ticket = self.ticket; sym = self.symbol; otype = self.order_type if not ticket: return "Keine offene Position." positions = mt5.positions_get(ticket=ticket) if not positions: with self._lock: self.ticket = None return "Position bereits geschlossen." pos = positions[0] tick = get_tick(sym) if not tick: return "Kein Tick." ct = mt5.ORDER_TYPE_SELL if otype == mt5.ORDER_TYPE_BUY else mt5.ORDER_TYPE_BUY cp = tick.bid if otype == mt5.ORDER_TYPE_BUY else tick.ask req = { "action": mt5.TRADE_ACTION_DEAL, "symbol": sym, "volume": float(pos.volume), "type": ct, "position": ticket, "price": float(cp), "deviation": DEVIATION, "magic": MAGIC, "comment": "Widget-CLOSE", "type_filling": get_filling(sym), } res = mt5.order_send(req) for mode in (mt5.ORDER_FILLING_RETURN, mt5.ORDER_FILLING_IOC, mt5.ORDER_FILLING_FOK): if res and res.retcode != 10030: break req["type_filling"] = mode res = mt5.order_send(req) if res and res.retcode == mt5.TRADE_RETCODE_DONE: log_trade.info(f"CLOSE T={ticket} @ {cp:.3f} ({reason})") self._log_close(ticket, cp, pos.profit, reason) with self._lock: self.ticket = None; self.order_type = None self.entry_price = 0.0; self.lots = 0.0 self.pnl = 0.0; self.cur_price = 0.0; self.sl = self.tp = self.margin = 0.0 self.open_time = 0.0 self._swap = 0.0; self._tick_size = None; self._tick_value = None return "" return "Close: " + _retcode_msg(res) def partial_close_position(self, pos, si, frac: float = 0.5, reason: str = "partial"): """ Schließt `frac` des Volumens einer offenen Position (Teil-Exit / Runner). CALLER MUSS den globalen mt5_lock bereits halten (wird vom Trailing innerhalb von _do_modify aufgerufen). Rückgabe: (geschlossenes_volumen, schlusskurs) bei Erfolg, sonst (0.0, fehlertext). Die realisierte Teil-PnL wird NICHT separat geloggt — sie steckt als eigener Deal an derselben position_id und wird beim finalen Close über _log_external_close in die Gesamt-PnL des Trades aufsummiert. """ sym = getattr(pos, "symbol", self.symbol) step = si.volume_step or 0.01 vmin = si.volume_min or step full = float(pos.volume) vol_close = round(round((full * frac) / step) * step, 8) # Beide Seiten müssen >= Mindestvolumen bleiben — sonst kein Teil-Exit if vol_close < vmin or (full - vol_close) < vmin: return 0.0, "Volumen zu klein zum Teilen" tick = get_tick(sym) if not tick: return 0.0, "Kein Tick" otype = pos.type ct = mt5.ORDER_TYPE_SELL if otype == mt5.ORDER_TYPE_BUY else mt5.ORDER_TYPE_BUY cp = tick.bid if otype == mt5.ORDER_TYPE_BUY else tick.ask req = { "action": mt5.TRADE_ACTION_DEAL, "symbol": sym, "volume": float(vol_close), "type": ct, "position": pos.ticket, "price": float(cp), "deviation": DEVIATION, "magic": MAGIC, "comment": "Widget-PARTIAL", "type_filling": get_filling(sym), } res = mt5.order_send(req) for mode in (mt5.ORDER_FILLING_RETURN, mt5.ORDER_FILLING_IOC, mt5.ORDER_FILLING_FOK): if res and res.retcode != 10030: break req["type_filling"] = mode res = mt5.order_send(req) if res and res.retcode == mt5.TRADE_RETCODE_DONE: log_trade.info( f"TEIL-EXIT ({reason}): {vol_close:.2f}L von {full:.2f}L " f"@ {cp:.3f} T={pos.ticket}") with self._lock: self.lots = max(full - vol_close, 0.0) return vol_close, float(cp) return 0.0, f"retcode={res.retcode if res else 'None'}" def set_open_context(self, *, ai_sentiment=None, ai_confidence=None, rec_signal=None, rec_score=None, setup=None, regime=None, rsi=None, news_score=None, ctx=None): # `ctx` = voller Entscheidungszustand (2026-08-04, s. `history.log_trade_open`) self._open_context = { "ai_sentiment": ai_sentiment, "ai_confidence": ai_confidence, "rec_signal": rec_signal, "rec_score": rec_score, "setup": setup, "regime": regime, "rsi": rsi, "news_score": news_score, "ctx": ctx or {}, } def _log_open(self, ticket: int, sym: str, direction: str, entry_price: float): if not self.history: return ctx = dict(self._open_context) def delayed_log(): time.sleep(0.5) sl = tp = None try: # Eigener Thread → MT5-Call MUSS über den globalen Lock laufen # (sonst Race gegen copy_rates/positions_get der anderen Loops). with mt5_lock(timeout=5) as got: positions = mt5.positions_get(ticket=ticket) if got else None if positions: sl = float(positions[0].sl) or None tp = float(positions[0].tp) or None except Exception as e: log_hist.warning(f"SL/TP-Lookup: {e}") try: self.history.log_trade_open( ticket=ticket, symbol=sym, direction=direction, lots=float(self.lots) or 0.0, entry_price=entry_price, sl_at_entry=sl, tp_at_entry=tp, ai_sentiment=ctx.get("ai_sentiment"), ai_confidence=ctx.get("ai_confidence"), rec_signal=ctx.get("rec_signal"), rec_score=ctx.get("rec_score"), setup=ctx.get("setup"), regime=ctx.get("regime"), rsi_at_entry=ctx.get("rsi"), news_score=ctx.get("news_score"), # Voller Entscheidungszustand (2026-08-04, „Manuelle Trades") ctx=ctx.get("ctx"), ) except Exception as e: log_hist.error(f"log_trade_open: {e}") threading.Thread(target=delayed_log, daemon=True).start() def _log_close(self, ticket: int, exit_price: float, pnl: float, closed_by: str): if not self.history: return try: self.history.log_trade_close( ticket=ticket, exit_price=exit_price, pnl=pnl, closed_by=closed_by, ) except Exception as e: log_hist.error(f"log_trade_close: {e}") def refresh(self, sym): with mt5_lock() as got: if not got: return self._refresh_locked(sym) def _refresh_locked(self, sym): with self._lock: ticket = self.ticket if ticket is None: on_sym = mt5.positions_get(symbol=sym) or [] all_pos = on_sym if on_sym else (mt5.positions_get() or []) 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] pos_sym = getattr(pick, "symbol", sym) _boff = self._broker_offset_s(pos_sym) with self._lock: self.ticket = pick.ticket; self.order_type = pick.type self.entry_price = pick.price_open; self.symbol = pos_sym self.lots = pick.volume; self.pnl = pick.profit self.sl = float(getattr(pick, "sl", 0.0) or 0.0) self.tp = float(getattr(pick, "tp", 0.0) or 0.0) self.open_time = float(getattr(pick, "time", 0) or 0) - _boff # Adoptierter Trade ohne SL → Schutz-SL nachrüsten (Lock gehalten) if not self.sl: psl, _ptp, _ps = self._calc_sl_tp(pos_sym, pick.type, float(pick.price_open)) if psl: r = mt5.order_send({"action": mt5.TRADE_ACTION_SLTP, "symbol": pos_sym, "position": pick.ticket, "sl": float(psl)}) if r and r.retcode == mt5.TRADE_RETCODE_DONE: with self._lock: self.sl = float(psl) log_trade.info( f"Schutz-SL für adoptierten Trade " f"T={pick.ticket} @ {psl:.3f}") else: log_trade.warning( f"Schutz-SL fehlgeschlagen T={pick.ticket} " f"rc={r.retcode if r else 'None'}") try: # gebundene Margin (eingesetzter Betrag) _m = mt5.order_calc_margin(pick.type, pos_sym, pick.volume, pick.price_open) if _m: with self._lock: self.margin = float(_m) except Exception: pass source = "magic-match" if own else "externer Trade adoptiert" cross = " ⚠ ANDERES Symbol!" if pos_sym != sym else "" log_trade.info( f"Position erkannt: T={pick.ticket} {pos_sym} " f"{'BUY' if pick.type == mt5.ORDER_TYPE_BUY else 'SELL'} " f"{pick.volume}L @ {pick.price_open:.3f} ({source}){cross}") if self.history: direction = "BUY" if pick.type == mt5.ORDER_TYPE_BUY else "SELL" self.history.log_trade_open( ticket=int(pick.ticket), symbol=pos_sym, direction=direction, lots=float(pick.volume), entry_price=float(pick.price_open), ) return pos = mt5.positions_get(ticket=ticket) if not pos: with self._lock: last_pnl = self.pnl last_price = self.cur_price last_commission = self._commission log_trade.info(f"Position {ticket} extern geschlossen pnl≈{last_pnl:.2f} commission={last_commission:.2f}") # MT5 braucht ~1-2s um den Close-Deal in die History zu schreiben. # Async mit kurzem Delay aufrufen, damit history_deals_get den Deal findet # und closed_by korrekt als "manual"/"sl"/"tp" gesetzt wird (nicht "unknown"). def _log_async(t=ticket, pnl=last_pnl, price=last_price, comm=last_commission): time.sleep(2) with mt5_lock(timeout=10) as _got: if _got: self._log_external_close(t, fallback_pnl=pnl, fallback_price=price, fallback_commission=comm) threading.Thread(target=_log_async, daemon=True).start() with self._lock: self.ticket = None; self.order_type = None self.entry_price = 0.0; self.pnl = 0.0; self.lots = 0.0 self.cur_price = 0.0; self.sl = self.tp = self.margin = 0.0 self.open_time = 0.0 self._swap = 0.0; self._commission = 0.0 self._tick_size = None; self._tick_value = None return p = pos[0] tick = get_tick(sym) swap = float(getattr(p, "swap", 0.0) or 0.0) commission = float(getattr(p, "commission", 0.0) or 0.0) now = time.time() if now - self._si_cache_ts > 5.0: self._si_cache = mt5.symbol_info(sym) self._si_cache_ts = now si = self._si_cache try: # gebundene Margin (eingesetzter Betrag) _m = mt5.order_calc_margin(p.type, sym, p.volume, p.price_open) margin = float(_m) if _m else 0.0 except Exception: margin = 0.0 # Öffnungszeit nur EINMAL je Position berechnen (spart den Offset-Tick-Call je # 1-s-Refresh) — ändert sich nicht, solange die Position offen ist. _open_time = None # Neu berechnen, wenn noch unbekannt ODER offensichtlich falsch (in der # Zukunft = Offset war beim Adoptieren nicht ermittelbar → Selbstheilung). if not self.open_time or self.open_time > now + 60: _open_time = float(getattr(p, "time", 0) or 0) - self._broker_offset_s(sym) with self._lock: self.lots = p.volume self._swap = swap self._commission = commission self.pnl = p.profit + swap self.sl = float(getattr(p, "sl", 0.0) or 0.0) self.tp = float(getattr(p, "tp", 0.0) or 0.0) self.margin = margin if _open_time is not None: self.open_time = _open_time self.cur_price = (tick.bid if p.type == mt5.ORDER_TYPE_BUY else tick.ask) if tick else p.price_current if si: self._tick_size = si.trade_tick_size or self._tick_size or 0.001 self._tick_value = si.trade_tick_value or self._tick_value or 1.0 def live_pnl(self, bid: float, ask: float) -> float | None: with self._lock: if self.ticket is None: return None otype = self.order_type; ep = self.entry_price lots = self.lots; ts = self._tick_size tv = self._tick_value; swap = self._swap if not ts or not tv: return None cur = bid if otype == mt5.ORDER_TYPE_BUY else ask diff = (cur - ep) if otype == mt5.ORDER_TYPE_BUY else (ep - cur) return diff / ts * tv * lots + swap def _broker_offset_s(self, sym: str | None = None) -> int: """ Broker-Serverzeit minus UTC in Sekunden, auf 30 min gerundet (z.B. UTC+3 → 10800). MT5 liefert deal.time/tick.time in Broker-Zeit, NICHT in UTC — ohne Korrektur landen Timestamps 3 h verschoben in der DB. ⚠ BUGFIX 2026-08-01 (Wochenende, real gemessen): `tick.time - now` misst die Zeitzone NUR, solange der Tick frisch ist. Steht der Markt, friert `tick.time` ein und die Differenz misst die **Veraltung**. Real am 01.08. um 11:25: letzter Tick Fr 23:54 → `tick.time - now = -34210` → gerundet **-34200 (−9,5 h)** statt +10800. Die alte Plausibilitätsprüfung `[-12h, +14h]` ließ das durch, weil eine Wochenend-Veraltung genau in dieses Fenster fällt. Folgen waren real sichtbar: `open_time` 12,5 h in der ZUKUNFT (Laufzeit-Uhr stand auf 0:00), und still betroffen waren auch Time-Stop-Alter (`trailing`), `deal.time`-Umrechnung beim externen Close und alle MQL5-Chart-Anker (Kanal/Liquiditäts-Trendlinie/ Trade-Marker). Neu: Der Offset wird **nur von einem frischen Tick** (neu) bestimmt; sonst gilt der zuletzt gültige Wert (`self._boff`, vorbelegt mit der bekannten Zeitzone UTC+3). Die Frische wird am bereits bekannten Offset gemessen — nicht am Tick selbst, das wäre zirkulär. Ein echter Zeitzonen-Wechsel (DST des Brokers) wird beim ersten frischen Tick übernommen und geloggt. """ try: # `sym` explizit übergebbar: beim Adoptieren einer Position ist # `self.symbol` noch nicht gesetzt → sonst kein Tick → Offset 0 und # die Öffnungszeit bliebe rohe Broker-Zeit (3 h in der Zukunft). sym = sym or self.symbol tick = mt5.symbol_info_tick(sym) if sym else None if tick and tick.time: now = time.time() off = round((tick.time - now) / 1800) * 1800 # Veraltung, gemessen am bisher bekannten Offset (nicht zirkulär). stale = (now + self._boff) - tick.time if stale <= _BROKER_TICK_FRESH_S and -12 * 3600 <= off <= 14 * 3600: if off != self._boff and self._boff_logged != off: # ⚠ war `log.` — diesen Namen gibt es in dieser Datei NICHT # (nur `log_trade`/`log_hist`). Gefunden 2026-08-07 mit # pyflakes. Folge war schwerer als ein fehlender Logeintrag: # der NameError lief in das `except Exception: pass` unten, # und die Zuweisung `self._boff = int(off)` DARUNTER wurde # dabei übersprungen → ein echter Broker-Zeitzonen-Wechsel # wäre nie übernommen worden, dauerhaft und lautlos. # Genau der Fall, für den dieser Zweig 2026-08-01 gebaut # wurde (Deployment-Drift Fall 4). log_trade.info(f"Broker-Zeitzone neu erkannt: " f"{self._boff / 3600:+.1f} h → " f"{off / 3600:+.1f} h") self._boff_logged = off self._boff = int(off) except Exception: pass return int(self._boff) def _log_external_close(self, ticket: int, fallback_pnl: float | None = None, fallback_price: float | None = None, fallback_commission: float = 0.0, lookback_hours: int = 24): """ Versucht den externen Close über MT5-Deal-History zu rekonstruieren. Methode 1 (primär): history_deals_get(position=ticket) ohne Zeitrange. Ruft intern HistoryDealsGetByPosition() auf — sucht in der kompletten History und funktioniert auf den meisten Brokern. Methode 2 (Fallback): Zeitfenster-Suche nach position_id == ticket. Greift, wenn Methode 1 leer zurückkommt (seltener Broker-Bug). Methode 3 (letzter Ausweg): letzter bekannter PnL aus Trader-State, closed_by bleibt "unknown". """ if not self.history: return try: # ── Methode 1: position-basierter Lookup (kein Zeitfenster) ────── pos_deals = mt5.history_deals_get(position=ticket) own = [d for d in (pos_deals or []) if getattr(d, "position_id", None) == ticket] # ── Methode 2: Zeitfenster + position_id-Filter ─────────────────── if not own: # history_deals_get filtert nach BROKER-Zeit, nicht UTC — # ohne Offset läge das Fensterende 3h vor Broker-jetzt und # frisch geschlossene Deals fielen heraus. now_b = int(time.time()) + self._broker_offset_s() from_ts = now_b - lookback_hours * 3600 range_deals = mt5.history_deals_get(from_ts, now_b + 300) own = [d for d in (range_deals or []) if getattr(d, "position_id", None) == ticket] if own: log_hist.debug(f"T={ticket}: Methode-2 lieferte {len(own)} Deals") else: n1 = len(pos_deals) if pos_deals else 0 n2 = len(range_deals) if range_deals else 0 log_hist.debug( f"T={ticket}: keine Deals mit position_id={ticket} " f"(M1={n1} Deals, M2={n2} Deals — Broker setzt position_id nicht)") if own: deals_sorted = sorted(own, key=lambda d: getattr(d, "time", 0)) close_deal = next( (d for d in reversed(deals_sorted) if d.entry == mt5.DEAL_ENTRY_OUT), None) if close_deal: reason = getattr(close_deal, "reason", None) closed_by = "unknown" try: if reason == mt5.DEAL_REASON_SL: closed_by = "sl" elif reason == mt5.DEAL_REASON_TP: closed_by = "tp" elif reason in (mt5.DEAL_REASON_CLIENT, mt5.DEAL_REASON_EXPERT, mt5.DEAL_REASON_MOBILE, mt5.DEAL_REASON_WEB): closed_by = "manual" except AttributeError: pass commission = sum(getattr(d, "commission", 0) for d in own) total_profit = sum( getattr(d, "profit", 0) + getattr(d, "swap", 0) + getattr(d, "commission", 0) for d in own) # deal.time ist Broker-Zeit (z.B. UTC+3) → in UTC umrechnen raw_ts = int(getattr(close_deal, "time", 0) or 0) exit_ts = (raw_ts - self._broker_offset_s()) if raw_ts \ else int(time.time()) self.history.log_trade_close( ticket=ticket, exit_price=float(close_deal.price), pnl=float(total_profit), closed_by=closed_by, exit_ts=exit_ts, commission=float(commission)) log_hist.info( f"Externer Close: T={ticket} {closed_by} @ " f"{close_deal.price:.3f} pnl={total_profit:.2f}") return # ⚠⚠ HIER LAG DER FEHLER (behoben 2026-08-12). Vorher fiel dieser # Zweig in den Fallback durch und buchte den zuletzt bekannten # SCHWEBE-Gewinn als realisierten Close. # Das ist genau falsch herum: wir haben die Deals der Position # GEFUNDEN und festgestellt, dass **kein OUT-Deal existiert**. # Das ist positive Evidenz, dass die Position NOCH OFFEN ist — # typischerweise ein transient leeres `positions_get` direkt # nach dem Eröffnen. # Real am 12.08.: #49926460 wurde 0 Sekunden nach dem Öffnen als # „extern geschlossen" mit pnl 0,00 und exit_price 0,0 gebucht; # der ECHTE Close 7 min später lief ins Leere, weil # `log_trade_close` nur Zeilen mit `exit_time IS NULL` anfasst. # Bei #49852362 kostete dasselbe **+5,87 statt −95,38 €** und # buchte den Trade auf den FALSCHEN TAG. # ➜ Nicht schreiben. Der nächste Tick prüft erneut; bleibt die # Position wirklich weg, liefert der Broker dann einen # OUT-Deal. `reconcile_open_trades` ist das zweite Netz. log_hist.warning( f"T={ticket}: kein OUT-Deal in {len(own)} Deals — Position gilt " f"als WEITER OFFEN, kein Close gebucht (Fallback uebersprungen)") return # ── Methode 3: Fallback — letzter bekannter PnL ─────────────────── # ⚠ Nur noch fuer den WIRKLICH blinden Fall: es liess sich KEIN # einziger Deal der Position abrufen (MT5-Aussetzer, Broker setzt # `position_id` nicht). Dann ist „offen oder zu" nicht entscheidbar, # und eine Zeile mit `closed_by='unknown'` ist besser als ein Trade, # der ewig offen in der DB haengt. if fallback_pnl is not None: self.history.log_trade_close( ticket=ticket, exit_price=float(fallback_price or 0.0), pnl=float(fallback_pnl), closed_by="unknown", exit_ts=int(time.time()), commission=fallback_commission) log_hist.warning( f"Externer Close (Fallback-PnL): T={ticket} " f"pnl≈{fallback_pnl:.2f} commission={fallback_commission:.2f} " f"price≈{fallback_price or 0:.3f}") else: log_hist.warning( f"T={ticket}: keine Deal-Daten, kein Fallback-PnL — " f"wird bei Reconcile als 'unknown' eingetragen") except Exception as e: log_hist.error(f"_log_external_close: {e}") # ── ECHTE Steuer-Buchungen statt Schaetzung ────────────────────────────── _STEUER_TTL_S = 300 # MT5-Abfrage gehoert nicht in jeden Aufruf def steuer_buchungen(self, von_ts: int, bis_ts: int) -> dict | None: """Tatsaechlich einbehaltene und erstattete Quellensteuer im Zeitfenster. ⚠⚠ WARUM DAS DIE SCHAETZUNG ERSETZT (Befund 05.08., nachgemessen 12.08.): `server._add_net` rechnete `net_pnl = total_pnl − gross_win × 26,375 %` und unterstellte damit, die WHT auf JEDEN Gewinn-Trade sei endgueltig verloren. Sie wird aber **taeglich per `Tax settlement` erstattet**. Gemessen ueber 90 Tage: einbehalten −2.625,89 €, erstattet +2.608,83 € → verblieben **−17,06 €**. Die Schaetzung sagte −2.619,93 € und machte damit aus **+153 € rund −2.450 €**. ✅ Die Schaetzung der EINBEHALTENEN Summe ist fast exakt (2.620 gegen 2.626) — falsch ist allein die Annahme, sie bleibe weg. Rueckgabe `{wht, tax, netto, n}` in Kontowaehrung (wht negativ, tax positiv, netto = Summe) oder `None`, wenn MT5 nicht antwortet. ⚠ Die Buchungen haben `position_id == 0` und sind damit von den Trade-Deals sauber getrennt — kein Doppelzaehlen mit `total_pnl`. ⚠ ZEITFALLE: `history_deals_get` filtert nach BROKER-Wallclock. Deshalb Epochs mit Offset uebergeben und beim Lesen wieder abziehen — mit `datetime`-Objekten verschiebt sich das Fenster (real zweimal passiert). ⚠ Gecacht (`_STEUER_TTL_S`), weil die Abfrage ueber lange Zeitraeume Sekunden braucht. """ schluessel = (int(von_ts), int(bis_ts)) cache = getattr(self, "_steuer_cache", None) if cache and cache[0] == schluessel and time.time() - cache[1] < self._STEUER_TTL_S: return cache[2] off = self._broker_offset_s() try: with mt5_lock(timeout=8) as got: if not got: return None deals = mt5.history_deals_get(int(von_ts) + off, int(bis_ts) + off + 300) except Exception as e: log_hist.warning(f"steuer_buchungen: {e}") return None if deals is None: return None wht = tax = 0.0 n = 0 for d in deals: k = (getattr(d, "comment", "") or "") if getattr(d, "position_id", 0): continue # Trade-Deal, nicht Steuer ts = int(getattr(d, "time", 0) or 0) - off if not (von_ts <= ts <= bis_ts): continue if "WHT" in k: wht += float(d.profit); n += 1 elif "Tax" in k: tax += float(d.profit); n += 1 res = {"wht": round(wht, 2), "tax": round(tax, 2), "netto": round(wht + tax, 2), "n": n} self._steuer_cache = (schluessel, time.time(), res) return res def reconcile_open_trades(self, lookback_hours: int = 168): if not self.history: return open_trades = self.history.open_trades() if not open_trades: log_hist.info("Reconcile: keine offenen Trades in DB") return log_hist.info(f"Reconcile: prüfe {len(open_trades)} offene DB-Einträge …") n_closed = n_orphaned = 0 cutoff_ts = int(time.time()) - lookback_hours * 3600 for trade in open_trades: ticket = trade["ticket"] try: if mt5.positions_get(ticket=ticket): continue except Exception: pass self._log_external_close(ticket, lookback_hours=lookback_hours) try: still_open_ids = {t["ticket"] for t in self.history.open_trades()} if ticket not in still_open_ids: n_closed += 1 else: # Position in MT5 weg, aber kein Deal gefunden → # sofort als 'unknown' markieren (kein Age-Cutoff nötig, # da MT5-Abwesenheit bereits bestätigt wurde). self.history.log_trade_close( ticket=ticket, exit_price=0.0, pnl=0.0, closed_by="unknown", exit_ts=int(time.time())) n_orphaned += 1 log_hist.warning( f"Reconcile: T={ticket} nicht in MT5 + keine Deals " f"→ als 'unknown' markiert") except Exception as e: log_hist.error(f"Reconcile-Check T={ticket}: {e}") log_hist.info(f"Reconcile fertig: {n_closed} nachgetragen, " f"{n_orphaned} als 'unknown' markiert") def modify_sltp(self, sl=None, tp=None): """Manuelles Setzen von SL/TP der offenen Position (TRADE_ACTION_SLTP). None/leer = jeweiligen Broker-Wert beibehalten; 0 = entfernen. Prüft Seite/Mindestabstand vorab (freundlichere Meldung als der Broker-Retcode). Gibt Fehlertext zurück oder None bei Erfolg.""" with mt5_lock(timeout=5) as got: if not got: return "MT5 belegt" if not self.ticket: return "keine Position" positions = mt5.positions_get(ticket=self.ticket) if not positions: return "keine Position" pos = positions[0]; sym = pos.symbol si = mt5.symbol_info(sym); tick = mt5.symbol_info_tick(sym) if not si or not tick: return "kein Symbol/Tick" is_long = pos.type == mt5.ORDER_TYPE_BUY cur = tick.bid if is_long else tick.ask spread = getattr(si, "spread", 0) or 0 min_dist = max((si.trade_stops_level + spread + 5) * si.point, 0.01) def _val(x, keep): if x in (None, ""): return float(keep or 0.0) return float(x) new_sl = _val(sl, pos.sl); new_tp = _val(tp, pos.tp) if new_sl: if is_long and new_sl >= cur - min_dist: return f"SL muss < {cur - min_dist:.3f} liegen (unter Kurs)" if not is_long and new_sl <= cur + min_dist: return f"SL muss > {cur + min_dist:.3f} liegen (über Kurs)" if new_tp: if is_long and new_tp <= cur + min_dist: return f"TP muss > {cur + min_dist:.3f} liegen (über Kurs)" if not is_long and new_tp >= cur - min_dist: return f"TP muss < {cur - min_dist:.3f} liegen (unter Kurs)" res = mt5.order_send({ "action": mt5.TRADE_ACTION_SLTP, "symbol": sym, "position": pos.ticket, "sl": round(new_sl, si.digits), "tp": round(new_tp, si.digits), }) if not res or res.retcode != mt5.TRADE_RETCODE_DONE: return f"Broker lehnte ab (rc={getattr(res, 'retcode', '?')}: " \ f"{getattr(res, 'comment', '?')})" with self._lock: self.sl = round(new_sl, si.digits) self.tp = round(new_tp, si.digits) # ⚠⚠ war `log.` — den Namen gibt es hier nicht (nur `log_trade`/ # `log_hist`). Seit dem Initial-Commit drin, gefunden 2026-08-07 mit # pyflakes. Die Stelle steht NICHT in einem `try`, der NameError lief # also bis in `/api/sltp` durch — mit drei Folgen: (1) der Broker hatte # SL/TP bereits geändert, (2) der User bekam trotzdem eine Fehlermeldung, # (3) `set_sltp` kam nie bis `trail.deactivate()`, das Trailing blieb an # und hätte die Handeingabe beim nächsten Tick zurückgezogen — genau das, # was die User-Vorgabe verhindern soll. (Aufgefangen hat es nur die # separate Extern-Erkennung in `trailing._do_modify`, einen Tick später.) log_trade.info(f"Manuelles SLTP: SL={self.sl} · TP={self.tp} " f"(Ticket {pos.ticket})") return None def snapshot(self): with self._lock: return dict( ticket=self.ticket, order_type=self.order_type, entry_price=self.entry_price, lots=self.lots, pnl=self.pnl, cur_price=self.cur_price, sl=self.sl, tp=self.tp, margin=self.margin, open_time=(self.open_time if self.ticket else 0.0), )