diff --git a/analyze_slippage_vola.py b/analyze_slippage_vola.py new file mode 100644 index 0000000..3bdba6d --- /dev/null +++ b/analyze_slippage_vola.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Steigt die Exit-Slippage mit dem Vola-REGIME? — die offene Ausführungs-Frage. + +ANLASS (2026-08-08, User-Vorschlag 4/2). Das Vola-Regime ist als +RICHTUNGS-Signal gemessen tot (`backtest_pbreak_regime.py`: Bruchrate über 5 +ATR-Quintile flach, 37,0–39,2 %; das Regime-Merkmal fiel durch die vorab +fixierte Regel). Als **Ausführungskosten-Warnung** ist es aber nie gemessen +worden — und das ist eine ganz andere Behauptung: nicht „wohin läuft der Kurs", +sondern „wie teuer wird das Verlassen der Position". + +METHODE — bewusst die aus `analyze_slippage_holdtime.py`, damit die Zahlen +vergleichbar bleiben: + gap = (sl_at_entry − exit_price) × Richtung, nur `closed_by='sl'` + gap > 0 heisst „schlechter als der Initial-SL" +⚠ Warum ausgerechnet dieser gap unkontaminiert ist: das **Trailing kann ihn nur +NEGATIVER machen** (es zieht den SL in Gewinnrichtung). Ein positiver gap kann +also nicht vom Trailing stammen — er ist zwingend Slippage. + Schätzer (Untergrenze): E[Slippage] ≥ P(gap>0) × Ø(gap | gap>0) + +NEUE ACHSE: ΔATR = ATR_M15(Exit-Zeitpunkt) / Median(ATR_M15 der letzten 20 Bars). +> 1 heisst „Vola zieht gerade an". ⚠ KAUSAL: der Median endet VOR dem Exit-Bar. + +⚠⚠ ENTSCHEIDUNGSREGEL, VORAB: ein Warn-Flag im Kosten-Gate ist nur dann +gerechtfertigt, wenn die Slippage im obersten ΔATR-Bucket **mindestens doppelt +so hoch** ist wie im untersten UND der Zusammenhang **monoton** verläuft. Ein +einzelner Ausreisser-Bucket reicht nicht — dieselbe Nachbar-Regel, an der ORB +und die VWAP-Bänder gescheitert sind. +⚠ Stichprobe: es gibt insgesamt ~317 SL-Closes. Über 4–5 Buckets sind das +~60–80 je Zelle — dünn. Ein ❌ ist belastbar, ein ✅ nur ein Hinweis. + +Aufruf: python analyze_slippage_vola.py +""" +from __future__ import annotations + +import bisect +import sqlite3 +import statistics as st + +import MetaTrader5 as mt5 + +DB = "oil_widget_history.db" +_BROKER_OFF = 10800 # Brokerzeit = UTC+3 +_MA = 20 # Fenster fuer den ATR-Median +_M15_SEK = 900 + + +def atr_series(H, L, C, p=14): + tr = [0.0] + for i in range(1, len(C)): + tr.append(max(H[i] - L[i], abs(H[i] - C[i - 1]), abs(L[i] - C[i - 1]))) + out = [None] + for i in range(1, len(C)): + seg = tr[max(1, i - p + 1):i + 1] + out.append(sum(seg) / len(seg)) + return out + + +def main(): + db = sqlite3.connect(DB); db.row_factory = sqlite3.Row + rows = [] + for r in db.execute( + "SELECT exit_time, exit_price, sl_at_entry, direction, entry_time " + "FROM trades WHERE closed_by='sl' AND sl_at_entry IS NOT NULL " + "AND exit_price IS NOT NULL AND exit_time IS NOT NULL"): + d = 1 if (r["direction"] or "").upper() in ("BUY", "LONG") else -1 + rows.append({"t": int(r["exit_time"]), + "gap": (r["sl_at_entry"] - r["exit_price"]) * d, + "hold": (int(r["exit_time"]) - int(r["entry_time"] or 0)) / 60.0}) + db.close() + if len(rows) < 60: + print(f"nur {len(rows)} SL-Closes — zu wenig"); return 1 + + mt5.initialize() + sym = next((c for c in ("SpotCrude", "USOIL", "WTI", "XTIUSD") + if mt5.symbol_info(c)), None) + bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M15, 0, 26000) + mt5.shutdown() + T = [int(b["time"]) - _BROKER_OFF for b in bars] + H = [float(b["high"]) for b in bars]; L = [float(b["low"]) for b in bars] + C = [float(b["close"]) for b in bars] + A = atr_series(H, L, C) + + # ΔATR je Trade — kausal: der Median endet VOR dem Exit-Bar + dat = [] + for r in rows: + i = bisect.bisect_right(T, r["t"]) - 1 + if i < _MA + 15 or i >= len(A) or not A[i]: + continue + fenster = [a for a in A[i - _MA:i] if a] + if len(fenster) < _MA // 2: + continue + med = st.median(fenster) + if med <= 0: + continue + dat.append({**r, "datr": A[i] / med, "atr": A[i]}) + + print("=" * 84) + print(" EXIT-SLIPPAGE gegen das VOLA-REGIME (ΔATR = ATR / Median der letzten 20)") + print("=" * 84) + print(f" {len(rows)} SL-Closes, davon {len(dat)} mit M15-Abdeckung") + if len(dat) < 60: + print(" zu wenige mit Abdeckung — keine Aussage"); return 1 + + ds = sorted(x["datr"] for x in dat) + print(f" ΔATR: Median {ds[len(ds)//2]:.2f} 10-Perz. {ds[len(ds)//10]:.2f} " + f"90-Perz. {ds[int(0.9*len(ds))]:.2f}") + + # ── Quartile, damit die Zellen nicht zu duenn werden ────────────────── + kanten = [ds[0], ds[len(ds)//4], ds[len(ds)//2], ds[3*len(ds)//4], ds[-1] + 1e-9] + print(f"\n {'ΔATR-Bereich':<18}{'n':>5}{'slip>0':>8}{'Rate':>7}" + f"{'Ø gap|>0':>11}{'Schaetzer':>12}") + werte = [] + for a, b in zip(kanten, kanten[1:]): + grp = [x for x in dat if a <= x["datr"] < b] + if len(grp) < 15: + print(f" {f'{a:.2f}–{b:.2f}':<18}{len(grp):>5}{'':>8}{'zu wenig':>7}") + werte.append(None); continue + pos = [x["gap"] for x in grp if x["gap"] > 0] + rate = len(pos) / len(grp) + m = sum(pos) / len(pos) if pos else 0.0 + sch = rate * m + werte.append(sch) + print(f" {f'{a:.2f}–{b:.2f}':<18}{len(grp):>5}{len(pos):>8}{100*rate:>6.0f}%" + f"{m:>11.4f}{sch:>12.4f}") + + gut = [w for w in werte if w is not None] + print("\n " + "-" * 78) + if len(gut) < 3: + print(" zu wenige belegte Buckets fuer ein Urteil"); return 0 + unten, oben = gut[0], gut[-1] + monoton = all(gut[i] <= gut[i + 1] for i in range(len(gut) - 1)) + faktor = (oben / unten) if unten > 0 else float("inf") + print(f" unterstes Quartil {unten:.4f} $ → oberstes {oben:.4f} $ " + f"Faktor {faktor:.2f}×") + print(f" monoton steigend: {'JA' if monoton else 'NEIN'}") + print(f" Regel (Faktor ≥2 UND monoton): " + f"{'ERFUELLT — Warn-Flag gerechtfertigt' if (faktor >= 2 and monoton) else 'VERFEHLT — kein Flag'}") + print("\n ⚠ Zum Vergleich (analyze_slippage_holdtime): Median-Spread 0,0280 $;") + print(" <30-min-Trades 0,0370 $ ueber alle Stunden, 0,0124 $ im Fenster 10–17.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/core/engine.py b/core/engine.py index 39ef97f..b504b8f 100644 --- a/core/engine.py +++ b/core/engine.py @@ -3974,6 +3974,43 @@ class TradingEngine: log.debug(f"sr_close_hint: {e}") return None + def _vola_regime(self, atr15) -> dict | None: + """ΔATR = aktueller ATR_M15 / Median der letzten ~5 h. + + ⚠ KEIN zweiter MT5-Fetch: der ATR wird aus dem ohnehin laufenden + Turn-Fetch gesammelt (`wave.tf_atr`), hier nur gepuffert. Ein extra + Abruf fuer Zahlen, die schon da sind, war beim Konsens-Pfeil der Fehler. + + ⚠⚠ BELEGLAGE, ehrlich: gemessen (`analyze_slippage_vola.py`, 337 + SL-Closes) steigt die Exit-Slippage MONOTON ueber alle vier + ΔATR-Quartile — 0,0200 → 0,0390 → 0,0645 → 0,0839 $, Faktor 4,19×. + Die Slip-RATE bleibt dabei flach (20–23 %), die GROESSE waechst + (0,094 → 0,396 $): es rutscht nicht oefter, sondern weiter. + ABER: der Bootstrap auf die DIFFERENZ oben−unten ergibt + [−0,012 … +0,183] — **enthaelt die Null**. Bei n≈85 je Quartil ist der + Unterschied NICHT statistisch belegt. Deshalb reine ANZEIGE, kein Gate, + und die Beschriftung sagt das auch. + """ + try: + if not atr15 or atr15 <= 0: + return None + buf = getattr(self, "_atr15_buf", None) + if buf is None: + from collections import deque + buf = self._atr15_buf = deque(maxlen=1200) # ~5 h bei 15-s-Takt + buf.append(float(atr15)) + if len(buf) < 200: + return {"datr": None, "stufe": None} + import statistics as _st + med = _st.median(buf) + if med <= 0: + return {"datr": None, "stufe": None} + d = atr15 / med + return {"datr": round(d, 2), + "stufe": "hoch" if d >= 1.26 else "normal" if d >= 0.91 else "ruhig"} + except Exception: + return None + def _m15_setup(self, market, wave_snap, cone, alignment) -> dict | None: """SETUP-BEREITSCHAFT auf M15 — der Ersatz für die entfernte Gesamtempfehlung. @@ -4044,6 +4081,32 @@ class TradingEngine: "lo": x.get("lo"), "hi": x.get("hi")} for x in b80] or None + # ── 4b) ZIEL-ERREICHBARKEIT — reine GEOMETRIE auf dem Kegel. + # Ziel eines Trades am nahen Level ist das GEGENÜBERLIEGENDE. + # Gefragt wird nur: passt dieser Abstand noch in das 80-%-Band + # des jeweiligen Horizonts? ⚠ KEINE Bruch-Annahme — die im + # Vorschlag mitgedachte „je länger, desto eher bricht es"-Regel + # ist gemessen NICHT robust (Touch-Zahl: umgekehrtes U auf 4 + # Live-Tagen, im Backtest +0,010 AUC und nach dem Nachtrainieren + # gar nichts mehr). Hier steckt deshalb nur Arithmetik. + if len(zonen) == 2 and bid and b80: + nah = min(zonen, key=lambda x: x["gap_atr15"]) + fern = max(zonen, key=lambda x: x["gap_atr15"]) + strecke = abs(fern["level"] - bid) + out["ziel"] = { + "level": fern["level"], "strecke": round(strecke, 3), + "horizonte": [{"minutes": x.get("minutes"), + "drin": bool(x.get("lo") is not None + and x.get("hi") is not None + and x["lo"] <= fern["level"] <= x["hi"])} + for x in b80], + "von": nah["level"]} + else: + out["ziel"] = None + + # ── 4c) VOLA-REGIME als Slippage-HINWEIS (kein Gate, s. Docstring) + out["vola"] = self._vola_regime(atr15) + # ── 5) VETO — die eigene Bilanz, je Lot (absolute Euro sind bei # wechselnder Positionsgröße ungültig, Lehre vom 04.08.) out["veto"] = alignment or None diff --git a/web/app.js b/web/app.js index 5b34d5c..49c4fb7 100644 --- a/web/app.js +++ b/web/app.js @@ -170,10 +170,19 @@ function renderM15(s) { const k = s.kosten || {}; const kOk = k.stufe === "guenstig" || k.stufe === "normal"; $("m15-kosten").className = "m15-item " + (k.ratio == null ? "" : kOk ? "ok" : "fail"); + // ⚠ Slippage-Hinweis: gemessen steigt die Exit-Slippage monoton mit ΔATR + // (0,020 → 0,084 $, Faktor 4,2). ABER der Bootstrap auf die Differenz enthält + // die Null (n≈85 je Quartil) — deshalb HINWEIS, kein Gate, und die + // Beschriftung sagt „historisch", nicht „belegt". + const vo = s.vola || {}; + const slipTxt = vo.stufe === "hoch" + ? " · ⚠ Vola zieht an — Slippage historisch ~4× höher" + : vo.stufe === "ruhig" ? " · Vola ruhig" : ""; $("m15-kosten").textContent = k.ratio == null ? "② Kosten —" : `② Kosten ${f(k.ratio, 3)}×ATR · ${ - k.stufe === "guenstig" ? "günstig" : k.stufe === "normal" ? "normal" : "TEUER"}`; + k.stufe === "guenstig" ? "günstig" : k.stufe === "normal" ? "normal" : "TEUER"}` + + slipTxt; // ── Gate 3: Level in Reichweite ────────────────────────────────────── const z = (s.zonen || []); @@ -186,11 +195,25 @@ function renderM15(s) { : "③ kein Level in Sicht"; // ── Reichweite (Kegel) — sagt WIE WEIT, nicht wohin ────────────────── + // ⚠ REINE GEOMETRIE: passt das Gegenlevel (= das Ziel) noch in das 80-%-Band + // des jeweiligen Horizonts? Keine Bruch-Annahme — die wäre nicht belegt. + const zi = s.ziel; const r = s.reichweite || []; - $("m15-reich").className = "m15-item"; - $("m15-reich").textContent = r.length - ? "④ erwartete Spanne · " + r.map(x => `${x.minutes}′ ${f(x.lo, 2)}–${f(x.hi, 2)}`).join(" · ") - : "④ erwartete Spanne —"; + if (zi && zi.horizonte && zi.horizonte.length) { + const drin = zi.horizonte.filter(x => x.drin).map(x => x.minutes); + const el = $("m15-reich"); + el.className = "m15-item " + (drin.length ? "ok" : "warn"); + el.textContent = drin.length + ? `④ Ziel ${f(zi.level, 3)} erreichbar ab ${Math.min(...drin)}′ ` + + `(${zi.strecke} $ vom Kurs)` + : `④ Ziel ${f(zi.level, 3)} liegt AUSSERHALB des 120′-Kegels ` + + `(${zi.strecke} $) — CRV zeitlich nicht gedeckt`; + } else { + $("m15-reich").className = "m15-item"; + $("m15-reich").textContent = r.length + ? "④ erwartete Spanne · " + r.map(x => `${x.minutes}′ ${f(x.lo, 2)}–${f(x.hi, 2)}`).join(" · ") + : "④ erwartete Spanne —"; + } // ── Veto: die eigene Bilanz, JE LOT ────────────────────────────────── const v = s.veto || {}; diff --git a/web/index.html b/web/index.html index 8e1d748..61c27fd 100644 --- a/web/index.html +++ b/web/index.html @@ -6,7 +6,7 @@