"""
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):
        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)

    def _send_locked(self, sym, otype, atr_tf=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)   # 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) -> 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)
            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):
        with self._lock:
            if self.ticket:
                return "Position bereits offen!"
        t, e = self._send(sym, mt5.ORDER_TYPE_BUY, atr_tf)
        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):
        with self._lock:
            if self.ticket:
                return "Position bereits offen!"
        t, e = self._send(sym, mt5.ORDER_TYPE_SELL, atr_tf)
        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),
            )
