Files
AH-Oil-Trader/backtest_pbreak_retrain.py
T
Axel HocksandClaude Opus 5 2e89724368 P(break) Niveau-Reparatur gemessen: beide Varianten fallen durch
Folgeauftrag zur B4-Pruefung. Hypothese war: das Modell rankt brauchbar, nur
sein Niveau haengt an einer wandernden Basisrate. Geprueft wurden ein
Regime-Merkmal (ATR/gleitender Median) und ein laufend nachgefuehrter
Achsenabschnitt, Regel vorab fixiert.

collect() in backtest_pbreak_retrain.py hat dafuer einen ADDITIVEN
REG-Parameter bekommen (Default None), Ausgabe ohne ihn bitgenau geprueft
(4371 Zeilen, 0 Abweichungen).

Auf Backtest-Daten besteht die Roll-Achse (Kalib 2,61->1,95 und 10,51->3,25 Pp,
AUC leicht besser), das Regime-Merkmal faellt durch (H2->H1 schlechter).

Auf LIVE-Daten (n=921 ab 01.08.) faellt auch die Roll-Achse durch: Kalib
14,16 -> 10,64 Pp (also nicht repariert), AUC 0,531 -> 0,516 (ueber der
Toleranz), und der Nachbar n=800 kippt auf 15,14 Pp = schlechter als der
Ausgangswert.

Grund: die oberste Klasse bleibt INVERTIERT (69 % vorhergesagt, 17,2 % real).
Eine Niveau-Verschiebung kann eine invertierte Klasse nicht reparieren. Die
Ausgangshypothese ist damit widerlegt.

Drei Erklaerungen fuer die Basisraten-Luecke geprueft, alle widerlegt:
  Sammelart (live 1x je Beruehrung vs Backtest jeder Bar): -1,7 Pp, falsche
    Richtung
  Vola-Regime: Bruchrate ueber 5 Quintile flach (37,0 bis 39,2 %)
  aktuelles Regime ungewoehnlich: letzte 7 Tage Median 0,96 = normal
Der flache Vola-Befund erklaert zugleich, warum das Regime-Merkmal nichts
bringt.

Kontrolle: das Modell VOR dem 31.07. hat auf diesen Daten AUC 0,364/0,406 -
unter 0,5, also invertiert. Reproduziert exakt den Grund fuer das
Nachtrainieren.

Nichts gebaut. Naechster Schritt ist kein drittes Fitten, sondern die einzige
ungeprueft gebliebene Divergenz: die Level-Auswahl selbst (Backtest baut sie
mit pick_level/_cluster nach, live entscheidet _draw_levels).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:18:35 +02:00

315 lines
13 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
"""P(break) NEU TRAINIEREN auf einer LIVE-SPIEGELNDEN Stichprobe (Weg b, 2026-07-31).
WARUM: `analyze_pbreak_live.py` + `backtest_pbreak_touches.py` haben belegt, dass das
Modell nicht verfallen ist, sondern live eine ANDERE Merkmalsverteilung bekommt:
Merkmal Live-Mittel Training-µ Differenz
mom3 0,149 1,4523 1,303
mom6 0,181 1,6565 1,476
Ursache: das ALTE Training fixiert das Level beim Entry (≥0,3×ATR entfernt) und wartet
auf den ersten Touch nach einem echten ANLAUF → mom3 ≈ 1,45×ATR (ein Schub). **Live
wählt `_draw_levels` das nächstgelegene Level jede Sekunde NEU** (mit Hysterese) — der
Kurs steht dann oft neben einem Level, das gerade erst zum nächsten wurde, ohne je
darauf zugelaufen zu sein → mom3 ≈ 0,15. Das Modell antwortet konsequent „kein Schub →
kein Durchbruch → 23 %", liegt damit immer unter der Schwelle 0,55, und das Gate war
seit Inbetriebnahme faktisch nie aktiv (91 % aller Berührungen geschlossen).
WAS DIESES SKRIPT ANDERS MACHT — die Stichprobe spiegelt den Live-Pfad:
* Level-Auswahl **dynamisch mit Hysterese**, wie `engine._draw_levels`: die Linie
bleibt stehen, bis der Kurs sie WIRKLICH durchbricht (±0,1×ATR); nur wenn sie
>1,5×ATR entfernt ist UND ein deutlich näheres Level existiert, wird gewechselt.
* Gesampelt wird an **JEDEM Bar** im 0,15×ATR-Band um dieses Level (= live
`at_level`) — KEINE Anlauf-Bedingung.
* Ausgang identisch zu live/Training: erreicht der Kurs binnen 12 M5-Bars zuerst
`level ± 0,5×ATR` in Bruchrichtung (break) oder in Gegenrichtung (bounce);
Timeout zählt als bounce.
* Merkmale wie live: mom6/mom3 (M5, ×Richtung), wt (EMA-Richtung), dist
(|level entry|/ATR), plus optional die Touch-Zahl (User-Idee, im alten
Backtest +0,010 AUC).
KONTROLLE (entscheidend): das ALTE Modell wird auf DERSELBEN live-spiegelnden
Stichprobe ausgewertet. Erwartung ~AUC 0,5 (= der live beobachtete Wert). Nur wenn
das neue Modell dort deutlich besser ist, ist der Umbau belegt — sonst hätte man
lediglich zwei Stichproben verglichen.
Urteil: neues Modell muss out-of-sample (Fit H1 → H2) sowohl TRENNEN (AUC) als auch
KALIBRIERT sein. Beides, sonst kein Einbau.
"""
import sys
from collections import deque
import numpy as np
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (_EMA_FAST, _EMA_SLOW, _N_BARS, _ANGLE_LR, _ANGLE_DEAD,
_REVERSAL_STRETCH, _STRETCH_MAX)
_MAXH = 200; _ATRMIN = 0.12
_PIV_K = 3; _BRK_W = 12; _BRK_ATR = 0.5
_M30 = 6; _PIV_LOOKBACK = 50
_ZONE = 0.15 # „am Level" (wie live `at_level`)
_CLUSTER = 0.5 # Level-Clusterung in ×ATR (wie `_draw_levels`)
_HYST = 0.1 # echter Durchbruch erst ab ±X×ATR (Hysterese)
_FAR = 1.5 # alte Linie gilt als „irrelevant weit" ab X×ATR
_TWIN = 6 # Touch-Fenster in M5-Bars (30 min)
# Altes Live-Modell (M30-Fit) — dient als KONTROLLE auf derselben Stichprobe
_OLD_MU = (1.6565, 1.4523, 0.9465, 1.9133)
_OLD_SD = (2.5291, 2.4177, 0.2250, 2.1257)
_OLD_W = (-0.3264, 0.4719, 1.4330, 0.0943, -0.1885)
def _ema_series(v, p):
k = 2.0 / (p + 1); o = []; e = v[0]
for i, x in enumerate(v):
e = x if i == 0 else x * k + e * (1 - k)
o.append(e)
return o
def _atr_series(H, L, C, p=14):
t = [0.0]
for i in range(1, len(C)):
t.append(max(H[i] - L[i], abs(H[i] - C[i - 1]), abs(L[i] - C[i - 1])))
out = []
for i in range(len(C)):
if not i:
out.append(None); continue
w = t[max(1, i - p + 1):i + 1]
out.append(sum(w) / max(1, len(w)))
return out
def build_levels(H, L):
"""M30-Pivots k=3, kausal, geclustert — wie `wave.pb_levels30` + `_draw_levels`."""
nm = len(H) // _M30
mh = [max(H[i * _M30:(i + 1) * _M30]) for i in range(nm)]
ml = [min(L[i * _M30:(i + 1) * _M30]) for i in range(nm)]
born = [[] for _ in range(nm)]
for p in range(_PIV_K, nm - _PIV_K):
if mh[p] == max(mh[p - _PIV_K:p + _PIV_K + 1]) and p + _PIV_K < nm:
born[p + _PIV_K].append(mh[p])
if ml[p] == min(ml[p - _PIV_K:p + _PIV_K + 1]) and p + _PIV_K < nm:
born[p + _PIV_K].append(ml[p])
lv = []; win = deque(); cur = []
for m in range(nm):
win.append(born[m]); cur.extend(born[m])
while len(win) > _PIV_LOOKBACK:
for x in win.popleft():
try: cur.remove(x)
except ValueError: pass
lv.append(sorted(cur))
return lv
def _cluster(levels, atr):
"""Nahe Pivots zu einer Linie zusammenfassen (~0,5×ATR) — wie `_draw_levels`."""
if not levels:
return []
out = []; grp = [levels[0]]
for x in levels[1:]:
if x - grp[-1] <= _CLUSTER * atr:
grp.append(x)
else:
out.append(sum(grp) / len(grp)); grp = [x]
out.append(sum(grp) / len(grp))
return out
def pick_level(cands, px, d, atr, prev):
"""Ziel-Level in Trade-Richtung MIT Hysterese (Nachbau von `_draw_levels`).
d>0 → nächster Widerstand ÜBER px · d<0 → nächste Unterstützung UNTER px.
`prev` = zuletzt gezeichnete Linie; sie bleibt stehen, bis der Kurs sie wirklich
durchbricht (±_HYST×ATR) — außer sie ist >_FAR×ATR weg und es gibt eine deutlich
nähere.
"""
side = [x for x in cands if (x > px if d > 0 else x < px)]
fresh = (min(side) if d > 0 else max(side)) if side else None
if prev is not None:
broken = (px > prev + _HYST * atr) if d > 0 else (px < prev - _HYST * atr)
if not broken:
far = abs(prev - px) > _FAR * atr
if not (far and fresh is not None and abs(fresh - px) < abs(prev - px) - 0.5 * atr):
return prev
return fresh
def outcome(H, L, level, atr, j, d):
"""break/bounce wie live: level ± 0,5×ATR, Fenster 12 Bars, Timeout = bounce."""
up = level + d * _BRK_ATR * atr
dn = level - d * _BRK_ATR * atr
for k in range(j, min(j + _BRK_W, len(H))):
if (H[k] >= up) if d > 0 else (L[k] <= up):
return 1
if (L[k] <= dn) if d > 0 else (H[k] >= dn):
return 0
return 0
def collect(a, b, H, L, C, EF, ES, AT, LV, REG=None):
"""Live-spiegelnde Stichprobe: jeder Bar im 0,15×ATR-Band um das dyn. Level.
⚠ `REG` ist ADDITIV (2026-08-07, für `backtest_pbreak_regime.py`): wird eine
Regime-Reihe übergeben, bekommt jede Zeile zusätzlich `"reg"`. OHNE das
Argument ist die Ausgabe **bitgenau wie vorher** — die dokumentierten Zahlen
dieses Skripts dürfen sich nicht verschieben (Migrations-Regel)."""
TH = _REVERSAL_STRETCH
out = []
i = max(a, _N_BARS, 320)
while i < min(b, len(C) - _MAXH - 1):
atr = AT[i]
if not atr or atr <= 0:
i += 1; continue
atr = max(atr, _ATRMIN); es = ES[i]; ef = EF[i]
stretch = (C[i] - es) / atr
ang = calc_trend_angle(C[i - _ANGLE_LR - 2:i], _ANGLE_LR); ad = ang - 90.0
d = 0
if stretch <= -TH and ad >= _ANGLE_DEAD: d = 1
elif stretch >= TH and ad <= -_ANGLE_DEAD: d = -1
elif abs(stretch) < _STRETCH_MAX: d = 1 if ef > es else -1 if ef < es else 0
if not d:
i += 1; continue
entry = C[i]
# ── Position „läuft" bis _MAXH Bars; dabei jeden Bar prüfen (wie `_pos_loop`)
prev_lvl = None; hist = deque()
for j in range(i + 1, min(i + _MAXH, len(C) - _BRK_W)):
aj = AT[j]
if not aj:
continue
aj = max(aj, _ATRMIN)
lv = LV[min(j // _M30, len(LV) - 1)]
cl = _cluster(lv, aj)
level = pick_level(cl, C[j], d, aj, prev_lvl)
prev_lvl = level
if level is None:
continue
at_level = abs(C[j] - level) <= _ZONE * aj
hist.append((j, at_level, level))
while hist and hist[0][0] < j - _TWIN:
hist.popleft()
if not at_level:
continue
# Touch-Zahl: Eintritte in die Zone desselben Levels im Fenster
tc = 0; prev_in = False
for _jj, inz, lvl2 in hist:
inside = inz and abs(lvl2 - level) < 1e-9
if inside and not prev_in:
tc += 1
prev_in = inside
tc = max(1, tc)
y = outcome(H, L, level, aj, j, d)
feat = [(C[j] - C[max(0, j - 6)]) * d / aj,
(C[j] - C[max(0, j - 3)]) * d / aj,
1.0 if (EF[j] - ES[j]) * d > 0 else 0.0,
abs(level - entry) / aj]
zeile = {"feat": feat, "y": y, "tc": tc,
"feat5": feat + [1.0 if 2 <= tc <= 4 else 0.0,
1.0 if tc >= 5 else 0.0]}
if REG is not None:
zeile["reg"] = REG[min(j, len(REG) - 1)]
out.append(zeile)
i += 12 # nächste „Position" versetzt starten (weniger Überlappung)
return out
def fit_logreg(X, y, iters=3000, lr=0.3):
n, m = X.shape
Xb = np.hstack([np.ones((n, 1)), X]); w = np.zeros(m + 1)
for _ in range(iters):
p = 1 / (1 + np.exp(-(Xb @ w)))
w -= lr * (Xb.T @ (p - y)) / n
return w
def auc(ps, ys):
ps = np.asarray(ps); ys = np.asarray(ys)
order = np.argsort(ps); ys = ys[order]
pos = ys.sum(); neg = len(ys) - pos
if not pos or not neg:
return 0.5
ranks = np.arange(1, len(ys) + 1)
return float((ranks[ys == 1].sum() - pos * (pos + 1) / 2) / (pos * neg))
def calib(ps, ys, label):
print(f" Kalibrierung {label}: {'Bucket':<12}{'n':>7}{'vorherg.':>10}{'real':>8}")
for lo, hi in ((0, .2), (.2, .35), (.35, .5), (.5, .65), (.65, 1.01)):
sel = [(p, y) for p, y in zip(ps, ys) if lo <= p < hi]
if len(sel) < 40:
continue
print(f" {lo:.2f}{hi:<7.2f}{len(sel):>7}"
f"{100*np.mean([p for p, _ in sel]):>9.1f}%"
f"{100*np.mean([y for _, y in sel]):>7.1f}%")
def old_model(feat):
z = _OLD_W[0]
for k in range(4):
z += _OLD_W[k + 1] * (feat[k] - _OLD_MU[k]) / _OLD_SD[k]
return 1 / (1 + np.exp(-max(-30, min(30, z))))
def main():
n = int(sys.argv[1]) if len(sys.argv) > 1 else 80000
mt5.initialize(); sym = None
for c in ("SpotCrude", "USOIL", "WTI", "XTIUSD"):
if mt5.symbol_info(c): sym = c; break
bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, n)
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); AT = _atr_series(H, L, C)
LV = build_levels(H, L)
mid = len(C) // 2
s1 = collect(_N_BARS, mid, H, L, C, EF, ES, AT, LV)
s2 = collect(mid, len(C), H, L, C, EF, ES, AT, LV)
print("=" * 100)
print(f" P(break) NEU TRAINIERT auf live-spiegelnder Stichprobe — {sym} M5 ({len(C)} Bars)")
print(f" Level dynamisch mit Hysterese · gesampelt an JEDEM Bar im {_ZONE}×ATR-Band")
print(f" H1 n={len(s1)} H2 n={len(s2)}")
print("=" * 100)
if len(s1) < 500 or len(s2) < 500:
print("zu wenige Stichproben"); return
y1 = np.array([e["y"] for e in s1], float); y2 = np.array([e["y"] for e in s2], float)
print(f"\n Basisrate Durchbruch: H1 {100*y1.mean():.1f} % H2 {100*y2.mean():.1f} %")
# Merkmalsverteilung — spiegelt sie wirklich die Live-Werte?
F1 = np.array([e["feat"] for e in s1])
print(f"\n Merkmalsverteilung (Kontrolle gegen die Live-Rekonstruktion):")
print(f" {'Merkmal':<8}{'hier µ':>10}{'ALT-Training µ':>16}{'Live gemessen':>15}")
for k, nm, old, live in ((0, "mom6", 1.6565, 0.181), (1, "mom3", 1.4523, 0.149)):
print(f" {nm:<8}{F1[:, k].mean():>10.3f}{old:>16.3f}{live:>15.3f}")
# ── KONTROLLE: altes Modell auf DIESER Stichprobe
p_old2 = np.array([old_model(e["feat"]) for e in s2])
print(f"\n KONTROLLE — ALTES Modell auf dieser Stichprobe (H2):")
print(f" AUC {auc(p_old2, y2):.3f} Ø-Vorhersage {100*p_old2.mean():.1f} % "
f"vs. real {100*y2.mean():.1f} %")
print(f" (live beobachtet: AUC 0,539 · 23 % vs 41 % — passt es zusammen?)")
# ── NEUES Modell, 4 und 6 Merkmale
for tag, key in (("NEU 4 Merkmale", "feat"), ("NEU 6 Merkmale (+Touch-Zahl)", "feat5")):
X1 = np.array([e[key] for e in s1]); X2 = np.array([e[key] for e in s2])
mu = X1.mean(0); sd = X1.std(0) + 1e-9
w = fit_logreg((X1 - mu) / sd, y1)
p2 = 1 / (1 + np.exp(-(np.hstack([np.ones((len(X2), 1)), (X2 - mu) / sd]) @ w)))
p1 = 1 / (1 + np.exp(-(np.hstack([np.ones((len(X1), 1)), (X1 - mu) / sd]) @ w)))
print(f"\n {tag}")
print(f" AUC in-sample H1 {auc(p1, y1):.3f} out-of-sample H2 {auc(p2, y2):.3f}")
calib(p2, y2, "H2")
if key == "feat":
print(f"\n → zum Einbetten (falls dieses Modell gewinnt):")
print(f" _PB_MU = ({', '.join(f'{x:.4f}' for x in mu)})")
print(f" _PB_SD = ({', '.join(f'{x:.4f}' for x in sd)})")
print(f" _PB_W = ({', '.join(f'{x:.4f}' for x in w)})")
if __name__ == "__main__":
main()