Headless FastAPI-Backend (server.py + core/engine.py) mit Mobile-PWA (web/), Strategie-/Backtest-Suite und Doku. Secrets, DB, Logs und Laufzeit-State sind via .gitignore ausgeschlossen; Config-Vorlage: oil_widget_config.ini.example. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
162 lines
7.4 KiB
Python
162 lines
7.4 KiB
Python
#!/usr/bin/env python3
|
||
"""Ausführungs-Analyse (Track B, Mess-Kalibrierung — KEINE Strategie-Änderung):
|
||
A) Kosten-Profil: echter Spread je Berlin-Stunde (Bar-Feld `spread`, in Points)
|
||
absolut und als ×ATR — ersetzt die pauschale 0,1×ATR-Kostenannahme.
|
||
B) Slippage: SL-Closes, die SCHLECHTER als der Initial-SL ausgeführt wurden
|
||
(sichere Untergrenze der echten Slippage; getrailte SLs sind nicht rekonstruierbar).
|
||
C) MAE/MFE der Live-Trades: wie weit liefen echte Trades ins Minus (MAE) und
|
||
ins Plus (MFE), normiert auf ATR≈|Entry−InitialSL|/2 — validiert SL-Band,
|
||
Breakeven-Schwelle (1,3) und Trailing an LIVE-Daten statt Simulation.
|
||
Aufruf: python analyze_execution.py [m5_bars] [m1_bars]
|
||
"""
|
||
import sys, sqlite3, datetime as dt
|
||
from zoneinfo import ZoneInfo
|
||
from collections import defaultdict
|
||
import MetaTrader5 as mt5
|
||
|
||
_BROKER_OFF = 3 * 3600
|
||
_BERLIN = ZoneInfo("Europe/Berlin")
|
||
DB = "oil_widget_history.db"
|
||
|
||
def bhour(broker_ts):
|
||
return dt.datetime.fromtimestamp(int(broker_ts) - _BROKER_OFF,
|
||
tz=dt.timezone.utc).astimezone(_BERLIN).hour
|
||
|
||
def _atr_series(H, L, C, p=14):
|
||
t = [0.0]
|
||
for i in range(1, len(C)):
|
||
t.append(max(H[i]-L[i], abs(H[i]-C[i-1]), abs(L[i]-C[i-1])))
|
||
out = []
|
||
for i in range(len(C)):
|
||
w = t[max(1, i-p+1):i+1]
|
||
out.append(sum(w)/len(w) if w else None)
|
||
return out
|
||
|
||
def main():
|
||
n5 = int(sys.argv[1]) if len(sys.argv) > 1 else 80000
|
||
n1 = int(sys.argv[2]) if len(sys.argv) > 2 else 100000
|
||
mt5.initialize()
|
||
sym = None
|
||
for c in ("SpotCrude", "USOIL", "WTI", "XTIUSD"):
|
||
if mt5.symbol_info(c):
|
||
sym = c; break
|
||
si = mt5.symbol_info(sym)
|
||
point = si.point
|
||
bars5 = None
|
||
for req in (n5, 80000, 60000, 40000):
|
||
bars5 = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, req)
|
||
if bars5 is not None and len(bars5) > 2000: break
|
||
bars1 = None
|
||
for req in (n1, 60000, 30000, 15000):
|
||
bars1 = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M1, 0, req)
|
||
if bars1 is not None and len(bars1) > 1000: break
|
||
mt5.shutdown()
|
||
|
||
# ── A) Kosten-Profil: Spread je Berlin-Stunde ────────────────────────────
|
||
print("=" * 84)
|
||
print(f" A) KOSTEN-PROFIL — {sym}, {len(bars5)} M5-Bars, Spread aus Bar-Feld (Points×{point})")
|
||
print("=" * 84)
|
||
H = [float(b["high"]) for b in bars5]; L = [float(b["low"]) for b in bars5]
|
||
C = [float(b["close"]) for b in bars5]
|
||
AT = _atr_series(H, L, C)
|
||
byh = defaultdict(list)
|
||
for i, b in enumerate(bars5):
|
||
atr = AT[i]
|
||
if not atr or atr <= 0: continue
|
||
sp = float(b["spread"]) * point
|
||
if sp <= 0: continue
|
||
byh[bhour(b["time"])].append((sp, sp / atr))
|
||
print(f" {'Std':>3} {'Ø-Spread':>9} {'×ATR':>6} (Kostenannahme bisher pauschal 0,10×ATR)")
|
||
tot = []
|
||
for h in range(24):
|
||
v = byh.get(h)
|
||
if not v: continue
|
||
sp = sum(x[0] for x in v)/len(v); rel = sum(x[1] for x in v)/len(v)
|
||
tot += v
|
||
mark = " ⚠ teuer" if rel > 0.15 else (" günstig" if rel < 0.07 else "")
|
||
print(f" {h:>3} {sp:>9.4f} {rel:>6.3f}{mark}")
|
||
if tot:
|
||
print(f" ALLE Ø-Spread {sum(x[0] for x in tot)/len(tot):.4f} · {sum(x[1] for x in tot)/len(tot):.3f}×ATR")
|
||
|
||
# ── Trades laden (B + C) ─────────────────────────────────────────────────
|
||
con = sqlite3.connect(DB); con.row_factory = sqlite3.Row
|
||
rows = [dict(r) for r in con.execute(
|
||
"SELECT * FROM trades WHERE exit_time IS NOT NULL AND entry_price IS NOT NULL "
|
||
"ORDER BY entry_time")]
|
||
|
||
# ── B) Slippage bei SL-Closes ────────────────────────────────────────────
|
||
print("\n" + "=" * 84)
|
||
print(" B) SLIPPAGE — SL-Closes schlechter als der Initial-SL (sichere Untergrenze)")
|
||
print("=" * 84)
|
||
slips = []
|
||
n_sl = 0
|
||
for r in rows:
|
||
if r["closed_by"] != "sl" or not r["sl_at_entry"]: continue
|
||
n_sl += 1
|
||
d = 1 if r["direction"] in ("BUY", "LONG", "buy") else -1
|
||
gap = (r["sl_at_entry"] - r["exit_price"]) * d # >0 = schlechter als Initial-SL
|
||
if gap > 0:
|
||
slips.append((gap, r))
|
||
print(f" SL-Closes gesamt: {n_sl} · davon SCHLECHTER als Initial-SL: {len(slips)}")
|
||
if slips:
|
||
gaps = [g for g, _ in slips]
|
||
gaps.sort()
|
||
print(f" Slippage: Ø {sum(gaps)/len(gaps):.3f} · Median {gaps[len(gaps)//2]:.3f} "
|
||
f"· Max {max(gaps):.3f} (Preis-Punkte)")
|
||
worst = sorted(slips, key=lambda x: -x[0])[:5]
|
||
for g, r in worst:
|
||
t = dt.datetime.fromtimestamp(r["entry_time"]).strftime("%d.%m %H:%M")
|
||
print(f" {t} {r['direction']:<4} SL {r['sl_at_entry']:.3f} → Exit "
|
||
f"{r['exit_price']:.3f} Slippage {g:.3f} (netto {((r['pnl'] or 0)+(r['commission'] or 0)):+.2f})")
|
||
|
||
# ── C) MAE/MFE der Live-Trades (M1-Fenster) ─────────────────────────────
|
||
print("\n" + "=" * 84)
|
||
print(f" C) MAE/MFE live — M1-Abdeckung {len(bars1)} Bars "
|
||
f"(~{len(bars1)/60/24*1.0:.0f} Handelstage), ATR≈|Entry−InitSL|/2")
|
||
print("=" * 84)
|
||
T1 = [int(b["time"]) for b in bars1]
|
||
H1 = [float(b["high"]) for b in bars1]; L1 = [float(b["low"]) for b in bars1]
|
||
t0 = T1[0]
|
||
import bisect
|
||
cov = 0
|
||
mae_w, mae_l, mfe_w, mfe_l = [], [], [], []
|
||
for r in rows:
|
||
eb = int(r["entry_time"]) + _BROKER_OFF # lokale Epoch → Broker-Epoch
|
||
xb = int(r["exit_time"]) + _BROKER_OFF
|
||
if eb < t0 or not r["sl_at_entry"]: continue
|
||
i0 = bisect.bisect_left(T1, eb); i1 = bisect.bisect_right(T1, xb)
|
||
if i1 - i0 < 1: continue
|
||
d = 1 if r["direction"] in ("BUY", "LONG", "buy") else -1
|
||
entry = float(r["entry_price"])
|
||
atr_est = abs(entry - float(r["sl_at_entry"])) / 2.0
|
||
if atr_est <= 0: continue
|
||
seg_h = H1[i0:i1]; seg_l = L1[i0:i1]
|
||
# adverse/favorable Exkursion je Richtung
|
||
if d > 0:
|
||
mae = max(0.0, entry - min(seg_l)); mfe = max(0.0, max(seg_h) - entry)
|
||
else:
|
||
mae = max(0.0, max(seg_h) - entry); mfe = max(0.0, entry - min(seg_l))
|
||
net = (r["pnl"] or 0) + (r["commission"] or 0)
|
||
cov += 1
|
||
(mae_w if net > 0 else mae_l).append(mae / atr_est)
|
||
(mfe_w if net > 0 else mfe_l).append(mfe / atr_est)
|
||
def st(v):
|
||
if not v: return "n=0"
|
||
v = sorted(v)
|
||
return (f"n={len(v):>3} Ø={sum(v)/len(v):.2f} Median={v[len(v)//2]:.2f} "
|
||
f"P90={v[int(len(v)*0.9)]:.2f}")
|
||
print(f" Abgedeckte Trades: {cov}")
|
||
print(f" MAE Gewinner {st(mae_w)} (wie tief liefen GEWINNER ins Minus)")
|
||
print(f" MAE Verlierer {st(mae_l)}")
|
||
print(f" MFE Gewinner {st(mfe_w)}")
|
||
print(f" MFE Verlierer {st(mfe_l)} (wie viel Plus gaben VERLIERER wieder her)")
|
||
if mfe_l:
|
||
gave = sum(1 for x in mfe_l if x >= 1.3) / len(mfe_l)
|
||
print(f" Verlierer, die ≥1,3×ATR im Plus waren (Breakeven hätte greifen müssen): {100*gave:.0f}%")
|
||
if mae_w:
|
||
deep = sum(1 for x in mae_w if x >= 1.8) / len(mae_w)
|
||
print(f" Gewinner, die ≥1,8×ATR im Minus waren (SL-Band-Nähe überlebt): {100*deep:.0f}%")
|
||
|
||
if __name__ == "__main__":
|
||
main()
|