Files
AH-Oil-Trader/analyze_divergence.py
T
Axel HocksandClaude Opus 5 97617cd81d Deployment-Drift: Stufe 1 (Trail-mult) + Stufe 2 (Entscheidungs-Telemetrie)
Behebt die strukturelle Schwachstelle, die heute dreimal zugeschlagen hat:
Komponenten werden unter anderen Bedingungen BETRIEBEN als VALIDIERT.
Track B schuetzt gegen Overfitting, nicht gegen Deployment-Drift.

STUFE 1 (core/trailing.py): _MULT_BY_TF durchgehend 1,5 (vorher M15 2,0 /
M30 2,5 / H1 3,0) und _MULT_BREAKOUT_ADD 0,5 -> 0,0. Die Staffelung war nie
gemessen; backtest_trailing.py validiert nur 1,5 und die breiteren Werte
sind dort messbar schlechter (80k Bars, 2 Halbjahre):
    mult 1,5  H1 SigmaR  -249 · H2 +1252 · Worst -1,50
    mult 2,0  H1 SigmaR  -412 · H2 +1187 · Worst -2,00
    mult 2,5  H1 SigmaR -1367 · H2 +1450 · Worst -2,50
Auf M30 fuhr der Bot also 5,5x schlechteres H1 und einen 67 % groesseren
Einzelverlust; bei Breakouts waren es 3,0 - nie getestet. Relevant, weil die
TF-Heuristik oft auf M15/M30 landet (31.07.: 19 von 33 Phasen).

STUFE 2a (wave_rec/history/engine): jede Empfehlung loggt einen
maschinenlesbaren block_reason - deadband, dead_hour, eia, htf_counter,
stretch, min_conf, breakout_pending, entry_room, no_data, stale. DB migriert
(Backup angelegt). Ohne den war nicht feststellbar, WELCHES der Gates die
93 % WARTEN erzeugt (Backtest-Erwartung ~43 %).

STUFE 2b (analyze_divergence.py, NEU): haelt vier Dinge gegen die
Backtest-Erwartung - A Signal-Verteilung, B Gate-Anteile inkl.
"im Backtest modelliert?", C TF-Wechsel-Rate, D0 Modell-Kalibrierung,
D Merkmals-Drift gegen _PB_MU/_SD.

LEHRE AUS DEM BAU SELBST: die Merkmals-Drift (D) haette den realen Fehler
NICHT gefangen - mom3 lag nur 0,50 Sigma unter mu, bei Gewicht 1,43 wurden
daraus aber -0,71 im Logit (23 % statt 41 % vorhergesagt). Deshalb ist D0
der schaerfere Test und die D-Schwelle auf 0,5 Sigma gesetzt.

Erster Lauf nach dem Fix bestaetigt das Nachtraining live:
D0 = 27,0 % vorhergesagt vs 28,0 % real (vorher 23 vs 41).

Stufe 3 (geteilter Exit-Kern) bleibt offen, spaeter und einzeln.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:54:34 +02:00

223 lines
9.7 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
"""Divergenz-Wächter: läuft der Bot unter den Bedingungen, unter denen gemessen wurde?
Stufe 2 der Deployment-Drift-Behebung (2026-07-31).
WARUM ES DAS GIBT: An EINEM Tag wurden drei Fälle gefunden, in denen eine Komponente
unter anderen Bedingungen BETRIEBEN als VALIDIERT wurde:
1. P(break) — trainiert auf Anlauf zu FIXIERTEM Level, live dynamisch gewähltes
Level → AUC 0,715 fiel auf 0,368.
2. `breakout_k=0,3` — validiert auf FESTER Zeitebene, live wechselt sie 33×/Tag
und löschte dabei den Bestätigungs-Anker → 93 % WARTEN statt ~43 %.
3. Trailing-`mult` — validiert bei 1,5, live 1,53,0 je nach TF → H1 ΣR 249
statt 1367, Worst 1,50 statt 2,50.
Keiner davon wäre durch MEHR Backtesting gefunden worden — die Backtests waren
korrekt. Gefehlt hat die Kontrolle, ob der Betrieb ihren Annahmen entspricht.
Track B schützt gegen OVERFITTING (Edge existiert gar nicht). Dieses Skript schützt
gegen DEPLOYMENT-DRIFT (Edge existiert, läuft aber unter anderen Bedingungen).
GEPRÜFT WIRD:
A) Signal-Verteilung live vs. Backtest-Erwartung (`backtest_dist.py`: ~43 % WARTEN)
B) WELCHES Gate blockt — Anteile je `block_reason` (seit 2026-07-31 geloggt)
C) TF-Wechsel-Rate (die Bedingung, unter der `breakout_k` validiert wurde, ist
„feste TF" — je mehr Wechsel, desto weiter weg)
D) Merkmals-Drift der eingebetteten Modelle: Live-Mittel gegen `_PB_MU`/`_PB_SD`
Aufruf: python analyze_divergence.py [tage] (Default 7)
"""
import re
import sqlite3
import sys
from datetime import datetime, timedelta
DB = "oil_widget_history.db"
LOG = "oil_widget.log"
# Erwartungswerte aus den Backtests — die Messlatte
_EXP_WAIT = 43.0 # backtest_dist.py: ~57 % LONG/SHORT, ~43 % WARTEN
_EXP_WAIT_TOL = 15.0 # ab dieser Abweichung wird geflaggt (Pp)
def _flag(ok: bool) -> str:
return "OK " if ok else "⚠ "
def sec_a(c, t0):
print("\n" + "=" * 84)
print(" A) SIGNAL-VERTEILUNG — läuft der Bot so oft wie gemessen?")
print("=" * 84)
c.execute("SELECT signal, COUNT(*) FROM recommendations WHERE timestamp>=? "
"GROUP BY signal", (t0,))
d = dict(c.fetchall()); tot = sum(d.values())
if not tot:
print(" keine Daten"); return
w = 100.0 * d.get("WARTEN", 0) / tot
dev = w - _EXP_WAIT
print(f" n={tot} WARTEN {w:.1f} % LONG {100*d.get('LONG',0)/tot:.1f} % "
f"SHORT {100*d.get('SHORT',0)/tot:.1f} %")
print(f" Backtest-Erwartung: {_EXP_WAIT:.0f} % WARTEN → Abweichung {dev:+.1f} Pp "
f"{_flag(abs(dev) <= _EXP_WAIT_TOL)}")
if dev > _EXP_WAIT_TOL:
print(" ⚠ Der Bot wartet deutlich öfter als gemessen — ein Live-Gate blockt")
print(" mehr, als der Backtest kennt. Siehe Abschnitt B.")
def sec_b(c, t0):
print("\n" + "=" * 84)
print(" B) WELCHES GATE BLOCKT? (block_reason, seit 2026-07-31)")
print("=" * 84)
try:
c.execute("SELECT block_reason, COUNT(*) FROM recommendations "
"WHERE timestamp>=? AND signal='WARTEN' GROUP BY block_reason "
"ORDER BY COUNT(*) DESC", (t0,))
rows = c.fetchall()
except sqlite3.OperationalError:
print(" Spalte block_reason fehlt (ältere DB)"); return
tot = sum(n for _, n in rows)
if not tot:
print(" noch keine Daten — das Logging läuft erst seit dem Neustart am 31.07.")
return
# Welche Gates kennt der Backtest? (nur diese dürfen den Vergleich prägen)
im_backtest = {"deadband": True, "htf_counter": True, "stretch": True,
"min_conf": True, "breakout_pending": True,
"entry_room": False, "dead_hour": False, "eia": False,
"no_data": False, "stale": False, None: None}
print(f" {'Gate':<20}{'n':>8}{'Anteil':>9} im Backtest modelliert?")
for br, n in rows:
mb = im_backtest.get(br)
mark = "ja" if mb else ("NEIN — live-only" if mb is False else "—")
print(f" {str(br):<20}{n:>8}{100*n/tot:>8.1f} % {mark}")
live_only = sum(n for br, n in rows if im_backtest.get(br) is False)
print(f"\n Nur-Live-Gates blocken {100*live_only/tot:.1f} % aller WARTEN-Fälle.")
print(" ⚠ Dieser Anteil ist per Definition NICHT durch einen Backtest gedeckt.")
def sec_c(t0):
print("\n" + "=" * 84)
print(" C) TF-WECHSEL — `breakout_k` wurde auf FESTER Zeitebene validiert")
print("=" * 84)
try:
sw = []
for ln in open(LOG, encoding="utf-8", errors="ignore"):
m = re.search(r"^(\S+ \S+).*Wellen-Timeframe → (\w+)", ln)
if m:
try:
t = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S")
except ValueError:
continue
if t.timestamp() >= t0:
sw.append((t, m.group(2)))
except FileNotFoundError:
print(" kein Log gefunden"); return
if not sw:
print(" keine TF-Wechsel im Zeitraum OK"); return
days = max(1.0, (datetime.now().timestamp() - t0) / 86400)
gaps = sorted((sw[i][0] - sw[i - 1][0]).total_seconds() for i in range(1, len(sw)))
med = gaps[len(gaps) // 2] / 60 if gaps else 0
print(f" {len(sw)} Wechsel in {days:.1f} Tagen = {len(sw)/days:.1f}/Tag")
if gaps:
print(f" Median-Abstand {med:.0f} min {_flag(med >= 15)}")
if med < 15:
print(" ⚠ Wechselt schneller, als ein Signal seine Breakout-Bestätigung")
print(" abschließen kann → prüfe `tf_min_dwell_s`.")
def sec_d0(c, t0):
"""Der SCHÄRFSTE Test: sagt das Modell im Mittel das voraus, was eintritt?
⚠ Diese Prüfung ersetzt NICHT die Merkmals-Drift unten, sie ist ihr überlegen —
und das ist eine Lehre aus dem Bau dieses Skripts: mit den ALTEN Modellwerten
lag `mom3` nur **0,50σ** unter µ und wäre an einer 1σ-Schwelle NICHT
aufgefallen. Entscheidend ist nicht die Abweichung des Merkmals, sondern ihre
WIRKUNG nach Gewichtung (mom3 hatte Gewicht 1,43 → 0,50σ wurden zu 0,71 im
Logit). Der Vergleich „Ø-Vorhersage vs. echte Rate" fängt das direkt ab: er
hätte 23 % vs 41 % sofort gezeigt.
"""
print("\n" + "=" * 84)
print(" D0) MODELL-KALIBRIERUNG — sagt es voraus, was eintritt?")
print("=" * 84)
c.execute("""SELECT AVG(p_break), AVG(CASE WHEN outcome='break' THEN 100.0 ELSE 0 END),
COUNT(*) FROM pbreak_predictions
WHERE ts>=? AND outcome IS NOT NULL""", (t0,))
pm, rm, n = c.fetchone()
if not n or n < 30:
print(f" nur {n or 0} ausgewertete Vorhersagen — zu wenig"); return
gap = (rm or 0) - (pm or 0)
print(f" n={n} Ø-Vorhersage {pm:.1f} % echte Rate {rm:.1f} % "
f{gap:+.1f} Pp {_flag(abs(gap) <= 8)}")
if abs(gap) > 8:
print(" ⚠ Das Modell ist systematisch verschoben — es bewertet live eine")
print(" andere Situation als beim Training (Deployment-Drift).")
def sec_d(c, t0):
print("\n" + "=" * 84)
print(" D) MERKMALS-DRIFT der eingebetteten Modelle")
print("=" * 84)
try:
from core.engine import _PB_MU, _PB_SD
except Exception as e:
print(f" Modell nicht ladbar: {e}"); return
# Live-Merkmale liegen (noch) nicht in der DB → aus candles_m1 rekonstruieren
c.execute("SELECT MAX(time) FROM candles_m1")
last = c.fetchone()[0]
if not last:
print(" keine M1-Kerzen — Rekonstruktion nicht möglich"); return
import time as _t
off = round((last - _t.time()) / 3600) * 3600
c.execute("""SELECT ts,direction,level,price_at_pred,atr FROM pbreak_predictions
WHERE ts>=? AND atr IS NOT NULL""", (t0,))
rows = c.fetchall()
if len(rows) < 30:
print(f" nur {len(rows)} Vorhersagen im Zeitraum — zu wenig"); return
def close_at(ts):
c.execute("SELECT c FROM candles_m1 WHERE time<=? ORDER BY time DESC LIMIT 1", (ts,))
r = c.fetchone(); return r[0] if r else None
m3, m6 = [], []
for ts, d, lvl, px, atr in rows:
b = ts + off
c0, c3, c6 = close_at(b), close_at(b - 15 * 60), close_at(b - 30 * 60)
if None in (c0, c3, c6):
continue
dd = 1 if d == "LONG" else -1
m3.append((c0 - c3) * dd / atr); m6.append((c0 - c6) * dd / atr)
if len(m3) < 30:
print(" zu wenige rekonstruierbare Merkmale"); return
print(f" n={len(m3)} (P(break)-Modell, Merkmale aus candles_m1 rekonstruiert)")
print(f" {'Merkmal':<8}{'Live-µ':>10}{'Modell-µ':>11}{'Δ in SD':>10}")
for nm, v, k in (("mom6", m6, 0), ("mom3", m3, 1)):
mean = sum(v) / len(v)
dsd = (mean - _PB_MU[k]) / _PB_SD[k]
print(f" {nm:<8}{mean:>10.3f}{_PB_MU[k]:>11.3f}{dsd:>9.2f}σ {_flag(abs(dsd) <= 0.5)}")
print("\n ⚠ Schwelle bewusst 0,5σ (nicht 1σ): der reale P(break)-Fehler lag bei")
print(" mom3 nur 0,50σ unter µ — bei Gewicht 1,43 wurden daraus aber 0,71 im")
print(" Logit, was 23 % statt 41 % vorhersagte. Merkmals-Drift allein")
print(" unterschätzt den Schaden; D0 (Kalibrierung) ist der schärfere Test.")
def main():
days = int(sys.argv[1]) if len(sys.argv) > 1 else 7
t0 = (datetime.now() - timedelta(days=days)).timestamp()
db = sqlite3.connect(DB); c = db.cursor()
print("=" * 84)
print(f" DIVERGENZ-WÄCHTER — läuft der Bot unter den validierten Bedingungen?")
print(f" Zeitraum: letzte {days} Tage")
print("=" * 84)
sec_a(c, t0)
sec_b(c, t0)
sec_c(t0)
sec_d0(c, t0)
sec_d(c, t0)
db.close()
print("\n" + "=" * 84)
print(" Track B schützt gegen Overfitting. DIESER Report schützt gegen")
print(" Deployment-Drift: gemessen unter X, betrieben unter Y.")
print("=" * 84)
if __name__ == "__main__":
main()