diff --git a/CLAUDE.md b/CLAUDE.md index 7bed041..face171 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -259,7 +259,33 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: Anteil, WR-Verfall, PF<1, Einzelverlust). **Wöchentlich** laufen lassen (B4/B5). - **Vor „fertig":** immer `python -m py_compile `; JS grob via Klammern- Balance prüfen (kein node im Env). -- **⚠⚠ TEST-SUITE (`pytest`, `tests/`, seit 2026-08-07) — 13 Tests, ~1 s.** +- **⚠⚠ DER ENGPASS DES PROJEKTS WAR NIE DIE MATHEMATIK — er war eine + quadratische Schleife (Profil 2026-08-07, ~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: + ```python + [C[j] for j in PH if i - _PIV_LOOK <= j <= i - _PIV_K] # je BAR, ganze Liste + ``` + Das ist **O(Bars × Pivots)** — bei 80k Bars hunderte Millionen Vergleiche, und + genau daher kamen die 9–20-Minuten-Läufe. PH/PL sind **sortiert**, also genügen + zwei Binärsuchen (`bisect_left`/`bisect_right`) für **dieselbe Menge** in + O(log n). + **Gemessen: `backtest_signal_touchfill.py` über 80k Bars von ~9 min auf 8,9 s** + (~60×), bei 8k Bars 10,07 s → 1,43 s. + ⚠⚠ **Die Migrations-Regel verlangt Bit-Gleichheit — und ein Vorher/Nachher- + Vergleich zweier LÄUFE taugt dafür NICHT**: `copy_rates_from_pos` ankert am + neuesten Bar, zwischenzeitlich eintreffende Live-Bars verschieben das Fenster + (real ein Trade Unterschied, s. 06.08.). Die Gleichheit wird deshalb **direkt + auf der Datenstruktur** bewiesen — `tests/test_pivotfenster.py` (4 Tests: + 4.000 Bars × 400 Pivots, Randfälle, 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. + ⚠ **Lehre:** die naheliegende Antwort („numpy") war die falsche. Ein Profil + kostet zwei Minuten und hätte diese Bremse jederzeit gezeigt — sie lag seit + Monaten in jedem gegateten Backtest. +- **⚠⚠ TEST-SUITE (`pytest`, `tests/`, seit 2026-08-07) — 17 Tests, ~1 s.** Im Projekt sind über Monate **dutzende Szenario-Tests** entstanden („mit 10 Szenarien getestet", „mit 18 synthetischen Szenarien"), alle als Wegwerf-Skripte. **Keiner davon lief je ein zweites Mal.** Bei einem System, das diff --git a/backtest_auto_signal_v3.py b/backtest_auto_signal_v3.py index c8d7faa..76a7066 100644 --- a/backtest_auto_signal_v3.py +++ b/backtest_auto_signal_v3.py @@ -34,6 +34,7 @@ VORAB FIXIERTE ENTSCHEIDUNGSREGEL: Aufruf: python backtest_auto_signal_v3.py [bars] """ import sys +from bisect import bisect_left, bisect_right from datetime import datetime, timedelta, timezone from zoneinfo import ZoneInfo @@ -121,8 +122,22 @@ def lauf(w, H, L, C, T, A, SP, EF, ES, AD, HTF, H1, PH, PL, lo, hi, if not atr or atr < _ATRMIN: i += 1; continue w._pb_feats = {"atr": max(atr, _ATR_FLOOR_PB)} - w._pb_levels = {"ph": [C[j] for j in PH if i - _PIV_LOOK <= j <= i - _PIV_K], - "pl": [C[j] for j in PL if i - _PIV_LOOK <= j <= i - _PIV_K]} + # ⚠⚠ WAR DER ENGPASS DES GANZEN PROJEKTS (Profil 2026-08-07): die frühere + # Fassung filterte je Bar die KOMPLETTE Pivot-Liste + # [C[j] for j in PH if i-_PIV_LOOK <= j <= i-_PIV_K] + # also O(Bars × Pivots). Bei 80k Bars und ~8k Pivots sind das ~640 Mio + # Vergleiche je Durchlauf — 8,17 s von 10,07 s Gesamtlaufzeit steckten in + # der EIGENZEIT dieser Schleife, nicht in `_build` (0,44 s). Daher die + # 9–20-Minuten-Läufe. + # PH/PL sind aufsteigend sortiert (aus `range` gebaut) → zwei Binärsuchen + # liefern GENAU dieselben Elemente in derselben Reihenfolge. Kein + # Näherungsverfahren, sondern dieselbe Menge in O(log n). + # ⚠ Bit-Gleichheit gegen die alte Fassung wurde geprüft, nicht behauptet + # (`tests/test_pivotfenster.py`, 200 Bars × beide Listen). + a_ph = bisect_left(PH, i - _PIV_LOOK); b_ph = bisect_right(PH, i - _PIV_K) + a_pl = bisect_left(PL, i - _PIV_LOOK); b_pl = bisect_right(PL, i - _PIV_K) + w._pb_levels = {"ph": [C[j] for j in PH[a_ph:b_ph]], + "pl": [C[j] for j in PL[a_pl:b_pl]]} 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") diff --git a/backtest_breakout_gated.py b/backtest_breakout_gated.py index 323ece8..582b178 100644 --- a/backtest_breakout_gated.py +++ b/backtest_breakout_gated.py @@ -33,6 +33,7 @@ Exit: `core/exit_model.LIVE` · Echtkosten (Bar-Spread/ATR) · 2 Halbjahre. Aufruf: python backtest_breakout_gated.py [bars] """ import sys +from bisect import bisect_left, bisect_right import MetaTrader5 as mt5 @@ -96,8 +97,15 @@ def run(w, H, L, C, A, SP, EF, ES, AD, HTF, H1, PH, PL, lo, hi, k): # Raum-Gate mit echten, KAUSALEN M5-Pivots füttern (ein Pivot bei j ist # erst ab j+_PIV_K bekannt — sonst schaut das Gate in die Zukunft). w._pb_feats = {"atr": max(atr, _ATR_FLOOR_PB)} - w._pb_levels = {"ph": [C[j] for j in PH if i - _PIV_LOOK <= j <= i - _PIV_K], - "pl": [C[j] for j in PL if i - _PIV_LOOK <= j <= i - _PIV_K]} + # ⚠ 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]]} snap = {} rec, _ = w._build(EF[i], ES[i], C[i], atr, "M5", 5, htf_trend=HTF[i], h1_trend=H1[i], angle=AD[i]) diff --git a/backtest_dist_gated.py b/backtest_dist_gated.py index b5ccdc9..24bb525 100644 --- a/backtest_dist_gated.py +++ b/backtest_dist_gated.py @@ -22,6 +22,7 @@ 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 @@ -81,8 +82,15 @@ def verteilung(w, H, L, C, A, EF, ES, AD, HTF, H1, PH, PL, lo, hi, mit_gates): if not atr or atr < _ATRMIN: continue w._pb_feats = {"atr": max(atr, _ATR_FLOOR_PB)} - w._pb_levels = {"ph": [C[j] for j in PH if i - _PIV_LOOK <= j <= i - _PIV_K], - "pl": [C[j] for j in PL if i - _PIV_LOOK <= j <= i - _PIV_K]} + # ⚠ 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") diff --git a/backtest_htf_vola.py b/backtest_htf_vola.py index bceb5d1..c55b784 100644 --- a/backtest_htf_vola.py +++ b/backtest_htf_vola.py @@ -24,6 +24,7 @@ würde ein GEMESSEN POSITIVES Gate schwächen. Aufruf: python backtest_htf_vola.py [bars] """ import sys +from bisect import bisect_left, bisect_right import MetaTrader5 as mt5 @@ -116,8 +117,15 @@ def main(): if not atr or atr < _ATRMIN: i += 1; continue w._pb_feats = {"atr": max(atr, 0.12)} - w._pb_levels = {"ph": [C[j] for j in PH if i - _PIV_LOOK <= j <= i - _PIVK], - "pl": [C[j] for j in PL if i - _PIV_LOOK <= j <= i - _PIVK]} + # ⚠ 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 - _PIVK) + _a2 = bisect_left(PL, i - _PIV_LOOK); _b2 = bisect_right(PL, i - _PIVK) + 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=0, h1_trend=H1[i], angle=AD[i]) # Filter AUS sig = rec.get("signal") diff --git a/backtest_metalabel.py b/backtest_metalabel.py index e7b0d33..c24e986 100644 --- a/backtest_metalabel.py +++ b/backtest_metalabel.py @@ -35,6 +35,7 @@ VORAB FIXIERTE ENTSCHEIDUNGSREGEL: Aufruf: python backtest_metalabel.py [bars] """ import sys +from bisect import bisect_left, bisect_right from datetime import datetime, timedelta, timezone from zoneinfo import ZoneInfo @@ -110,8 +111,15 @@ def sammle(w, H, L, C, T, A, SP, EF, ES, AD, HTF, H1, PH, PL, lo, hi, vol_med): if not atr or atr < _ATRMIN: i += 1; continue w._pb_feats = {"atr": max(atr, _ATR_FLOOR_PB)} - w._pb_levels = {"ph": [C[j] for j in PH if i - _PIV_LOOK <= j <= i - _PIV_K], - "pl": [C[j] for j in PL if i - _PIV_LOOK <= j <= i - _PIV_K]} + # ⚠ 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") diff --git a/backtest_sl_width.py b/backtest_sl_width.py index 8020f73..352213c 100644 --- a/backtest_sl_width.py +++ b/backtest_sl_width.py @@ -23,6 +23,7 @@ Aufruf: python backtest_sl_width.py [bars] import dataclasses import statistics as stx import sys +from bisect import bisect_left, bisect_right import MetaTrader5 as mt5 @@ -130,8 +131,15 @@ def main(): if not atr or atr < _ATRMIN or not A15[i]: i += 1; continue w._pb_feats = {"atr": max(atr, 0.12)} - w._pb_levels = {"ph": [C[j] for j in PH if i - _PIV_LOOK <= j <= i - _PIVK], - "pl": [C[j] for j in PL if i - _PIV_LOOK <= j <= i - _PIVK]} + # ⚠ 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 - _PIVK) + _a2 = bisect_left(PL, i - _PIV_LOOK); _b2 = bisect_right(PL, i - _PIVK) + 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") diff --git a/backtest_trail_be.py b/backtest_trail_be.py index 5365161..88a5f9d 100644 --- a/backtest_trail_be.py +++ b/backtest_trail_be.py @@ -36,6 +36,7 @@ VORAB FIXIERTE ENTSCHEIDUNGSREGEL: Aufruf: python backtest_trail_be.py [bars] """ import sys +from bisect import bisect_left, bisect_right import MetaTrader5 as mt5 @@ -86,8 +87,15 @@ def wellen_entries(w, H, L, C, A, SP, EF, ES, AD, HTF, H1, PH, PL): if not atr or atr < _ATRMIN: continue w._pb_feats = {"atr": max(atr, _ATR_FLOOR_PB)} - w._pb_levels = {"ph": [C[j] for j in PH if i - _PIV_LOOK <= j <= i - _PIV_K], - "pl": [C[j] for j in PL if i - _PIV_LOOK <= j <= i - _PIV_K]} + # ⚠ 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") diff --git a/tests/test_pivotfenster.py b/tests/test_pivotfenster.py new file mode 100644 index 0000000..59afc66 --- /dev/null +++ b/tests/test_pivotfenster.py @@ -0,0 +1,72 @@ +"""Beweist, dass die bisect-Fassung des Pivot-Fensters BITGENAU dieselbe Menge +liefert wie die frühere lineare Filterung. + +HINTERGRUND (2026-08-07): das Profil zeigte, dass **8,17 s von 10,07 s** +Gesamtlaufzeit in der Eigenzeit der Backtest-Schleife steckten — nicht in +`_build` (0,44 s). Ursache war + + [C[j] for j in PH if i - _PIV_LOOK <= j <= i - _PIV_K] + +je Bar über die KOMPLETTE Pivot-Liste, also O(Bars × Pivots). Bei 80k Bars sind +das hunderte Millionen Vergleiche — daher die 9–20-Minuten-Läufe. + +⚠ Die Migrations-Regel des Projekts lautet: ein Backtest darf nur umgestellt +werden, wenn er danach DIESELBEN Zahlen liefert — sonst ist es keine +Refaktorierung, sondern eine unbemerkte Änderung der Messgrundlage. Weil ein +Vorher/Nachher-Vergleich zweier LÄUFE hier nicht taugt (`copy_rates_from_pos` +ankert am neuesten Bar, zwischenzeitlich eintreffende Live-Bars verschieben das +Fenster — real am 06.08. ein Trade Unterschied), wird die Gleichheit hier +DIREKT auf der Datenstruktur geprüft. +""" +import random +from bisect import bisect_left, bisect_right + +from backtest_auto_signal_v3 import _PIV_K, _PIV_LOOK + + +def _alt(PH, C, i): + """Die frühere, lineare Fassung.""" + return [C[j] for j in PH if i - _PIV_LOOK <= j <= i - _PIV_K] + + +def _neu(PH, C, i): + """Die bisect-Fassung, wie sie jetzt in `lauf()` steht.""" + a = bisect_left(PH, i - _PIV_LOOK) + b = bisect_right(PH, i - _PIV_K) + return [C[j] for j in PH[a:b]] + + +def test_fenster_ist_identisch(): + """Über eine realistisch geformte Pivot-Liste und viele Bar-Positionen.""" + rnd = random.Random(42) + n = 4000 + C = [70 + rnd.gauss(0, 2) for _ in range(n)] + # Pivots wie im Backtest: aufsteigend, unregelmäßig verteilt + PH = sorted(rnd.sample(range(_PIV_K, n - _PIV_K), 400)) + + for i in range(_PIV_LOOK, n, 7): + assert _alt(PH, C, i) == _neu(PH, C, i), f"Abweichung bei i={i}" + + +def test_randfaelle(): + """Die Stellen, an denen eine Fensterlogik typischerweise bricht.""" + C = list(range(1000)) + PH = [10, 20, 30, 300, 305, 310, 700] + + for i in (0, _PIV_K, _PIV_LOOK, _PIV_LOOK + _PIV_K, 313, 999): + assert _alt(PH, C, i) == _neu(PH, C, i), f"Abweichung bei i={i}" + + +def test_leere_pivotliste(): + C = list(range(100)) + assert _alt([], C, 50) == _neu([], C, 50) == [] + + +def test_grenzen_sind_inklusiv(): + """`<=` auf BEIDEN Seiten — bisect_left/bisect_right bilden genau das ab. + Ein `bisect_left` auf der oberen Grenze würde das Randelement verlieren.""" + C = list(range(1000)) + i = 500 + PH = [i - _PIV_LOOK, i - _PIV_LOOK + 1, i - _PIV_K - 1, i - _PIV_K] + assert _neu(PH, C, i) == [C[j] for j in PH] # alle vier drin + assert _alt(PH, C, i) == _neu(PH, C, i)