analyze_squeeze_entry_gap.py fuehrt die zwei noetigen Quellen zusammen - das Ausbruchs-Level steht NUR im Log, der tatsaechliche Fill NUR in trades - und splittet am Umbau-Stichtag. Die Erfolgsmeldung ist paradox: eine per Pending gefuellte Order erzeugt KEINE AUTO-SQUEEZE-ENTRY-Logzeile (die entsteht nur im Market-Fallback), Squeeze-Trades ohne Log-Treffer sind also der Erfolg. Das Skript weist sie deshalb getrennt aus. Als Messung squeeze_entry_gap im measurement_reminder.py hinterlegt (>=12 Squeeze-Trades ab dem Umbau). Stichtag exakt 05.08. 08:55 statt Mitternacht - der 08:33-Trade lief noch ueber die Market-Order und haette die Zaehlung verfaelscht (0/12 statt faelschlich 1/12). Zahlen-Korrektur: die zuerst dokumentierten +0,306 / +0,741 xATR stammten aus einer Scratchpad-Auswertung mit FESTEM Stundenversatz. DST-korrekt ueber zoneinfo sind es +0,275 / +0,686 (n=34 unveraendert). In CLAUDE.md, engine.py, trader.py und der ini nachgezogen; die Schlussfolgerung aendert sich nicht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
865 lines
42 KiB
Python
865 lines
42 KiB
Python
"""
|
||
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
|
||
|
||
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
|
||
|
||
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
|
||
# `engine.get_bars` bereits für die Chart-Umrechnung (`broker_utc_offset`).
|
||
_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):
|
||
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)
|
||
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
|
||
src_str = f"M15-Pivot {piv:.3f}" if piv else "Fallback 1.2%"
|
||
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):
|
||
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)
|
||
|
||
def _send_locked(self, sym, otype):
|
||
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)
|
||
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") -> 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)
|
||
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):
|
||
with self._lock:
|
||
if self.ticket:
|
||
return "Position bereits offen!"
|
||
t, e = self._send(sym, mt5.ORDER_TYPE_BUY)
|
||
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):
|
||
with self._lock:
|
||
if self.ticket:
|
||
return "Position bereits offen!"
|
||
t, e = self._send(sym, mt5.ORDER_TYPE_SELL)
|
||
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:
|
||
log.info(f"Broker-Zeitzone neu erkannt: "
|
||
f"{self._boff / 3600:+.1f} h → {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
|
||
log_hist.warning(f"T={ticket}: kein OUT-Deal in {len(own)} Deals")
|
||
|
||
# ── Methode 3: Fallback — letzter bekannter PnL ───────────────────
|
||
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}")
|
||
|
||
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)
|
||
log.info(f"Manuelles SLTP: SL={self.sl} · TP={self.tp} (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),
|
||
)
|