Files
Axel HocksandClaude Opus 5 3f1f19087d Live-Backtest-Vergleich in die Pipeline: core/stichprobe.py + Abschnitt E
Konsequenz aus dem Messfehler vom 07.08.: der Vergleich lief nur, wenn jemand
daran dachte - und als er lief, war er falsch gebaut.

1) core/stichprobe.py - die Praevention ist KONSTRUKTIV, nicht ermahnend:
   entkoppeln() / anteil_ci() / unterscheidbar() / schluessel_level_stunde().
   Regel: vor jeder berichteten Rate entkoppeln, jede Aussage ueber einen
   Unterschied mit Konfidenzintervall belegen. Die Zeilen in
   pbreak_predictions und recommendations sind nicht unabhaengig.
   8 Tests, darunter einer, der die reale Konstellation nachspielt
   (roh 77 %, entkoppelt 33 %).
   Fallstrick beim Bau, gefunden weil ich es laufen liess: ein sqlite3-Cursor
   liefert Tupel, keine dicts - die Feldangabe akzeptiert jetzt Namen UND
   Spaltennummern.

2) analyze_divergence.sec_d0 war selbst der Fehler. Es mittelte AVG(p_break)
   ueber ALLE Rohzeilen und flaggte bei Delta > 8 Pp:
     vorher  1058 rohe Zeilen, Delta +8,7 Pp -> ALARM
     jetzt   110 entkoppelte, 37,6 % liegt in [37,3 ... 55,5] % -> OK
   Der Waechter haette heute also einen Fehlalarm produziert - genau den, dem
   ich elf Hypothesen lang nachgegangen bin.

3) NEU: Abschnitt E "GLEICHES FENSTER" (--backtest). Rechnet den Backtest ueber
   genau den Live-Zeitraum und haelt ihn gegen das Intervall der entkoppelten
   Live-Rate. Erster Lauf: live 46,4 % [37,3 ... 55,5] gegen Backtest 43,9 %
   -> im Intervall, kein Befund.

4) Eingebaut in den Wochenreport (weekly_review._divergenz_section, montags
   07:30 per Mail + Telegram), mit demselben Fail-safe wie der
   Squeeze-Abschnitt. Bewusst NICHT in den pre-commit-Hook: der braucht MT5 und
   Minuten, Stufe 1 muss bei ~5 s bleiben.

Ausserdem ein Rechenfehler in meinem ersten Entwurf behoben (p_break steht in
der DB bereits in Prozent, ich hatte zusaetzlich skaliert -> "3763,9 %") und
ein \n-Heredoc-Fall, der genau gegen die dokumentierte Regel verstiess.

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

231 lines
11 KiB
Python
Raw Permalink 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
"""B4-Wochenreport (Strategie-Lebenszyklus, s. CLAUDE.md Track B).
Hält die LIVE-Zahlen (trades-DB) gegen die BACKTEST-Erwartung und flaggt die bekannten
Leckagen (Sizing-Asymmetrie, Gegen-Signal-Trades, Winner-Kappen, Regime-Verfall).
Aufruf: python weekly_review.py (letzte 6 Wochen + diese Woche im Detail)
Zeitspalten der DB sind LOKALE Epoch (Broker-Offset bereits korrigiert).
"""
import sqlite3, datetime as dt
from pathlib import Path
DB = "oil_widget_history.db"
# ── Backtest-Erwartung (R-basiert, exit-sim; s. CLAUDE.md / backtest_*.py) ──
EXP_WR = 0.68 # Treffer ~6871 % (Reversal/Trend, SL2+Trail)
EXP_PF = 1.30 # Profit-Faktor in R
EXP_VERH = 0.55 # Ø-Gewinn/Ø-Verlust in R (WR hoch → Wins kleiner, trotzdem PF>1)
def net(r): return (r["pnl"] or 0.0) + (r["commission"] or 0.0)
def d_of(r): return "LONG" if r["direction"] in ("BUY","LONG","buy") else "SHORT"
def align(r):
rs = (r["rec_signal"] or "WARTEN").upper()
if rs in ("LONG","SHORT"): return "mit" if rs == d_of(r) else "gegen"
return "ohne"
def agg(rows):
v = [net(r) for r in rows]
if not v: return None
n = len(v); wins = [x for x in v if x > 0]; loss = [x for x in v if x < 0]
w = len(wins); g = sum(wins); ls = -sum(loss)
aw = g / w if w else 0.0; al = ls / len(loss) if loss else 0.0
return dict(n=n, wr=w/n, pf=(g/ls if ls > 0 else 99.9), sum=sum(v),
aw=aw, al=al, verh=(aw/al if al > 0 else 99.9), worst=min(v))
def line(lbl, s):
if not s: return f" {lbl:<14} —"
return (f" {lbl:<14} n={s['n']:>3} WR={100*s['wr']:>3.0f}% PF={s['pf']:>4.2f} "
f"Verh={s['verh']:>4.2f} Øgew={s['aw']:>+5.2f} Øverl={s['al']:>+6.2f} "
f"Σ={s['sum']:>+8.2f} Worst={s['worst']:>+7.2f}")
def _squeeze_section(m5) -> list:
"""B4-Monitor für den Volatilitäts-Squeeze-Breakout: rechnet die Regel mit den
LIVE-Params auf den letzten ~30 Tagen M5 NEU (der Setup ist mechanisch → seine
Live-Performance = Neuberechnung auf frischen Bars, keine Trade-Attribution nötig)
und hält ØR/PF gegen die Backtest-Erwartung (+0,14…+0,23). m5 = {H,L,C,SP} oder None."""
if not m5 or len(m5.get("C") or []) < 300:
return ["", "SQUEEZE-BREAKOUT (B4): keine M5-Daten — übersprungen."]
try:
# ⚠ s. Kommentar in `engine.squeeze_monitor`: die Mechanik liegt jetzt in
# `core/squeeze_scan.py`, die Live-Parameter stehen in der Signatur statt
# als mutierte Modul-Globale eines Backtests.
from core.squeeze_scan import atr_series, scan
from core.wave_rec import _SQ_N, _SQ_MULT, _SQ_K
H, L, C, SP = m5["H"], m5["L"], m5["C"], m5["SP"]
A = atr_series(H, L, C)
sq, _ = scan(H, L, C, A, SP, 1.0, 0, len(C), _SQ_MULT,
box_n=_SQ_N, k=_SQ_K) # SP schon in Preis
out = ["", "-" * 92,
f"SQUEEZE-BREAKOUT (B4-Monitor · ~30 Tage M5 neu gerechnet · live-Params "
f"Box={_SQ_N}/k={_SQ_K}/≤{_SQ_MULT}×ATR):"]
n = len(sq)
if n == 0:
out.append(" keine Squeeze-Ausbrüche im Fenster — weiter beobachten.")
return out
w = sum(1 for x in sq if x > 0); s = sum(sq)
up = sum(x for x in sq if x > 0); dn = -sum(x for x in sq if x < 0)
pf = up / dn if dn > 0 else 99.9; oR = s / n
out.append(f" n={n} Treffer={100*w/n:.0f}% ØR={oR:+.3f} PF={pf:.2f} ΣR={s:+.0f}"
f" (Erwartung ØR +0,14…+0,23 · PF>1)")
if oR <= 0 or pf < 1.0:
out.append(" (!) ØR≤0/PF<1 im Fenster → Edge driftet? B5 prüfen (Regimewechsel). "
"⚠ kleine Stichprobe, nicht überreagieren.")
elif n < 6:
out.append(" ⚠ noch wenige Trades — nicht aussagekräftig, weiter beobachten.")
else:
out.append(" [OK] im Rahmen der Erwartung — Setup trägt live.")
return out
except Exception as e:
return ["", f"SQUEEZE-BREAKOUT (B4): Fehler ({e})"]
def _fetch_m5(days=30):
"""M5-Bars für den Squeeze-Monitor (nur STANDALONE — schließt MT5 nur, wenn WIR
es initialisiert haben; die Server-Instanz reicht die Bars direkt in build_report)."""
try:
import MetaTrader5 as mt5
pre = mt5.terminal_info() is not None # schon vom Server initialisiert?
if not pre and not mt5.initialize():
return None
try:
sym = next((c for c in ("SpotCrude", "USOIL", "WTI", "XTIUSD")
if mt5.symbol_info(c)), None)
if not sym:
return None
bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, days * 288 + 30)
point = mt5.symbol_info(sym).point
finally:
if not pre:
mt5.shutdown()
if bars is None:
return None
return {"H": [float(b["high"]) for b in bars], "L": [float(b["low"]) for b in bars],
"C": [float(b["close"]) for b in bars],
"SP": [float(b["spread"]) * point for b in bars]}
except Exception:
return None
def _divergenz_section() -> list:
"""LIVE gegen BACKTEST auf DEMSELBEN Fenster — Pipeline-Einbau 2026-08-07.
⚠⚠ WARUM DAS HIER STEHT. Track B prüft, ob ein Edge existiert; der
Wochenreport prüft, ob er live noch trägt. Was BEIDE nicht abdeckten: ob
live überhaupt DASSELBE gemessen wird wie im Backtest. `analyze_divergence.py`
gibt es dafür seit dem 31.07., aber es lief nur, wenn jemand daran dachte —
und am 07.08. hat genau diese Lücke zugeschlagen: eine geclusterte
6-Tage-Live-Stichprobe wurde gegen ein 208-Tage-Backtest-Mittel gehalten,
Ergebnis eine „Lücke" von 14,1 Pp, die es nicht gab (34 % Zeitraum,
53 % Clusterung, Rest Rauschen — elf Hypothesen umsonst geprüft).
Der Abschnitt beantwortet deshalb genau zwei Fragen, beide mit
Konfidenzintervall statt Punktabstand:
· sagt das Modell live das voraus, was eintritt? (entkoppelt!)
· liefert der Backtest auf DEMSELBEN Zeitfenster dasselbe?
⚠ Fail-safe wie beim Squeeze-Abschnitt: schlägt der Aufruf fehl (kein MT5,
keine DB), fehlt der Abschnitt — der Report darf daran nicht scheitern.
"""
import subprocess
import sys
out = ["", "=" * 92,
"DIVERGENZ — läuft der Bot unter den Bedingungen, unter denen gemessen wurde?",
"=" * 92]
try:
r = subprocess.run(
[sys.executable, "-X", "utf8", "analyze_divergence.py", "7", "--backtest"],
cwd=Path(__file__).resolve().parent, capture_output=True, text=True,
timeout=900)
txt = r.stdout or ""
# Nur die beiden entscheidenden Abschnitte übernehmen — der Rest ist im
# Wochenreport Rauschen und würde die Mail unlesbar machen.
nimm = False
for ln in txt.splitlines():
if ln.strip().startswith(("D0)", "E)")):
nimm = True
elif ln.strip().startswith(("D)", "A)", "B)", "C)")):
nimm = False
if nimm and ln.strip() and not ln.startswith("==="):
out.append(" " + ln.strip())
if len(out) <= 4:
out.append(" (keine auswertbaren Daten)")
except Exception as e:
out.append(f" Divergenz-Prüfung nicht möglich ({e})")
out.append("")
out.append(" ⚠ Gelesen wird gegen das KONFIDENZINTERVALL, nicht gegen den")
out.append(" Punktabstand: „liegt der Backtest-Wert im Intervall der")
out.append(" entkoppelten Live-Rate?" + '"' + " Nur ein NEIN ist ein Befund.")
return out
def build_report(db=DB, m5=None) -> str:
"""Baut den B4-Wochenreport als reinen Text (für Konsole, Mail-<pre>, Telegram).
m5 = vorab geholte M5-Bars für den Squeeze-Monitor (Server reicht sie unter
mt5_lock durch); None → Squeeze-Abschnitt versucht selbst zu laden (standalone)."""
if m5 is None:
m5 = _fetch_m5()
out = []; P = out.append
c = sqlite3.connect(db); c.row_factory = sqlite3.Row
rows = [r for r in c.execute(
"SELECT * FROM trades WHERE exit_time IS NOT NULL ORDER BY entry_time")]
now = dt.datetime.now()
def wkey(ts): return dt.datetime.fromtimestamp(ts).isocalendar()[:2] # (jahr,kw)
thisk = now.isocalendar()[:2]
P("=" * 92)
P(f" B4-WOCHENREPORT · {now:%d.%m.%Y %H:%M} · Live (EUR netto) vs Backtest-Erwartung")
P(f" Erwartung: WR~{100*EXP_WR:.0f}% · PF~{EXP_PF:.2f} · Verhältnis~{EXP_VERH:.2f} (R-basiert)")
P("=" * 92)
from collections import OrderedDict
byw = OrderedDict()
for r in rows: byw.setdefault(wkey(r["entry_time"]), []).append(r)
P("\nWochenverlauf (Verfall-Check):")
for (yr, kw), rs in list(byw.items())[-6:]:
tag = " <- diese Woche" if (yr, kw) == thisk else ""
P(line(f"KW{kw}/{yr%100}", agg(rs)) + tag)
tw = byw.get(thisk, [])
P(f"\nDiese Woche — nach Empfehlung (n={len(tw)}):")
for k in ("mit", "gegen", "ohne"):
P(line(k, agg([r for r in tw if align(r) == k])))
P("\nGesamt (alle Trades):")
P(line("alle", agg(rows)))
recent = [r for (yr, kw), rs in list(byw.items())[-4:] for r in rs]
s = agg(recent)
P("\n" + "-" * 92)
P(f"BEWERTUNG (letzte 4 Wochen, n={s['n'] if s else 0}):")
if s:
def mark(ok): return "[OK]" if ok else "[!]"
P(f" {mark(s['wr']>=EXP_WR-0.08)} Trefferquote {100*s['wr']:.0f}% (erwartet ~{100*EXP_WR:.0f}%)")
P(f" {mark(s['pf']>=1.0)} Profit-Faktor {s['pf']:.2f} (erwartet ~{EXP_PF:.2f}; <1 = verlustbringend)")
P(f" {mark(s['verh']>=EXP_VERH-0.10)} Verhältnis {s['verh']:.2f} (erwartet ~{EXP_VERH:.2f})")
flags = []
gg = agg([r for r in recent if align(r) == "gegen"])
share_gg = (gg["n"]/s["n"]) if gg else 0
if share_gg > 0.12:
flags.append(f"(!) {100*share_gg:.0f}% GEGEN-Signal-Trades (dort WR ~14 % — meiden!)")
if s["verh"] < EXP_VERH - 0.10:
flags.append("(!) Verhältnis unter Erwartung → Gewinner zu klein (Kappen) oder Verlierer zu groß (Sizing 90 %)")
if s["wr"] < EXP_WR - 0.10:
flags.append("(!) Treffer unter Erwartung → Regime-Verfall ODER zu viele Nicht-Setup-Trades")
if s["pf"] < 1.0:
flags.append("(!) PF<1 = Verlustphase → B5 prüfen: Regimewechsel? Sizing? Disziplin?")
if s["worst"] < -25:
flags.append(f"(!) Einzelverlust {s['worst']:+.0f} € — 90 %-Margin-Sizing (Memory: risk_pct=1.5)")
P("\n " + ("\n ".join(flags) if flags else "[OK] keine Leckage-Flags — im Rahmen der Erwartung."))
P("-" * 92)
P("Nächster Schritt bei [!]: B5 (behalten/verwerfen) — driftet der Live-Edge weg,")
P("erst Ursache trennen (Regime vs. Disziplin vs. Sizing), nicht am Signal drehen.")
for ln in _squeeze_section(m5):
P(ln)
for ln in _divergenz_section():
P(ln)
return "\n".join(out)
def main():
print(build_report())
if __name__ == "__main__":
main()