Anlass war die Frage nach numpy. Statt zu vermuten wurde profiliert — und das
Ergebnis war ein anderes als erwartet: von 10,07 s Gesamtlaufzeit steckten
8,17 s in der EIGENZEIT der Backtest-Schleife, nur 0,44 s in _build.
Ursache:
[C[j] for j in PH if i - _PIV_LOOK <= j <= i - _PIV_K] # je BAR, ganze Liste
Das ist O(Bars x Pivots) — bei 80k Bars hunderte Millionen Vergleiche, und
genau daher kamen die 9-20-Minuten-Laeufe. PH/PL sind sortiert, also genuegen
zwei Binaersuchen fuer dieselbe Menge in O(log n).
Gemessen: backtest_signal_touchfill.py ueber 80k Bars von ~9 min auf 8,9 s
(~60x), bei 8k Bars 10,07 s -> 1,43 s. Das Ergebnis bleibt inhaltlich
identisch (alle sechs Felder negativ); die Handvoll Trades Unterschied ist die
bekannte Live-Bar-Drift, nicht der Umbau.
MIGRATIONS-REGEL ERNST GENOMMEN: ein Vorher/Nachher-Vergleich zweier LAEUFE
taugt hier nicht, weil copy_rates_from_pos am neuesten Bar ankert und
eintreffende Live-Bars das Fenster verschieben. Die Gleichheit wird deshalb
DIREKT auf der Datenstruktur bewiesen — tests/test_pivotfenster.py mit vier
Faellen: 4.000 Bars x 400 Pivots, Randfaelle, leere Liste, beidseitig
inklusive Grenzen.
Angewandt auf 7 Skripte (auto_signal_v3, breakout_gated, dist_gated, htf_vola,
metalabel, sl_width, trail_be) — mechanisch identische Transformation, alle
kompilieren, Stichprobe gegengelaufen (dist_gated 20k in 1,1 s).
Lehre: die naheliegende Antwort (numpy) war die falsche. Ein Profil kostet zwei
Minuten und haette diese Bremse jederzeit gezeigt — sie lag seit Monaten in
jedem gegateten Backtest.
17 Tests gruen, n-Falle-Scan sauber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
207 lines
8.8 KiB
Python
207 lines
8.8 KiB
Python
#!/usr/bin/env python3
|
||
"""Signal-Verteilung mit dem VOLLEN Live-Gate-Stack (2026-08-05).
|
||
|
||
WARUM ES DAS BRAUCHT — der 43-%-Maßstab ist ungültig:
|
||
`backtest_dist.py` liefert „~57 % gerichtet / ~43 % WARTEN". Diese Zahl kommt aber
|
||
aus **`_build` ALLEIN** — ohne **Entry-Raum-Gate** und ohne **Breakout-Bestätigung**.
|
||
Genau die beiden erzeugen live **62 % aller Blocks** (seit 01.08., echte Marktzeit:
|
||
entry_room 35,3 % · breakout_pending 26,5 %). Der Vergleich „live 93 % WARTEN gegen
|
||
erwartete 43 %" stellt also zwei verschiedene Systeme gegenüber — und das
|
||
dokumentierte Erfolgskriterium des TF-Churn-Fixes ist damit per Konstruktion
|
||
unerreichbar.
|
||
|
||
Dieses Skript stellt den fehlenden Maßstab her: dieselbe Verteilung, aber über den
|
||
Stack, der live wirklich läuft (`_build` → `_confirm_breakout` → `_room_gate`).
|
||
|
||
⚠ ABGRENZUNG: gezählt wird **je BAR**, live je Snapshot (~1×/s). Für den ANTEIL ist
|
||
das vergleichbar; für absolute Häufigkeiten nicht.
|
||
⚠ NICHT modelliert (live-only, ohne Bar-Entsprechung): EIA-Fenster, News-Blackout,
|
||
Cooldowns, Startup-Schonfrist, `stale`. Deren Anteil ist live klein bzw. wird beim
|
||
Vergleich herausgerechnet.
|
||
|
||
Aufruf: python backtest_dist_gated.py [bars]
|
||
"""
|
||
import sys
|
||
from bisect import bisect_left, bisect_right
|
||
from collections import Counter
|
||
|
||
import MetaTrader5 as mt5
|
||
|
||
from core.analysis import calc_trend_angle
|
||
from core.wave_rec import (WaveRecommender, _EMA_FAST, _EMA_SLOW, _N_BARS,
|
||
_ANGLE_LR, _ANGLE_DEAD, _HTF_DEADBAND)
|
||
|
||
_ATRMIN = 0.06
|
||
_TIMEOUT_BARS = 12
|
||
_PIV_K, _PIV_LOOK = 3, 300
|
||
_ATR_FLOOR_PB = 0.12
|
||
_K = 0.3
|
||
|
||
# Live-Referenz (seit 01.08., echte Marktzeit, `stale` herausgerechnet) — aus der
|
||
# `recommendations`-Tabelle; s. Roast-Analyse 2026-08-05.
|
||
_LIVE = {"(gerichtet)": 7.2, "entry_room": 35.3, "breakout_pending": 26.5,
|
||
"min_conf": 16.3, "htf_counter": 6.7, "stretch": 6.4, "deadband": 1.6}
|
||
|
||
|
||
def _ema_series(v, p):
|
||
k = 2 / (p + 1); out = [v[0]]
|
||
for x in v[1:]:
|
||
out.append(out[-1] + k * (x - out[-1]))
|
||
return out
|
||
|
||
|
||
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 _htf_sign(C, i, n, atr):
|
||
if i < n * _EMA_SLOW:
|
||
return 0
|
||
seg = C[i - n * _EMA_SLOW:i + 1:n]
|
||
if len(seg) < _EMA_SLOW + 2:
|
||
return 0
|
||
ef = _ema_series(seg, _EMA_FAST)[-1]; es = _ema_series(seg, _EMA_SLOW)[-1]
|
||
dead = _HTF_DEADBAND * atr * (n ** 0.5)
|
||
return 1 if ef - es > dead else (-1 if es - ef > dead else 0)
|
||
|
||
|
||
def verteilung(w, H, L, C, A, EF, ES, AD, HTF, H1, PH, PL, lo, hi, mit_gates):
|
||
"""→ Counter über die Block-Gründe. `mit_gates=False` = nur `_build` (die alte,
|
||
ungültige Referenz), True = voller Live-Stack."""
|
||
z = Counter()
|
||
pend = None
|
||
for i in range(max(lo, _PIV_LOOK + _EMA_SLOW + 5), hi):
|
||
atr = A[i]
|
||
if not atr or atr < _ATRMIN:
|
||
continue
|
||
w._pb_feats = {"atr": max(atr, _ATR_FLOOR_PB)}
|
||
# ⚠ bisect statt linearer Filterung (2026-08-07). Die alte Fassung
|
||
# scannte je Bar die KOMPLETTE Pivot-Liste = O(Bars x Pivots) und war
|
||
# der Engpass des Projekts (80k-Lauf: ~9 min -> 8,9 s). PH/PL sind
|
||
# sortiert, die Binaersuche liefert DIESELBE Menge — bewiesen in
|
||
# tests/test_pivotfenster.py, nicht behauptet.
|
||
_a1 = bisect_left(PH, i - _PIV_LOOK); _b1 = bisect_right(PH, i - _PIV_K)
|
||
_a2 = bisect_left(PL, i - _PIV_LOOK); _b2 = bisect_right(PL, i - _PIV_K)
|
||
w._pb_levels = {"ph": [C[j] for j in PH[_a1:_b1]],
|
||
"pl": [C[j] for j in PL[_a2:_b2]]}
|
||
rec, _ = w._build(EF[i], ES[i], C[i], atr, "M5", 5,
|
||
htf_trend=HTF[i], h1_trend=H1[i], angle=AD[i])
|
||
sig = rec.get("signal")
|
||
if not mit_gates:
|
||
z["(gerichtet)" if sig in ("LONG", "SHORT")
|
||
else (rec.get("block") or "(unmarkiert)")] += 1
|
||
continue
|
||
# ── Breakout-Bestätigung (Mechanik 1:1 aus `_confirm_breakout`) ──────
|
||
if sig in ("LONG", "SHORT"):
|
||
d = 1 if sig == "LONG" else -1
|
||
if pend is None or pend["dir"] != d:
|
||
pend = {"dir": d, "level": C[i] + d * _K * atr,
|
||
"invalid": C[i] - d * _K * atr, "confirmed": False, "bar": i}
|
||
if not pend["confirmed"]:
|
||
broke = (C[i] >= pend["level"]) if d > 0 else (C[i] <= pend["level"])
|
||
against = (C[i] <= pend["invalid"]) if d > 0 else (C[i] >= pend["invalid"])
|
||
if broke:
|
||
pend["confirmed"] = True
|
||
else:
|
||
if against or (i - pend["bar"]) > _TIMEOUT_BARS:
|
||
pend = {"dir": d, "level": C[i] + d * _K * atr,
|
||
"invalid": C[i] - d * _K * atr,
|
||
"confirmed": False, "bar": i}
|
||
z["breakout_pending"] += 1
|
||
continue
|
||
else:
|
||
pend = None
|
||
z[rec.get("block") or "(unmarkiert)"] += 1
|
||
continue
|
||
rec2 = w._room_gate(rec, C[i])
|
||
if rec2.get("signal") not in ("LONG", "SHORT"):
|
||
z["entry_room"] += 1
|
||
continue
|
||
z["(gerichtet)"] += 1
|
||
return z
|
||
|
||
|
||
def zeig(titel, z):
|
||
ges = sum(z.values())
|
||
print(f"\n {titel} (n={ges})")
|
||
print(f" {'Grund':<20}{'Anteil':>9}{'live':>9}{'Delta':>9}")
|
||
for g, n in z.most_common():
|
||
p = 100.0 * n / max(1, ges)
|
||
lv = _LIVE.get(g)
|
||
print(f" {g:<20}{p:>8.1f} %"
|
||
+ (f"{lv:>8.1f} %{p-lv:>+8.1f}" if lv is not None else f"{'—':>9}{'':>9}"))
|
||
return 100.0 * z["(gerichtet)"] / max(1, ges)
|
||
|
||
|
||
def main():
|
||
n_bars = int(sys.argv[1]) if len(sys.argv) > 1 else 80000
|
||
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_M5, 0, n_bars)
|
||
mt5.shutdown()
|
||
H = [float(b["high"]) for b in bars]; L = [float(b["low"]) for b in bars]
|
||
C = [float(b["close"]) for b in bars]
|
||
EF = _ema_series(C, _EMA_FAST); ES = _ema_series(C, _EMA_SLOW)
|
||
A = _atr_series(H, L, C)
|
||
AD = [0.0] * len(C)
|
||
for i in range(_N_BARS, len(C)):
|
||
AD[i] = calc_trend_angle(C[i - _N_BARS:i + 1], _ANGLE_LR) - 90.0
|
||
if abs(AD[i]) < _ANGLE_DEAD:
|
||
AD[i] = 0.0
|
||
HTF = [_htf_sign(C, i, 6, A[i] or 0.1) for i in range(len(C))]
|
||
H1S = [_htf_sign(C, i, 12, A[i] or 0.1) for i in range(len(C))]
|
||
PH = [j for j in range(_PIV_K, len(C) - _PIV_K)
|
||
if H[j] == max(H[j - _PIV_K:j + _PIV_K + 1])]
|
||
PL = [j for j in range(_PIV_K, len(C) - _PIV_K)
|
||
if L[j] == min(L[j - _PIV_K:j + _PIV_K + 1])]
|
||
|
||
w = WaveRecommender(lambda: None)
|
||
w.set_reversal_enabled(False) # Live-Stand seit 04.08.
|
||
# ⚠⚠ OHNE DIESE ZEILE IST DAS ENTRY-RAUM-GATE AUS: `_entry_room_atr`
|
||
# ist im Konstruktor 0.0, und `_room_gate` gibt dann `rec` unveraendert
|
||
# zurueck — der Aufruf sieht aus wie ein Gate, ist aber ein No-op.
|
||
# Genau das ist mir am 05.08. in drei Skripten passiert (live blockt das
|
||
# Gate 35 % aller Empfehlungen, im Backtest 0 %).
|
||
w.set_entry_room(0.6) # Live-Wert
|
||
w.set_dead_hours("") # live leer
|
||
|
||
print("=" * 74)
|
||
print(f" SIGNAL-VERTEILUNG — {sym} M5, {len(C)} Bars")
|
||
print(" Der gueltige Massstab fuer den WARTEN-Anteil (s. Modulkopf)")
|
||
print("=" * 74)
|
||
g_ohne = zeig("A) NUR `_build` — die ALTE, ungueltige Referenz (~57 % erwartet)",
|
||
verteilung(w, H, L, C, A, EF, ES, AD, HTF, H1S, PH, PL,
|
||
0, len(C), False))
|
||
g_mit = zeig("B) VOLLER Live-Stack (+ Breakout-Bestaetigung + Entry-Raum)",
|
||
verteilung(w, H, L, C, A, EF, ES, AD, HTF, H1S, PH, PL,
|
||
0, len(C), True))
|
||
print("\n" + "=" * 74)
|
||
print(" ERGEBNIS")
|
||
print("=" * 74)
|
||
print(f" gerichtet nur `_build` {g_ohne:>6.1f} % (daher die 43-%-Erwartung)")
|
||
print(f" gerichtet voller Stack {g_mit:>6.1f} % <- der GUELTIGE Massstab")
|
||
print(f" gerichtet LIVE (seit 01.08.) {_LIVE['(gerichtet)']:>5.1f} %")
|
||
d = _LIVE["(gerichtet)"] - g_mit
|
||
print()
|
||
if abs(d) <= 3:
|
||
print(f" => Live liegt {d:+.1f} Pp am Massstab. Der WARTEN-Anteil ist NORMAL,")
|
||
print(" nicht defekt. Der 43-%-Alarm war ein Vergleichsfehler.")
|
||
elif d < 0:
|
||
print(f" => Live liegt {d:+.1f} Pp DARUNTER — es gibt also einen echten Rest,")
|
||
print(" den die Bar-Simulation nicht erklaert (Cooldowns, EIA, News, TF-Wechsel).")
|
||
else:
|
||
print(f" => Live liegt {d:+.1f} Pp DARUEBER — mehr Signale als der Stack hergibt.")
|
||
print()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|