Files
AH-Oil-Trader/backtest_dist_gated.py
T
Axel HocksandClaude Opus 5 cddcefa463 Der Engpass war nie die Mathematik: quadratische Schleife, ~9 min -> 8,9 s
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>
2026-08-07 06:21:49 +02:00

207 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()