Files
AH-Oil-Trader/tools/repair_closes.py
T
Axel HocksandClaude Opus 5 d8839ef7c5 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>
2026-08-12 20:29:22 +02:00

160 lines
6.5 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.
#!/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())