Slippage vs Vola-Regime gemessen + Ziel-Erreichbarkeit gebaut (v=154)

MESSUNG (analyze_slippage_vola.py, 337 SL-Closes): die Exit-Slippage steigt
MONOTON ueber alle vier Delta-ATR-Quartile:
  0,39-0,91   Schaetzer 0,0200 $
  0,91-1,08             0,0390 $
  1,08-1,26             0,0645 $
  1,26-2,39             0,0839 $   Faktor 4,19x
Mechanismus stimmig: die Slip-RATE bleibt 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 NICHT statistisch belegt.
Meine vorab fixierte Regel (Faktor >=2 UND monoton) war damit
unterspezifiziert - sie haette ein Konfidenzintervall verlangen muessen.
Konsequenz: gebaut als reiner HINWEIS im Kosten-Gate, kein Gate, und die
Beschriftung sagt "historisch ~4x hoeher", nicht "belegt".

ZIEL-ERREICHBARKEIT (Vorschlag 3, Decay-Clock): reine Geometrie auf dem
kalibrierten Kegel - passt das Gegenlevel noch in das 80-%-Band des jeweiligen
Horizonts? Live: Ziel 77,784 (0,401 $) liegt im 120'-Kegel, nicht im 30'/60'.
Die im Vorschlag mitgedachte Annahme "je laenger, desto eher bricht es" ist
BEWUSST NICHT eingebaut - sie ist nicht belegt (Touch-Zahl: umgekehrtes U auf
4 Live-Tagen, im Backtest +0,010 AUC, nach dem Nachtrainieren gar nichts mehr).

Umsetzung ohne zweiten MT5-Fetch: _vola_regime puffert den ATR_M15 aus dem
ohnehin laufenden Turn-Fetch (deque, ~5 h). Kaltstart liefert datr=null statt
zu raten - live verifiziert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Axel Hocks
2026-08-08 22:59:37 +02:00
co-authored by Claude Opus 5
parent 44955bf70a
commit e5adfc9757
4 changed files with 237 additions and 7 deletions
+144
View File
@@ -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,039,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 45 Buckets sind das
~6080 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 1017.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+63
View File
@@ -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 (2023 %), die GROESSE waechst
(0,094 → 0,396 $): es rutscht nicht oefter, sondern weiter.
ABER: der Bootstrap auf die DIFFERENZ obenunten 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
+24 -1
View File
@@ -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 || [];
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 || {};
+2 -2
View File
@@ -6,7 +6,7 @@
<meta name="theme-color" content="#0d1117">
<title>Oil · MT5</title>
<link rel="manifest" href="manifest.json">
<link rel="stylesheet" href="style.css?v=153">
<link rel="stylesheet" href="style.css?v=154">
</head>
<body>
<!-- Modul-Menü (ganz oben) -->
@@ -340,6 +340,6 @@
</div>
<div id="toast" class="toast hidden"></div>
<script src="app.js?v=153"></script>
<script src="app.js?v=154"></script>
</body>
</html>