Files
AH-Oil-Trader/core/trader.py
T
Axel HocksandClaude Opus 5 c081bdc734 SL/TP nicht mehr am S/R verankert + Squeeze-Market-Fallback auf den BRK-Slot
User: "SL/TP sollte nicht am S/R verankert werden sondern immer dynamisch
berechnet werden" - nach dem Fund, dass Trades wiederholt am Level schlossen.

(1) PIVOT-ANKER DES INITIAL-SL ENTFERNT (`_SL_PIVOT_ANKER = False`).
`_calc_sl_tp` legte den SL an den naechsten M15-Pivot (LONG: pivot_low − Puffer,
SHORT: pivot_high + Puffer) und klemmte ihn erst DANACH auf 1,8–2,2×ATR. Der
Stop lag damit fuer einen LONG direkt unter der UNTERSTUETZUNG. Ueber 69
geloggte Orders war der Pivot in 4 % massgeblich (59 % gekappt, 36 % geweitet) -
und in genau diesen Faellen stand der Stop auf dem Level, an dem der Markt
ohnehin am haeufigsten durchwickt.
⚠ Der TP erbte das INDIREKT: er ist `2,0 × SL-Distanz`, skalierte also mit dem
Pivot-Abstand. Beide sind jetzt rein ATR-dynamisch (Mitte des Bandes = 2,0×ATR).

VORHER GEMESSEN (`backtest_sl_pivot.py`, 60k M5, 2 Halbjahre, gleiche
Einstiege, NUR die SL-Methode variiert, kanonischer Exit, Echtkosten):
    PIVOT  H1 -0,054 / H2 -0,040   PF 0,93 / 0,95
    FIX    H1 -0,040 / H2 +0,004   PF 0,95 / 1,01
FIX ist in BEIDEN Haelften besser (+0,014 / +0,043 R), der Worst-Case wird nicht
groesser - die vorab fixierte Regel ist erfuellt.
⚠ EHRLICH: beide KI enthalten die Null, es ist KEIN belegter Ertragsgewinn. Der
Punkt ist, dass die Verankerung nichts nuetzt UND den Stop auf das Level legt.
Das bestaetigt `backtest_sl_method.py` ("gedeckelt sind alle Methoden
gleichwertig") auf dem heutigen Exit - dort war es nur nie als NACHTEIL sichtbar.
Zurueck: `_SL_PIVOT_ANKER = True`.

(2) LUECKE AUS DEM ZWEI-SLOT-UMBAU, vom Deploy-Schritt 5 aufgedeckt:
"Auto-Squeeze-Entry fehlgeschlagen: Position bereits offen!". `engine.open_long`
routete IMMER auf `self.trader` - der Market-Fallback des Squeeze lief also
weiter ueber Slot 1 und scheiterte, sobald ein manueller Trade lief. FLAT-Check
und Pending-Manager waren gestern umgehaengt, der BESTELLWEG nicht.
Neu `_slot(source)`: auto_squeeze -> trader_brk, alles andere -> trader. Auch
der Bot-Marker (`_bot_open_ticket`) holt das Ticket jetzt vom richtigen Slot.
⚠ Genau die Fehlerklasse von Deployment-Drift Fall 8 - ein Umbau, der den
Bestellweg aendert, muss ALLE Bestellwege mitnehmen.

91 Tests gruen.

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

1087 lines
55 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
# ⚠ Pivot-Anker des Initial-SL — seit 2026-08-20 AUS (s. Begruendung in
# `_calc_sl_tp`). True stellt das alte Verhalten wieder her.
_SL_PIVOT_ANKER = False
class TradeManager:
def __init__(self, nur_ticket: bool = False):
"""`nur_ticket=True` => STRIKT an das eigene Ticket gebunden.
⚠⚠ DER KERN DES ZWEI-SLOT-BETRIEBS (2026-08-19). Der Default-Manager
ADOPTIERT jede Position auf dem Symbol (s. `_refresh_locked`) — das ist
gewollt, damit ein von Hand im Terminal eroeffneter Trade den vollen
Schutz-Stack bekommt. Laufen aber ZWEI Manager, wuerden sich beide
dieselbe Position greifen: Trailing zweimal auf einem Ticket, oder beide
auf dem falschen. Der BRK-Manager sucht sich deshalb NICHTS: ist sein
Ticket None, ist er flat und bleibt es, bis ihm eines zugewiesen wird.
Der bestehende Manager laeuft mit `False` bitgenau wie bisher."""
self.nur_ticket = bool(nur_ticket)
# ⚠⚠ Gegenstueck zu `nur_ticket`: der ADOPTIERENDE Manager muss die
# Tickets des anderen Slots in Ruhe lassen, sonst greift er die
# BRK-Position, sobald er selbst flat ist — und dann verwalten BEIDE
# dieselbe Position. Engine setzt hier einen Callable, der die aktuell
# fremden Tickets liefert.
self.fremde_tickets = None # Callable[[], set[int]] | None
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
# ⚠⚠ KEIN PIVOT-ANKER MEHR (2026-08-20, User-Fund "immer am S/R
# geschlossen"). Der SL wurde bis heute an den naechsten M15-Pivot
# gelegt — also fuer einen LONG direkt unter die UNTERSTUETZUNG, fuer
# einen SHORT ueber den WIDERSTAND — und erst danach auf 1,82,2×ATR
# geklemmt. Ueber 69 geloggte Orders war der Pivot in 4 % massgeblich
# (in 59 % kappte der Deckel, in 36 % weitete der Boden auf); in diesen
# Faellen lag der Stop exakt auf dem Level, an dem der Markt ohnehin am
# haeufigsten durchwickt.
# GEMESSEN mit dem HEUTIGEN Exit (`backtest_sl_pivot.py`, 60k M5,
# 2 Halbjahre, gleiche Einstiege, nur die SL-Methode variiert):
# PIVOT H1 0,054 / H2 0,040 PF 0,93 / 0,95
# FIX H1 0,040 / H2 +0,004 PF 0,95 / 1,01
# FIX ist in BEIDEN Haelften besser (+0,014 / +0,043 R), der
# Worst-Case wird nicht groesser. ⚠ Beide KI enthalten die Null — es
# ist KEIN belegter Ertragsgewinn. Der Punkt ist ein anderer: die
# Verankerung nuetzt nichts und legt den Stop auf das Level.
# ⚠ Das bestaetigt `backtest_sl_method.py` (gedeckelt sind alle
# Methoden gleichwertig, der Band-Cap dominiert) auf dem heutigen
# Exit — dort war es nur nie als NACHTEIL sichtbar.
# ZURUECK: `_SL_PIVOT_ANKER = True`.
if _SL_PIVOT_ANKER and 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)
elif otype == mt5.ORDER_TYPE_BUY:
piv = None
sl = entry_price - (INIT_SL_MIN_ATR + INIT_SL_MAX_ATR) / 2.0 * atr \
if atr 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
elif _SL_PIVOT_ANKER:
piv = pivot_high(sym, entry_price)
sl = (piv + buf) if piv else round(entry_price * (1 + INIT_SL_FALLBACK), si.digits)
else:
piv = None
sl = entry_price + (INIT_SL_MIN_ATR + INIT_SL_MAX_ATR) / 2.0 * atr if atr else round(entry_price * (1 + INIT_SL_FALLBACK), si.digits)
if max_dist and sl - entry_price > max_dist:
sl = entry_price + max_dist; capped = True
if min_dist and sl - entry_price < min_dist:
sl = entry_price + min_dist; floored = True
sl = round(sl, si.digits)
sl_dist = sl - entry_price
tp = round(entry_price - INIT_TP_RR * sl_dist, si.digits) if sl_dist > 0 else 0.0
_tfn = "M5" if atr_tf == mt5.TIMEFRAME_M5 else "M15"
src_str = f"M15-Pivot {piv:.3f}" if piv else "Fallback 1.2%"
if atr_tf:
src_str += f", ATR aus {_tfn}"
if capped:
src_str += f", gekappt auf {INIT_SL_MAX_ATR}xATR={max_dist:.3f}"
if floored:
src_str += f", auf min {INIT_SL_MIN_ATR}xATR={min_dist:.3f} aufgeweitet"
return sl, tp, src_str
def _send(self, sym, otype, atr_tf=None, buffer_pct=None):
with mt5_lock(timeout=15) as got:
if not got:
self.last_error = "MT5 belegt — bitte gleich nochmal"
return None, 0.0
return self._send_locked(sym, otype, atr_tf, buffer_pct)
def _send_locked(self, sym, otype, atr_tf=None, buffer_pct=None):
tick = get_tick(sym)
if not tick:
self.last_error = "Kein Tick"; return None, 0.0
price = tick.ask if otype == mt5.ORDER_TYPE_BUY else tick.bid
# SL ZUERST bestimmen → daraus risiko-basierte Lot-Größe (Verlust beim
# Initial-SL ≈ risk_pct der Equity). Margin bleibt Obergrenze. Fallback auf
# margin-basiert, wenn risk_pct=0 oder Daten fehlen. Behebt die großen
# EUR-Verluste aus 90 %-Margin × 2×ATR-SL.
sl, _tp, sl_src = self._calc_sl_tp(sym, otype, price, atr_tf)
risk = get_risk_per_trade()
sl_dist = abs(price - sl) if sl else None
# Eine manuell gesetzte Einsatz-Margin hat VORRANG vor dem Risiko-Modus
# (2026-08-04): sie ist eine ausdrückliche Größenvorgabe des Users, keine
# Rechenregel. `calc_lots` setzt sie um und deckelt auf die freie Margin.
if get_manual_margin() > 0:
risk = 0.0
if risk > 0:
# Risiko-Modus: KEIN stiller Fallback auf Margin-Sizing (75 % wäre ein
# Vielfaches des gewollten Risikos). Klappt die Risiko-Rechnung nicht
# (Daten fehlen / unter Mindestlot), wird der Trade abgelehnt.
lots = calc_lots_risk(sym, price, otype, sl_dist, risk)
if lots <= 0:
self.last_error = ("Risiko-Sizing nicht möglich (unter Mindestlot "
"oder Daten fehlen) — Trade abgelehnt")
return None, 0.0
else:
lots = calc_lots(sym, price, otype, buffer_pct) # margin-basiert (risk_pct=0)
if lots <= 0:
self.last_error = "Lot-Fehler"; return None, 0.0
req = {
"action": mt5.TRADE_ACTION_DEAL, "symbol": sym, "volume": float(lots),
"type": otype, "price": float(price), "deviation": DEVIATION,
"magic": MAGIC,
"comment": f"Widget-{'BUY' if otype == mt5.ORDER_TYPE_BUY else 'SELL'}",
"type_filling": get_filling(sym),
}
if sl:
req["sl"] = float(sl)
res = mt5.order_send(req)
for mode in (mt5.ORDER_FILLING_RETURN, mt5.ORDER_FILLING_IOC, mt5.ORDER_FILLING_FOK):
if res and res.retcode != 10030:
break
req["type_filling"] = mode
res = mt5.order_send(req)
if res and res.retcode == mt5.TRADE_RETCODE_DONE:
log_trade.info(
f"{'BUY' if otype == mt5.ORDER_TYPE_BUY else 'SELL'} "
f"{lots:.2f}L @ {price:.3f} T={res.order} SL={sl:.3f} ({sl_src})")
# tick_size/value/lots sofort cachen — sonst liefert live_pnl()
# bis zum ersten Positions-Tick (≤1 s) None und die P&L bleibt leer
si = mt5.symbol_info(sym)
with self._lock:
self.lots = float(lots); self._swap = 0.0; self._commission = 0.0
if si:
self._tick_size = si.trade_tick_size or self._tick_size or 0.001
self._tick_value = si.trade_tick_value or self._tick_value or 1.0
return res.order, float(price)
self.last_error = _retcode_msg(res)
return None, 0.0
# ══════════════════════════════════════════════════════════════════════
# PENDING STOP-ORDERS (Squeeze-Einstieg am Level, 2026-08-05)
# ══════════════════════════════════════════════════════════════════════
# WARUM: `backtest_squeeze_entry.py` hat gemessen, dass der Squeeze seinen
# gesamten Edge im EINSTIEGSPREIS hat. Die Market-Order auf dem 5-s-Poll füllt
# live im Median 0,275×ATR hinter dem Level (Mittel 0,686, Max 4,3) — damit
# dreht ØR von +0,456 auf 0,158. Eine ruhende Stop-Order deckelt den Preis
# konstruktiv: selbst mit 0,20×ATR unterstellter Fill-Slippage bleibt ØR
# +0,244/+0,426 in beiden Halbjahren.
def pending_orders(self, sym: str) -> list:
"""Eigene (magic-gleiche) Pending-Orders des Symbols."""
with mt5_lock(timeout=5) as got:
if not got:
return []
try:
orders = mt5.orders_get(symbol=sym) or ()
except Exception:
return []
return [o for o in orders if getattr(o, "magic", 0) == MAGIC]
def place_stop(self, sym: str, otype: int, trigger: float,
comment: str = "Widget-SQZ",
atr_tf=None, buffer_pct=None) -> tuple[int | None, str]:
"""Pending BUY_STOP/SELL_STOP am Ausbruchs-Level. → (ticket, Fehlertext).
Größe und SL werden mit DERSELBEN Logik bestimmt wie bei der Market-Order
(`calc_lots` / `_calc_sl_tp`), nur bezogen auf den Trigger-Preis statt auf
den aktuellen Tick — sonst wäre der SL-Abstand ein anderer als validiert.
⚠ Broker-Mindestabstand (`trade_stops_level`) wird geprüft: liegt der Kurs
schon zu nah am Trigger, lehnt MT5 die Order mit einem kryptischen retcode
ab. Dann meldet die Funktion das sauber, und der Aufrufer entscheidet."""
with mt5_lock(timeout=15) as got:
if not got:
return None, "MT5 belegt"
si = mt5.symbol_info(sym)
tick = get_tick(sym)
if not si or not tick:
return None, "Kein Symbol/Tick"
ist_long = otype == mt5.ORDER_TYPE_BUY_STOP
trigger = round(float(trigger), si.digits)
# Stop-Order muss auf der richtigen Seite des Marktes liegen …
bezug = tick.ask if ist_long else tick.bid
if (trigger <= bezug) if ist_long else (trigger >= bezug):
return None, f"Kurs hat das Level bereits passiert ({bezug:.3f})"
# … und den Broker-Mindestabstand einhalten
mind = (getattr(si, "trade_stops_level", 0) or 0) * (si.point or 0.001)
if mind and abs(trigger - bezug) < mind:
return None, (f"unter Broker-Mindestabstand "
f"({abs(trigger-bezug):.3f} < {mind:.3f})")
sl, _tp, sl_src = self._calc_sl_tp(sym, mt5.ORDER_TYPE_BUY if ist_long
else mt5.ORDER_TYPE_SELL, trigger,
atr_tf)
lots = calc_lots(sym, trigger,
mt5.ORDER_TYPE_BUY if ist_long else mt5.ORDER_TYPE_SELL,
buffer_pct)
if lots <= 0:
return None, "Lot-Fehler"
req = {"action": mt5.TRADE_ACTION_PENDING, "symbol": sym,
"volume": float(lots), "type": otype, "price": float(trigger),
"magic": MAGIC, "comment": comment,
"type_time": mt5.ORDER_TIME_GTC,
"type_filling": get_filling(sym)}
if sl:
req["sl"] = float(sl)
res = mt5.order_send(req)
for mode in (mt5.ORDER_FILLING_RETURN, mt5.ORDER_FILLING_IOC,
mt5.ORDER_FILLING_FOK):
if res and res.retcode != 10030:
break
req["type_filling"] = mode
res = mt5.order_send(req)
if res and res.retcode == mt5.TRADE_RETCODE_DONE:
log_trade.info(f"PENDING {'BUY_STOP' if ist_long else 'SELL_STOP'} "
f"{lots:.2f}L @ {trigger:.3f} T={res.order} "
f"SL={sl:.3f} ({sl_src})")
return res.order, ""
return None, _retcode_msg(res)
def cancel_pending(self, ticket: int) -> str:
"""Eine Pending-Order löschen. → "" bei Erfolg, sonst Fehlertext."""
with mt5_lock(timeout=15) as got:
if not got:
return "MT5 belegt"
res = mt5.order_send({"action": mt5.TRADE_ACTION_REMOVE,
"order": int(ticket)})
if res and res.retcode == mt5.TRADE_RETCODE_DONE:
log_trade.info(f"PENDING storniert T={ticket}")
return ""
return _retcode_msg(res)
def open_long(self, sym, atr_tf=None, buffer_pct=None):
with self._lock:
if self.ticket:
return "Position bereits offen!"
t, e = self._send(sym, mt5.ORDER_TYPE_BUY, atr_tf, buffer_pct)
if not t:
return self.last_error
with self._lock:
self.ticket = t; self.order_type = mt5.ORDER_TYPE_BUY
self.entry_price = e; self.symbol = sym
self._log_open(t, sym, "BUY", e)
return ""
def open_short(self, sym, atr_tf=None, buffer_pct=None):
with self._lock:
if self.ticket:
return "Position bereits offen!"
t, e = self._send(sym, mt5.ORDER_TYPE_SELL, atr_tf, buffer_pct)
if not t:
return self.last_error
with self._lock:
self.ticket = t; self.order_type = mt5.ORDER_TYPE_SELL
self.entry_price = e; self.symbol = sym
self._log_open(t, sym, "SELL", e)
return ""
def close(self, reason: str = "manual"):
with mt5_lock(timeout=15) as got:
if not got:
return "MT5 belegt — bitte gleich nochmal"
return self._close_locked(reason)
def _close_locked(self, reason: str = "manual"):
with self._lock:
ticket = self.ticket; sym = self.symbol; otype = self.order_type
if not ticket:
return "Keine offene Position."
positions = mt5.positions_get(ticket=ticket)
if not positions:
with self._lock:
self.ticket = None
return "Position bereits geschlossen."
pos = positions[0]
tick = get_tick(sym)
if not tick:
return "Kein Tick."
ct = mt5.ORDER_TYPE_SELL if otype == mt5.ORDER_TYPE_BUY else mt5.ORDER_TYPE_BUY
cp = tick.bid if otype == mt5.ORDER_TYPE_BUY else tick.ask
req = {
"action": mt5.TRADE_ACTION_DEAL, "symbol": sym, "volume": float(pos.volume),
"type": ct, "position": ticket, "price": float(cp), "deviation": DEVIATION,
"magic": MAGIC, "comment": "Widget-CLOSE",
"type_filling": get_filling(sym),
}
res = mt5.order_send(req)
for mode in (mt5.ORDER_FILLING_RETURN, mt5.ORDER_FILLING_IOC, mt5.ORDER_FILLING_FOK):
if res and res.retcode != 10030:
break
req["type_filling"] = mode
res = mt5.order_send(req)
if res and res.retcode == mt5.TRADE_RETCODE_DONE:
log_trade.info(f"CLOSE T={ticket} @ {cp:.3f} ({reason})")
self._log_close(ticket, cp, pos.profit, reason)
with self._lock:
self.ticket = None; self.order_type = None
self.entry_price = 0.0; self.lots = 0.0
self.pnl = 0.0; self.cur_price = 0.0; self.sl = self.tp = self.margin = 0.0
self.open_time = 0.0
self._swap = 0.0; self._tick_size = None; self._tick_value = None
return ""
return "Close: " + _retcode_msg(res)
def partial_close_position(self, pos, si, frac: float = 0.5,
reason: str = "partial"):
"""
Schließt `frac` des Volumens einer offenen Position (Teil-Exit / Runner).
CALLER MUSS den globalen mt5_lock bereits halten (wird vom Trailing
innerhalb von _do_modify aufgerufen).
Rückgabe: (geschlossenes_volumen, schlusskurs) bei Erfolg,
sonst (0.0, fehlertext).
Die realisierte Teil-PnL wird NICHT separat geloggt — sie steckt als
eigener Deal an derselben position_id und wird beim finalen Close über
_log_external_close in die Gesamt-PnL des Trades aufsummiert.
"""
sym = getattr(pos, "symbol", self.symbol)
step = si.volume_step or 0.01
vmin = si.volume_min or step
full = float(pos.volume)
vol_close = round(round((full * frac) / step) * step, 8)
# Beide Seiten müssen >= Mindestvolumen bleiben — sonst kein Teil-Exit
if vol_close < vmin or (full - vol_close) < vmin:
return 0.0, "Volumen zu klein zum Teilen"
tick = get_tick(sym)
if not tick:
return 0.0, "Kein Tick"
otype = pos.type
ct = mt5.ORDER_TYPE_SELL if otype == mt5.ORDER_TYPE_BUY else mt5.ORDER_TYPE_BUY
cp = tick.bid if otype == mt5.ORDER_TYPE_BUY else tick.ask
req = {
"action": mt5.TRADE_ACTION_DEAL, "symbol": sym,
"volume": float(vol_close), "type": ct, "position": pos.ticket,
"price": float(cp), "deviation": DEVIATION, "magic": MAGIC,
"comment": "Widget-PARTIAL", "type_filling": get_filling(sym),
}
res = mt5.order_send(req)
for mode in (mt5.ORDER_FILLING_RETURN, mt5.ORDER_FILLING_IOC,
mt5.ORDER_FILLING_FOK):
if res and res.retcode != 10030:
break
req["type_filling"] = mode
res = mt5.order_send(req)
if res and res.retcode == mt5.TRADE_RETCODE_DONE:
log_trade.info(
f"TEIL-EXIT ({reason}): {vol_close:.2f}L von {full:.2f}L "
f"@ {cp:.3f} T={pos.ticket}")
with self._lock:
self.lots = max(full - vol_close, 0.0)
return vol_close, float(cp)
return 0.0, f"retcode={res.retcode if res else 'None'}"
def set_open_context(self, *, ai_sentiment=None, ai_confidence=None,
rec_signal=None, rec_score=None,
setup=None, regime=None, rsi=None, news_score=None,
ctx=None):
# `ctx` = voller Entscheidungszustand (2026-08-04, s. `history.log_trade_open`)
self._open_context = {
"ai_sentiment": ai_sentiment, "ai_confidence": ai_confidence,
"rec_signal": rec_signal, "rec_score": rec_score,
"setup": setup, "regime": regime, "rsi": rsi, "news_score": news_score,
"ctx": ctx or {},
}
def _log_open(self, ticket: int, sym: str, direction: str, entry_price: float):
if not self.history:
return
ctx = dict(self._open_context)
def delayed_log():
time.sleep(0.5)
sl = tp = None
try:
# Eigener Thread → MT5-Call MUSS über den globalen Lock laufen
# (sonst Race gegen copy_rates/positions_get der anderen Loops).
with mt5_lock(timeout=5) as got:
positions = mt5.positions_get(ticket=ticket) if got else None
if positions:
sl = float(positions[0].sl) or None
tp = float(positions[0].tp) or None
except Exception as e:
log_hist.warning(f"SL/TP-Lookup: {e}")
try:
self.history.log_trade_open(
ticket=ticket, symbol=sym, direction=direction,
lots=float(self.lots) or 0.0, entry_price=entry_price,
sl_at_entry=sl, tp_at_entry=tp,
ai_sentiment=ctx.get("ai_sentiment"),
ai_confidence=ctx.get("ai_confidence"),
rec_signal=ctx.get("rec_signal"),
rec_score=ctx.get("rec_score"),
setup=ctx.get("setup"), regime=ctx.get("regime"),
rsi_at_entry=ctx.get("rsi"), news_score=ctx.get("news_score"),
# Voller Entscheidungszustand (2026-08-04, „Manuelle Trades")
ctx=ctx.get("ctx"),
)
except Exception as e:
log_hist.error(f"log_trade_open: {e}")
threading.Thread(target=delayed_log, daemon=True).start()
def _log_close(self, ticket: int, exit_price: float, pnl: float, closed_by: str):
if not self.history:
return
try:
self.history.log_trade_close(
ticket=ticket, exit_price=exit_price, pnl=pnl, closed_by=closed_by,
)
except Exception as e:
log_hist.error(f"log_trade_close: {e}")
def bind(self, ticket: int, sym: str) -> bool:
"""Dem ticket-gebundenen Slot eine bestehende Position zuweisen.
Gedacht fuer den Pending-Fill: die Order fuellt IM BROKER, es gibt also
keinen `open_*`-Aufruf, ueber den der Manager sein Ticket bekaeme.
⚠ Nur fuer `nur_ticket`-Manager — der adoptierende braucht es nicht."""
with mt5_lock(timeout=10) as got:
if not got:
return False
pos = mt5.positions_get(ticket=int(ticket))
if not pos:
return False
pk = pos[0]
_boff = self._broker_offset_s(getattr(pk, "symbol", sym))
with self._lock:
self.ticket = pk.ticket; self.order_type = pk.type
self.entry_price = pk.price_open
self.symbol = getattr(pk, "symbol", sym)
self.lots = pk.volume; self.pnl = pk.profit
self.sl = float(getattr(pk, "sl", 0.0) or 0.0)
self.tp = float(getattr(pk, "tp", 0.0) or 0.0)
self.open_time = float(getattr(pk, "time", 0) or 0) - _boff
return True
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 and self.nur_ticket:
# Strikt ticket-gebunden: KEINE Adoption. Ohne eigenes Ticket flat.
return
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:
fremd = set()
if self.fremde_tickets:
try:
fremd = {int(x) for x in (self.fremde_tickets() or ())}
except Exception:
fremd = set()
all_pos = [p for p in all_pos if int(p.ticket) not in fremd]
if not all_pos:
return
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),
)