diff --git a/CLAUDE.md b/CLAUDE.md index d5867b5..2dde356 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6209,6 +6209,34 @@ aber „20:11:42 CLOSE" sagte. ➜ **Epochs übergeben (lokal + Offset), beim Le Moment des Closes, der Broker den Fill). Die Summe hebt sich fast auf (+1,66 € über 19 Trades) → nur berichtet, **nicht** automatisch korrigiert. +## ✅✅ NETTO-RECHNUNG AUS DEN ECHTEN STEUER-BUCHUNGEN (2026-08-12) + +Der seit dem 05.08. dokumentierte Defekt ist behoben. `server._add_net` schaetzte +`net_pnl = total_pnl − gross_win × 26,375 %` und unterstellte, die Quellensteuer +sei endgueltig verloren. Sie wird aber taeglich per `Tax settlement` erstattet. +| Zeitraum | total_pnl | wht (ECHT) | net_pnl | einbehalten / erstattet | +|---|---|---|---|---| +| today | −32,72 | −68,93 | **+36,21** | −21,03 / **+89,96** | +| week | −1.235,26 | −432,88 | −802,38 | −425,71 / +858,59 | +| **all** | **+209,93** | **+28,93** | **+181,00** | −2.754,56 / **+2.725,63** | +⚠⚠ **Vorher stand bei `all` −2.449,95 €** — eine Verzerrung von rund **2.630 €**, +die aus einem profitablen Konto ein verlustreiches machte. +✅ Die Schaetzung der EINBEHALTENEN Summe war fast exakt (2.620 gegen 2.755); +falsch war allein die Annahme, sie bleibe weg — real werden **99 %** erstattet. + +**Umsetzung:** `trader.steuer_buchungen(von, bis)` liest die `WHT #…`- und +`Tax settlement`-Deals direkt (position_id == 0, damit sauber von den Trade-Deals +getrennt — kein Doppelzaehlen). Unter `mt5_lock`, **5 min gecacht** (die Abfrage +ueber lange Zeitraeume braucht Sekunden und gehoert nicht in jeden Aufruf). +⚠ **Fail-safe:** antwortet MT5 nicht, faellt es auf die alte Schaetzung zurueck — +das Feld **`wht_quelle`** sagt, welche Zahl drinsteht („echt" / „geschaetzt"). +⚠ **KURZE ZEITRAEUME sind verzerrt, in BEIDE Richtungen:** die Erstattung kommt +erst am Folgetag. Bei „today" oben ist `wht` sogar **negativ** (−68,93), weil die +Erstattung von GESTERN heute einging — das Netto liegt damit ueber dem Brutto. +Das ist korrekt gebucht, aber als Tageszahl irrefuehrend. Das Flag `wht_lag` +faengt bisher nur die eine Richtung (einbehalten, nichts erstattet). +⚠ Zeitfalle beachtet: Epochs mit Broker-Offset uebergeben, beim Lesen abziehen. + ## ✅ STATISTIK-MODUL: ARITHMETIK KORREKT, NETTO-RECHNUNG WEITER DEFEKT (2026-08-12) Gegen unabhängige SQL geprüft, alle drei Zeiträume: **Abweichung 0,00 in jeder diff --git a/core/trader.py b/core/trader.py index b0e92a1..3d30f73 100644 --- a/core/trader.py +++ b/core/trader.py @@ -826,6 +826,65 @@ class TradeManager: 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 diff --git a/server.py b/server.py index 00a3ea4..f69d6cd 100644 --- a/server.py +++ b/server.py @@ -20,6 +20,7 @@ Aktuell (Schritt 1): nur Read-only-Endpoints. GET /api/snapshot → kompletter Live-Zustand (Markt/Position/Analyse/Agent) """ from __future__ import annotations +import time import asyncio import contextlib import secrets @@ -151,20 +152,49 @@ def logs(lines: int = 200): return {"error": str(e), "lines": []} -def _add_net(stats: dict, rate: float) -> dict: +def _add_net(stats: dict, rate: float, trader=None) -> dict: """Ergänzt eine stats_overview-Periode um Netto-Werte nach Quellensteuer. - WHT wird nur auf die Gewinnseite (gross_win) erhoben, Verluste bleiben voll.""" + + ⚠⚠ SEIT 2026-08-12 AUS DEN **ECHTEN** BUCHUNGEN, nicht mehr geschätzt. + Die alte Rechnung war `net_pnl = total_pnl − gross_win × 26,375 %` und + unterstellte, die WHT sei endgültig verloren. Sie wird aber täglich per + `Tax settlement` erstattet — gemessen über 90 Tage zu **99 %** + (einbehalten −2.625,89 €, erstattet +2.608,83 €, verblieben −17,06 €). + Die Schätzung sagte −2.619,93 € und machte damit aus **+153 € rund + −2.450 €**. ✅ Die Schätzung der EINBEHALTENEN Summe war fast exakt — + falsch war allein die Annahme, sie bleibe weg. + ⚠ Fällt der Broker aus, wird auf die alte Schätzung zurückgefallen; das Feld + `wht_quelle` sagt, welche Zahl gerade drinsteht („echt" / „geschätzt"). + ⚠ KURZE ZEITRÄUME sind naturgemäss pessimistisch: die Erstattung kommt erst + am Folgetag. Für „today" fehlt sie also meist — deshalb `wht_lag`. + """ if not stats or stats.get("error"): return stats gw = stats.get("gross_win", 0.0) or 0.0 gl = stats.get("gross_loss", 0.0) or 0.0 - wht = gw * rate - net_gw = gw - wht + tp = stats.get("total_pnl", 0.0) or 0.0 + echt = None + if trader is not None: + try: + echt = trader.steuer_buchungen(int(stats.get("since") or 0), + int(stats.get("until") or time.time())) + except Exception: + echt = None + if echt and echt.get("n"): + wht = -float(echt["netto"]) # netto ist negativ → Last positiv + stats["wht_quelle"] = "echt" + stats["wht_brutto"] = echt["wht"] + stats["wht_erstattet"] = echt["tax"] + stats["wht_lag"] = bool(echt["tax"] == 0 and echt["wht"] < 0) + else: + wht = gw * rate + stats["wht_quelle"] = "geschätzt" stats["wht_pct"] = round(rate * 100, 3) - stats["wht"] = wht - stats["net_pnl"] = (stats.get("total_pnl", 0.0) or 0.0) - wht - stats["net_profit_factor"] = (net_gw / gl) if gl > 0 else None - stats["net_avg_win"] = (stats.get("avg_win", 0.0) or 0.0) * (1 - rate) + stats["wht"] = round(wht, 2) + stats["net_pnl"] = round(tp - wht, 2) + stats["net_profit_factor"] = ((gw - wht) / gl) if gl > 0 else None + stats["net_avg_win"] = (stats.get("avg_win", 0.0) or 0.0) * ( + 1 - (wht / gw if gw > 0 else 0.0)) return stats @@ -187,7 +217,8 @@ def stats(): out: dict = {"wht_pct": round(rate * 100, 3)} for period in ("today", "week", "all"): try: - out[period] = _add_net(eng.history.stats_overview(period), rate) + out[period] = _add_net(eng.history.stats_overview(period), rate, + getattr(eng, "trader", None)) except Exception as e: out[period] = {"error": str(e)} try: