Files
AH-Oil-Trader/core/trader.py
T
Axel HocksandClaude Opus 5 5819a9df21 Netto-Rechnung aus den ECHTEN Steuer-Buchungen statt geschaetzt
Der seit 05.08. dokumentierte Defekt ist behoben. _add_net rechnete
net_pnl = total_pnl - gross_win * 26,375 % und unterstellte, die
Quellensteuer sei endgueltig verloren. Sie wird aber taeglich per
"Tax settlement" erstattet - real zu 99 %.

all: vorher net_pnl -2.449,95 EUR, jetzt +181,00 (total +209,93, echte
Steuerlast +28,93). Verzerrung rund 2.630 EUR - sie machte aus einem
profitablen Konto ein verlustreiches. Die Schaetzung der EINBEHALTENEN
Summe war fast exakt; falsch war allein die Annahme, sie bleibe weg.

trader.steuer_buchungen() liest die WHT-/Tax-Deals direkt
(position_id == 0, sauber von Trade-Deals getrennt), unter mt5_lock,
5 min gecacht. Fail-safe: ohne MT5 Rueckfall auf die Schaetzung, das
Feld wht_quelle sagt welche Zahl drinsteht.

Kurze Zeitraeume bleiben verzerrt, in BEIDE Richtungen - die Erstattung
kommt am Folgetag. Bei "today" ist wht sogar negativ, weil die
Erstattung von gestern heute einging. Ehrlich benannt, nicht kaschiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 20:44:09 +02:00

1001 lines
50 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
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,82,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),
)