Tag P/L war falsch: Fehlbuchungen beim Schliessen behoben + repariert

User-Frage "ueberpruefe ob Tag P/L richtig berechnet wird". Die Formel
(realisiert + offen) war bitgenau richtig - die Datenbasis nicht:
Anzeige +14,26 EUR gegen -74,08 EUR realisiert beim Broker.

Ursache, im Log im Sekundentakt (#49926460): 20:04:10 eroeffnet ->
20:04:10 "extern geschlossen" -> "kein OUT-Deal in 1 Deals" ->
Fallback bucht 0,00 -> 20:11:42 der echte Close lief ins Leere.
Drei Defekte: (1) positions_get sieht die frische Position einen Tick
lang nicht, (2) _log_external_close buchte TROTZ "kein OUT-Deal" einen
Close - dabei ist genau das der Beweis, dass sie noch offen ist,
(3) log_trade_close fasste nur exit_time IS NULL an, die Fehlbuchung
blockierte den echten Close dauerhaft. Bei #49852362 kostete das
+5,87 statt -95,38 EUR und den falschen Tag.

Behoben: (1) Abbruch statt Fallback bei "kein OUT-Deal"; (2) eine
erkennbare Fehlbuchung (closed_by='unknown') darf von einem echten
Close korrigiert werden - eng gefasst, gute Zeilen bleiben unberuehrt.
4 Tests inkl. Gegenprobe.

Altlast: tools/repair_closes.py (Trockenlauf Standard, Backup
automatisch). 18 Zeilen ueber 60 Tage korrigiert. Heute von +7,63 auf
-72,67 (Restfehler 1,41). Gesamt-P&L von -282,78 auf +169,98.

Zeitfalle zweimal getroffen: history_deals_get filtert nach
Broker-Wallclock, und fromtimestamp(d.time, BROKER) rendert 3 h zu
spaet. Aufgefallen nur, weil eine Deal-Zeit 23:11 lautete, das Log
aber 20:11:42 sagte.

Statistik-Modul separat geprueft: Arithmetik in allen drei Zeitraeumen
bitgenau korrekt (Abweichung 0,00). Der Netto-Defekt vom 05.08. besteht
weiter und ist groesser als damals: angezeigt -2.449,95 EUR, real
verblieben -17,06 (99 % der Steuer werden erstattet). Nicht gebaut -
die Loesung ist dokumentiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Axel Hocks
2026-08-12 20:29:22 +02:00
co-authored by Claude Opus 5
parent 19ee379f20
commit d8839ef7c5
5 changed files with 369 additions and 2 deletions
+159
View File
@@ -0,0 +1,159 @@
#!/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-<datum>`), 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())