#!/usr/bin/env python3 """Fehlgebuchte Trade-Closes gegen die BROKER-Deals korrigieren. ANLASS (2026-08-12). Der Tages-P&L zeigte **+14,26 €**, der Broker hatte **−74,08 €** realisiert. Die Formel war richtig, die DATENBASIS nicht: der Fallback-Pfad in `trader._log_external_close` buchte Positionen als geschlossen, obwohl gar kein OUT-Deal vorlag (transient leeres `positions_get` direkt nach dem Oeffnen). Weil `log_trade_close` nur Zeilen mit `exit_time IS NULL` anfasste, lief der SPAETERE, echte Close ins Leere. Beide Ursachen sind im Code behoben — dieses Skript raeumt die Altlast auf. ⚠⚠ NUR EINDEUTIGE FAELLE. Korrigiert wird ausschliesslich, wo (a) die DB-Zeile `closed_by='unknown'` traegt (= Fallback-Buchung), UND (b) der Broker fuer dieselbe Position einen echten OUT-Deal hat. Alles andere wird nur BERICHTET, nicht angefasst — eine Massenkorrektur auf Verdacht waere schlimmer als die Altlast. ⚠ Standard ist der TROCKENLAUF. Schreiben nur mit `--schreiben`, und dann nur nach einem Backup (`oil_widget_history.db.bak-`), das das Skript selbst anlegt. Aufruf: python tools/repair_closes.py [--tage N] [--schreiben] """ from __future__ import annotations import argparse import datetime as dt import os import shutil import sqlite3 import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import MetaTrader5 as mt5 # noqa: E402 HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) DB = os.path.join(HERE, "oil_widget_history.db") _BROKER = dt.timezone(dt.timedelta(hours=3)) def broker_outs(tage: int) -> dict: """{position_id: (exit_ts_lokal, preis, pnl, kommission, grund)} aus den Deals.""" mt5.initialize() # ⚠⚠ ZEITFALLE (korrigiert 2026-08-12): `history_deals_get` filtert nach # BROKER-Wallclock. Wer `datetime`-Objekte uebergibt, verschiebt das # Fenster um den Offset und verliert die juengsten Deals. Und wer die # Rueckgabe mit `fromtimestamp(d.time, BROKER)` rendert, sieht sie **3 h # zu spaet** — real fiel mir das auf, weil eine Deal-Zeit „23:11" lautete, # das Log aber „20:11:42 CLOSE" sagte. # ➜ Epochs uebergeben (lokal + Offset), und beim Lesen `d.time - Offset`. _OFF = 10800 bis = int(dt.datetime.now().timestamp()) + _OFF von = bis - tage * 86400 deals = mt5.history_deals_get(von, bis + 300) gruende = {} try: gruende = {mt5.DEAL_REASON_SL: "sl", mt5.DEAL_REASON_TP: "tp", mt5.DEAL_REASON_CLIENT: "manual", mt5.DEAL_REASON_EXPERT: "manual", mt5.DEAL_REASON_MOBILE: "manual", mt5.DEAL_REASON_WEB: "manual"} except AttributeError: pass mt5.shutdown() if not deals: return {} # ⚠ Kommission/Swap fallen auch am IN-Deal an → je Position ALLE Deals summieren. proj: dict = {} for d in deals: pid = getattr(d, "position_id", 0) if not pid: continue p = proj.setdefault(pid, {"pnl": 0.0, "komm": 0.0, "out": None}) p["pnl"] += d.profit + d.swap + d.commission p["komm"] += d.commission if d.entry == mt5.DEAL_ENTRY_OUT: p["out"] = d out = {} for pid, p in proj.items(): d = p["out"] if d is None: continue # Position noch offen → nichts zu tun # deal.time ist BROKER-Wallclock als Epoch → in lokale Epoch umrechnen ts = int(d.time) - 10800 out[pid] = (ts, float(d.price), round(p["pnl"], 2), round(p["komm"], 2), gruende.get(getattr(d, "reason", None), "unknown")) return out def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--tage", type=int, default=7) ap.add_argument("--schreiben", action="store_true") a = ap.parse_args() bro = broker_outs(a.tage) print(f" Broker: {len(bro)} Positionen mit OUT-Deal in {a.tage} Tagen") db = sqlite3.connect(DB); db.row_factory = sqlite3.Row seit = dt.datetime.now().timestamp() - a.tage * 86400 rows = [dict(r) for r in db.execute( "SELECT ticket,exit_time,exit_price,pnl,closed_by FROM trades " "WHERE exit_time IS NOT NULL AND exit_time >= ?", (seit,))] fix, hinweis = [], [] for r in rows: b = bro.get(r["ticket"]) if not b: continue ts, px, pnl, komm, grund = b gleich = (abs((r["pnl"] or 0) - pnl) < 0.01 and abs((r["exit_price"] or 0) - px) < 0.0005) if gleich: continue (fix if r["closed_by"] == "unknown" else hinweis).append((r, b)) # Zeilen, die der Broker heute schloss, in der DB aber auf einem anderen Tag stehen for pid, b in bro.items(): r = db.execute("SELECT ticket,exit_time,exit_price,pnl,closed_by FROM trades " "WHERE ticket=?", (pid,)).fetchone() if r and r["closed_by"] == "unknown" and dict(r) not in [x[0] for x in fix]: d = dict(r) if abs((d["pnl"] or 0) - b[2]) >= 0.01 and not any(x[0]["ticket"] == pid for x in fix): fix.append((d, b)) def zeig(titel, liste): print(f"\n {titel} ({len(liste)})") if not liste: return print(f" {'Ticket':<12}{'DB pnl':>10}{'Broker':>10}{'Δ':>10} {'DB Zeit':<14}" f"{'Broker Zeit':<14}{'by'}") for r, b in liste: f = lambda t: dt.datetime.fromtimestamp(t).strftime("%d.%m %H:%M") print(f" {r['ticket']:<12}{r['pnl'] or 0:>+10.2f}{b[2]:>+10.2f}" f"{b[2]-(r['pnl'] or 0):>+10.2f} {f(r['exit_time']):<14}" f"{f(b[0]):<14}{r['closed_by']} → {b[4]}") zeig("KORRIGIERBAR (closed_by='unknown' + echter OUT-Deal)", fix) zeig("⚠ NUR HINWEIS — Abweichung, aber KEINE Fallback-Buchung", hinweis) if not a.schreiben: print(f"\n TROCKENLAUF — nichts geschrieben. Mit `--schreiben` ausfuehren.") return 0 if not fix: print("\n nichts zu korrigieren.") return 0 bak = DB + ".bak-" + dt.datetime.now().strftime("%Y-%m-%d-repair") shutil.copy2(DB, bak) print(f"\n Backup: {os.path.basename(bak)}") n = 0 for r, b in fix: ts, px, pnl, komm, grund = b db.execute("UPDATE trades SET exit_time=?, exit_price=?, pnl=?, closed_by=?, " "commission=? WHERE ticket=?", (ts, px, pnl, grund, komm, r["ticket"])) n += db.total_changes and 1 db.commit() print(f" {len(fix)} Zeilen korrigiert.") return 0 if __name__ == "__main__": raise SystemExit(main())