Files
AH-Oil-Trader/core/trader.py
T
Axel HocksandClaude Opus 5 b8bb1d260f Fix: Broker-Offset bei geschlossenem Markt (-9,5 h statt +3 h)
`_broker_offset_s()` leitete die Zeitzone aus `tick.time - now` ab. Das misst
die Zeitzone nur bei FRISCHEM Tick; steht der Markt, misst es die Veraltung.
Real am 01.08. (letzter Tick Fr 23:54): -34210 s -> gerundet -34200 (-9,5 h)
statt +10800. Die Pruefung [-12h,+14h] liess das durch.

Folgen (die Laufzeit-Uhr war nur das Sichtbare):
- open_time 12,5 h in der Zukunft -> Laufzeit 0:00
- Time-Stop-Alter (trailing.py:504) negativ -> 120-min-Stop haette nicht ausgeloest
- deal.time-Umrechnung beim externen Close, alle MQL5-Chart-Anker

Fix: Offset nur noch aus frischem Tick bestimmen, sonst letzter gueltiger Wert
(vorbelegt UTC+3 = dieselbe Konstante wie engine.broker_utc_offset). Frische am
bereits bekannten Offset gemessen -> nicht zirkulaer. Schwelle 900 s < halbes
Rundungsraster. Zeitzonen-Wechsel wird uebernommen und geloggt.

Ausserdem:
- market_closed greift sofort nach Neustart: _last_bid_move_ts wird aus dem
  echten Tick-Alter vorbelegt statt bei null zu starten (vorher 180 s blind)
- pollSnapshot: der stille catch umschloss render() -> Render-Fehler wurden
  lautlos verschluckt, das Dashboard fror ohne Konsolen-Fehler ein. Jetzt ist
  nur noch der fetch im try.
- Header-Labels ausgeschrieben: "PEPPERSTONE KURS" / "HYPERLIQUID KURS"
- v=131, CLAUDE.md (Deployment-Drift Fall 4)

Verifiziert: market_closed sofort True, tick_age gegen die Rohwerte 0 s
Abweichung, open_time -> 31.07. 22:54 (Laufzeit +15,8 h).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:44:25 +02:00

767 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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,
)
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
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
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):
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,
}
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"),
)
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),
)