analyze_scalping_costs.py: B3-Check fuer ein M1-Scalping-Modul

User-Frage "wie sinnvoll waere ein Scalping-Modul auf 1M-Basis". Vor dem Bau
gemessen, weil es zuerst eine Kosten- und keine Signalfrage ist: der Spread
ist absolut ~konstant und skaliert nicht mit der Zeitebene, die Bewegung schon.

Spread/ATR im Median: M1 0,319 · M5 0,193 · M15 0,133. Eine mittlere M1-Kerze
ist nur 3,4x so gross wie der Spread. Noetige Trefferquote bei 1:1 steigt von
50 auf 65,9 %; um 01:00 uebersteigt der Spread mit 1,051xATR_M1 die gesamte
durchschnittliche Bar-Spanne.

Dazu drei Gruende, die schwerer wiegen als die Kosten:
- M1-Historie ist hart bei 80.000 Bars / 80 Tage gedeckelt -> kein
  2-Stichproben-Test ueber verschiedene Regime moeglich
- das Aufloesungs-Artefakt vom 05.08. trifft M1 am haertesten (Scalp lebt in
  2-3 Bars, Bar-Sim ueberschaetzt enge Stops)
- der Slippage-Schwanz ist absolut: Ø 0,234 $ uebersteigt einen 2xATR_M1-Stop
  (0,174 $) bereits im Mittel

Empfehlung: kein eigenstaendiges Modul; messbar waere der Squeeze auf M1
(offene Variante B) beschraenkt auf 10-17 Uhr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Axel Hocks
2026-08-06 12:09:15 +02:00
co-authored by Claude Opus 5
parent bf838b4d03
commit c888bcc3e8
2 changed files with 253 additions and 0 deletions
+45
View File
@@ -284,6 +284,51 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche:
~15 s→5 s schneller erkannt, dann ~1 s Handeln (`_pos_loop`). Gleiche validierte M5-Regel,
reine Infra (kein Backtest nötig). **Variante B (Squeeze auf M1) = OFFEN/UNBELEGT** — erst
backtesten, wenn der M1-Logger (`candles_m1`, seit 2026-07-21) ab ~2027-01 genug History hat.
- **⚠⚠ SCALPING-MODUL AUF M1 = ABGERATEN, bevor überhaupt gebaut wird (gemessen
`analyze_scalping_costs.py`, 2026-08-06, User-Frage „wie sinnvoll wäre ein
zusätzliches Modul Scalping auf 1M-Basis, über das Dashboard steuerbar?").**
Es ist zuerst eine **Kosten-**, keine Signalfrage: der Spread ist bei WTI absolut
~konstant und skaliert NICHT mit der Zeitebene, die Bewegung schon.
| TF | Ø ATR | Ø Spread | **Median Sp/ATR** | Ø Sp/ATR |
|---|---|---|---|---|
| **M1** | 0,0870 $ | 0,0254 $ | **0,319** | **0,416** |
| M5 | 0,1677 $ | 0,0226 $ | 0,193 | 0,256 |
| M15 | 0,2269 $ | 0,0250 $ | 0,133 | 0,171 |
Der Spread ist auf allen drei Ebenen praktisch derselbe Betrag — **eine mittlere
M1-Kerze ist nur 3,4× so groß wie der Spread.** Nötige Trefferquote bei 1:1
(Ziel/Stop je 1×ATR_M1): **65,9 %** statt 50 %; bei 0,5:0,5 sogar **81,9 %**.
Zum Vergleich: nichts in diesem Projekt hat je über ~46 % geliefert.
**Nachts ist es arithmetisch unmöglich:** um **01:00 beträgt der Spread
1,051×ATR_M1** — er ist größer als die durchschnittliche Bar-Spanne (00:00 0,801 ·
06:00 0,750). Handelbar wäre allenfalls **1017 Uhr** (0,2100,265), also dort, wo
M1 ungefähr die heutigen M5-Kosten erreicht.
⚠⚠ **DREI Gründe, die schwerer wiegen als die Kosten selbst:**
**(1) Ein Backtest nach Projektstandard ist NICHT möglich.** Die M1-Historie des
Brokers ist hart bei **80.000 Bars = 80 Tage** gedeckelt (`copy_rates_from_pos`
liefert darüber `None`, `copy_rates_range` ebenso). Zwei Stichproben à 40 Tage
liegen im **selben Regime** — genau die Konstellation, an der ORB und die
Dead-Hour-Stunde 13 gescheitert sind. Der eigene `candles_m1`-Logger steht bei
19 Tagen und erreicht die 180 erst ~2027-01.
**(2) Das Auflösungs-Artefakt trifft M1 am härtesten.** `analyze_trail_resolution.py`
(05.08.) hat gezeigt: eine Bar-Simulation **überschätzt enge Stops systematisch**,
Kipp-Punkt bei ~0,3×ATR. Ein Scalp lebt in 23 Bars — eine M1-Bar-Sim hat dort
praktisch keine Auflösung mehr. Ein positives Ergebnis wäre nicht überprüfbar; es
bräuchte Tick-Daten.
**(3) Der Slippage-Schwanz ist ABSOLUT und frisst deshalb den kleinen Stop.**
Gemessen (`analyze_execution.py`): Median 0,017 $ (unkritisch), **Ø 0,234 $, Max
4,192 $**. Ein 2×ATR_M1-Stop ist **0,174 $** breit — der MITTLERE Slippage-Wert
übersteigt ihn bereits. Der reale 0,751-$-Fall vom 30.06. entspricht **8,6×** einem
M1-Stop gegen 2,2× einem M5-Stop; er löscht rund **11** gewonnene Scalps statt 4.
**Was FÜR die Idee spricht (fairerweise):** der Bau wäre billig — Pending-Stop-
Orders, `_auto_guard`, `_pending_ziel` mit Vorrang, Dashboard-Toggle (Muster
🚀 BRK / 🎯 SIG), `runtime_state.json`, B4-Monitor und die Setup-Kennzeichnung
(seit 06.08.) existieren alle. Und **höhere Frequenz = schnellere Validierung**:
der Squeeze braucht 20 Trades und hat nach 5 Tagen 8.
✅ **Empfehlung: KEIN eigenständiges Scalping-Modul, aber eine engere Variante ist
messbar** — der **Squeeze auf M1** (= die schon offene Variante B), beschränkt auf
1017 Uhr, mit ruhender Stop-Order. Das ist kein neues Signal, sondern eine
validierte Regel auf einer feineren Ebene; die 80 Tage reichen für ein
**Ausschluss**-Urteil (nicht für eine Bestätigung).
Snapshot **`wave.squeeze {state, dir, level, box_atr}`**: `active` = Ausbruch (echtes
Signal LONG/SHORT) · `armed` = komprimiert, Ausbruch steht bevor · None = keine
Kompression (transient — der Ausbruch weitet die Box, Signal klärt sich selbst).
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""Traegt ein M1-Scalping-Modul die Kosten? — B3-Check VOR jedem Bau.
WOZU: Die Frage "Scalping auf M1" ist zuerst eine KOSTEN-Frage, nicht eine
Signal-Frage. Der Spread ist bei WTI absolut ~konstant (0,0225 $, gemessen
`analyze_execution.py`) — er skaliert NICHT mit der Zeitebene. Die Bewegung, aus
der ein Trade seinen Ertrag zieht, skaliert sehr wohl. Je feiner die Zeitebene,
desto groesser also der Anteil, den der Spread vom verfuegbaren Weg frisst.
Die Projekt-Huerde (B3): Ø-Edge **netto > ~0,1×ATR** der gehandelten Zeitebene.
Auf M5 liegen die realen Kosten bei Ø 0,265×ATR_M5 — schon dort ist es eng.
Gemessen wird deshalb:
A) Spread/ATR je Zeitebene (M1 gegen M5/M15) — Median, Ø, je Berliner Stunde
B) Wieviel Weg gibt eine M1-Bewegung ueberhaupt her? (MFE/MAE in N Bars)
C) Welche Trefferquote braeuchte ein Scalp, um die Kosten zu bezahlen?
⚠ Das ist KEIN Backtest eines Scalping-Signals — es ist der Test, ob so ein
Signal ueberhaupt existieren KANN. Faellt A/C durch, eruebrigt sich die
Signalsuche (dieselbe Logik wie beim Entry-Raum-Gate: reine Arithmetik,
deshalb regime-unabhaengig robust).
Aufruf: python analyze_scalping_costs.py [n_bars]
"""
import sys
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
import MetaTrader5 as mt5
_BERLIN = ZoneInfo("Europe/Berlin")
_BROKER = timezone(timedelta(hours=3))
_UTC = timezone.utc
_HURDE = 0.10 # B3: Ø-Edge netto muss > 0,1×ATR sein
def _berlin(ts):
"""MT5-Bar-Zeitstempel → echte Berliner Zeit (s. CLAUDE.md, Fix 2026-08-05).
⚠ NICHT `fromtimestamp(ts, UTC+3)` — MT5 liefert dort bereits die
Broker-Wallclock, das ergaebe 3 Stunden Versatz.
"""
return (datetime.fromtimestamp(int(ts), _UTC)
.replace(tzinfo=_BROKER).astimezone(_BERLIN))
def _atr(H, L, C, n=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, s = [0.0] * len(C), 0.0
for i in range(1, len(C)):
s += tr[i]
if i > n:
s -= tr[i - n]
out[i] = s / min(i, n)
return out
def _q(v, p):
s = sorted(v)
return s[min(len(s) - 1, int(p * len(s)))] if s else 0.0
def hole(sym, tf, n):
b = mt5.copy_rates_from_pos(sym, tf, 0, n)
if b is None or len(b) < 200:
return None
point = mt5.symbol_info(sym).point
return {
"H": [float(x["high"]) for x in b],
"L": [float(x["low"]) for x in b],
"C": [float(x["close"]) for x in b],
"SP": [float(x["spread"]) * point for x in b],
"T": [int(x["time"]) for x in b],
}
def main():
n = int(sys.argv[1]) if len(sys.argv) > 1 else 80000
if not mt5.initialize():
print("MT5 nicht erreichbar"); return 1
sym = next((c for c in ("SpotCrude", "USOIL", "WTI", "XTIUSD")
if mt5.symbol_info(c)), None)
if not sym:
mt5.shutdown(); print("Symbol nicht gefunden"); return 1
tfs = [("M1", mt5.TIMEFRAME_M1), ("M5", mt5.TIMEFRAME_M5),
("M15", mt5.TIMEFRAME_M15)]
daten = {}
for lbl, tf in tfs:
d = hole(sym, tf, n)
if d:
d["ATR"] = _atr(d["H"], d["L"], d["C"])
daten[lbl] = d
print("=" * 86)
print(f" SCALPING auf M1 — traegt es die Kosten? Symbol {sym}")
print("=" * 86)
for lbl in ("M1", "M5", "M15"):
if lbl in daten:
d = daten[lbl]
print(f" {lbl}: {len(d['C'])} Bars "
f"{_berlin(d['T'][0]):%d.%m.%Y} {_berlin(d['T'][-1]):%d.%m.%Y}")
# ── A) Spread/ATR je Zeitebene ───────────────────────────────────────
print()
print("=" * 86)
print(" A) KOSTEN — Spread als Anteil der ATR der jeweiligen Zeitebene")
print("=" * 86)
print(f" {'TF':<5} {'Ø ATR ($)':>10} {'Ø Spread ($)':>13} "
f"{'Median Sp/ATR':>14} {'Ø Sp/ATR':>10} {'75 %':>8}")
kosten = {}
for lbl in ("M1", "M5", "M15"):
if lbl not in daten:
continue
d = daten[lbl]
r = [d["SP"][i] / d["ATR"][i] for i in range(20, len(d["C"]))
if d["ATR"][i] > 0 and d["SP"][i] > 0]
if not r:
continue
kosten[lbl] = (_q(r, 0.5), sum(r) / len(r))
ga = [a for a in d["ATR"][20:] if a > 0]
gs = [s for s in d["SP"][20:] if s > 0]
print(f" {lbl:<5} {sum(ga)/len(ga):>10.4f} {sum(gs)/len(gs):>13.4f} "
f"{_q(r, 0.5):>14.3f} {sum(r)/len(r):>10.3f} {_q(r, 0.75):>8.3f}")
print()
print(f" Referenz: B3-Huerde = Ø-Edge netto > {_HURDE:.2f}×ATR ·")
print(" gemessen auf M5 (backtest_realcosts.py): Ø 0,265×ATR = schon dort eng.")
# ── A2) je Berliner Stunde (M1) ──────────────────────────────────────
if "M1" in daten:
d = daten["M1"]
prostd = {}
for i in range(20, len(d["C"])):
if d["ATR"][i] <= 0 or d["SP"][i] <= 0:
continue
prostd.setdefault(_berlin(d["T"][i]).hour, []).append(
d["SP"][i] / d["ATR"][i])
print()
print(" Spread/ATR_M1 je Berliner Stunde (die guenstigsten zuerst):")
rang = sorted(((sum(v) / len(v), h, len(v)) for h, v in prostd.items()
if len(v) > 50))
for m, h, k in rang[:5]:
print(f" {h:02d}:00 {m:.3f}×ATR_M1 (n={k})")
print(" ...")
for m, h, k in rang[-3:]:
print(f" {h:02d}:00 {m:.3f}×ATR_M1 (n={k})")
# ── B) Wieviel Weg gibt M1 her? ──────────────────────────────────────
print()
print("=" * 86)
print(" B) VERFUEGBARER WEG — beste Auslenkung (MFE) in N M1-Bars, ×ATR_M1")
print("=" * 86)
if "M1" in daten:
d = daten["M1"]
H, L, C, A = d["H"], d["L"], d["C"], d["ATR"]
print(f" {'N Bars':<8} {'Median MFE':>12} {'Ø MFE':>9} {'Ø MAE':>9} "
f"{'MFE > Kosten?':>16}")
k_med = kosten.get("M1", (0, 0))[0]
for N in (3, 5, 10, 15, 30):
mfe, mae = [], []
# Richtungsfrei: die BESTE der beiden Seiten (Obergrenze — ein
# echtes Signal muesste die Seite vorher treffen)
for i in range(20, len(C) - N, 7):
if A[i] <= 0:
continue
hi = max(H[i + 1:i + N + 1]); lo = min(L[i + 1:i + N + 1])
mfe.append(max(hi - C[i], C[i] - lo) / A[i])
mae.append(min(hi - C[i], C[i] - lo) / A[i])
if not mfe:
continue
anteil = 100.0 * sum(1 for x in mfe if x > k_med) / len(mfe)
print(f" {N:<8} {_q(mfe, 0.5):>12.2f} {sum(mfe)/len(mfe):>9.2f} "
f"{sum(mae)/len(mae):>9.2f} {anteil:>15.0f} %")
print()
print(" ⚠ Das ist eine OBERGRENZE: gemessen wird die beste der beiden")
print(" Seiten im Nachhinein. Ein echtes Signal muss die Richtung")
print(" VORHER treffen — und das ist im Projekt 23× gescheitert.")
# ── C) noetige Trefferquote ──────────────────────────────────────────
print()
print("=" * 86)
print(" C) WELCHE TREFFERQUOTE braeuchte ein M1-Scalp?")
print("=" * 86)
if "M1" in kosten:
k = kosten["M1"][0]
print(f" Kosten je Trade (Median) = {k:.3f}×ATR_M1")
print(f" {'Ziel':<8} {'Stop':<8} {'CRV':>6} {'noetige WR':>12} "
f"{'noetige WR OHNE Kosten':>24}")
for ziel, stop in ((0.5, 0.5), (1.0, 1.0), (1.0, 0.5),
(1.5, 1.5), (2.0, 2.0)):
g, v = ziel - k, stop + k
wr = 100.0 * v / (g + v) if g > 0 else float("nan")
wr0 = 100.0 * stop / (ziel + stop)
gg = f"{wr:.1f} %" if g > 0 else "unmoeglich"
print(f" {ziel:<8.1f} {stop:<8.1f} {ziel/stop:>6.2f} "
f"{gg:>12} {wr0:>23.1f} %")
print()
print(" Die letzte Spalte ist der KOSTENFREIE Fall. Der Abstand")
print(" dazwischen ist der Preis, den das Signal zusaetzlich verdienen muss.")
mt5.shutdown()
return 0
if __name__ == "__main__":
sys.exit(main())