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>
231 lines
11 KiB
Python
231 lines
11 KiB
Python
#!/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 ~68–71 % (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()
|