From d61c4a3634d6af29e8fde7a943b29065d73a7288 Mon Sep 17 00:00:00 2001 From: Axel Hocks Date: Fri, 24 Jul 2026 20:57:56 +0200 Subject: [PATCH] Gesamtempfehlung-Paket: 2 Rollouts, 3 Anzeige-Features, 2 Messungen (v=114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Entry-Raum-Gate 0,6→1,0 (Messung lag vor: beide Hälften besser) - Konfidenz-Kalibrierung gemessen (analyze_verdict_calibration.py): conf INVERTIERT zwischen Regimen → keine P(Erfolg)-Aufwertung, kein Konf-Sizing - verdict_votes-Logging (1×/min) für spätere Copilot/Elliott-Entscheidung - Order-Dialog zeigt eigenen Ausrichtungs-Split (36/40 Trades ohne Signal: −423€) - Live-Kosten-Chip (Spread/ATR) im Verdict - P(break)×Entry-Raum-Freigabe gemessen VERWORFEN (Flip in jeder Schwelle, backtest_entryroom_pbreak.py) — 13. verworfener Signal-Eingriff - News-Konflikt-Chip + recommendations.news_score wieder befüllt Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 60 ++++++++++-- analyze_verdict_calibration.py | 119 +++++++++++++++++++++++ backtest_entryroom_pbreak.py | 171 +++++++++++++++++++++++++++++++++ core/config.py | 7 +- core/engine.py | 60 +++++++++++- core/history.py | 75 +++++++++++++++ oil_widget_config.ini.example | 2 +- web/app.js | 58 +++++++++-- web/index.html | 7 +- web/style.css | 6 +- 10 files changed, 544 insertions(+), 21 deletions(-) create mode 100644 analyze_verdict_calibration.py create mode 100644 backtest_entryroom_pbreak.py diff --git a/CLAUDE.md b/CLAUDE.md index 1e7ddba..79a3c24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -222,8 +222,9 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: = späterer Einstieg, Anfang des Moves verschenkt). ⚠ Ein-Stichproben-Lauf hatte „1,0 besser" gezeigt — Split-Prüfung drehte das (B2!). Nebeneffekt: kürzere „warte auf Breakout"-Phasen. -- **Entry-Raum-Gate (AKTIV, `[trading] entry_room_atr=0.6`, gemessen - `backtest_entryroom.py` 2026-07-16):** Richtungssignal → WARTEN, wenn das +- **Entry-Raum-Gate (AKTIV, `[trading] entry_room_atr=1.0` — 0,6→1,0 am + 2026-07-24, gemessen `backtest_entryroom.py` 2026-07-16):** Richtungssignal → + WARTEN, wenn das **Gegenlevel** (M5-Pivot in Trade-Richtung, trainingsgleich zum S/R-Auto-Close) näher als X×ATR_M5 liegt. Befund (seq. Sim MIT S/R-Close-Exit, Echtkosten, 2 Halbjahre): **monoton in BEIDEN Hälften** — Raum <0,3: ØR −0,119/−0,067 @@ -235,9 +236,19 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: 4 der 5 sr_closes desselben Tages waren dagegen RETTER — Exit NICHT anfassen). Umsetzung: `wave._room_gate` (in `refresh_market` VOR `_confirm_breakout`; `set_entry_room`, Grund „kein Raum: Widerstand X nur 0,4×ATR entfernt …"). - Backtests via `_build` bleiben gate-frei (kein pb_levels-State). 1,0 wäre gemessen - noch besser (H1 +415/H2 +266 R), filtert aber ~¾ der Signale → Start 0,6, per - B4-Wochenreport prüfen und ggf. via ini erhöhen. + Backtests via `_build` bleiben gate-frei (kein pb_levels-State). **0,6→1,0 + ausgerollt (2026-07-24, „baue alles ein"):** 1,0 war von Anfang an in BEIDEN + Hälften besser (H1 +415/H2 +266 R); der konservative 0,6-Start diente nur der + Frequenz (~¾ der Signale gefiltert → deutlich mehr WARTEN). Live-Kurzcheck vor + dem Rollout: S/R-Closes seit 16.07. +925 € bei nur noch 28 % Klein-Closes (<5 €) + — Gate wirkt wie gemessen. ⚠ **P(break)-Freigabe des Gates = VERWORFEN (gemessen + `backtest_entryroom_pbreak.py`, 2026-07-24):** Idee war, Raum<1,0-Signale + durchzulassen, wenn P(break)@Entry hoch (Level bricht eh → Ertrag nicht gedeckelt). + Das Modell TRENNT zwar auch am Entry (Freigabe-Gruppen klar besser als der Rest), + aber JEDE Schwelle kippt zwischen den Hälften (p≥0,5: −0,016/+0,083 · p≥0,6: + +0,089/−0,006 · p≥0,7: +0,076/−0,029) → nicht robust, reines Distanz-Gate bleibt. + **13. verworfener Signal-Eingriff.** (Nebenbefund: geblockte Gruppe in beiden + Hälften klar negativ, −436/−251 ΣR — bestätigt die 1,0-Erhöhung.) - **Mindest-Konfidenz-Gate** (`_MIN_CONF=55`): Signal < 55 % → WARTEN. Gemessen (`backtest_conf.py`): Band **40–54 % = negativer Edge** (−0,025), ≥55 % = +0,058; Gate hebt Ø-Edge ~+50 % und den Gesamtertrag (entfernt die schwachen Setups, bei @@ -983,6 +994,41 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: z. B. „▲ LONG · M5" — TF, für die die Empfehlung gilt); Rest = nur Konsens-Anzeige. Backend `engine._verdict` → `verdict` im Snapshot; Render `renderVerdict` (`app.js`). Details: `docs/gesamtempfehlung.md`. + **⚠ Konfidenz ist NICHT kalibriert (gemessen `analyze_verdict_calibration.py`, + 2026-07-24, „Gesamtempfehlung verbessern"-Paket):** conf_pct gegen Forward-Return + (2 h, ×ATR, 1604 gesampelte Signale aus `recommendations`, 2 Hälften) — H1 (Mai–Jun) + perfekt monoton (conf 85–100: WR 68 %/ØR +1,13; <55 klar negativ), **H2 (Jun–Jul) + komplett INVERTIERT** (conf 85–100: WR 40 %/ØR −0,90; mittlere Bins besser). Hohe + Konf = starker Trend + Konfluenz — im Chop-/Hochvola-Regime sind genau das die + Überdehnungen vor der Wende. **Konsequenzen:** conf-Anzeige NICHT als P(Erfolg) + aufwerten; **Konfidenz-Sizing-Idee VERWORFEN** (hätte in H2 die schlechtesten + Signale am größten gesized); Ring bleibt, was er ist (Score-Aggregat). Copilot-Bias + in derselben Messung nicht robust prädiktiv (n klein bzw. ØR ~0) — für die + datenbasierte Entscheidung (behalten/Gewicht/raus wie TU) loggt jetzt + **`verdict_votes`** (neue DB-Tabelle, `history.log_verdict_votes`, 1×/60 s aus + `_run_analysis`: headline/conf/bias/tf + Modul-Votes Welle/M30/H1/KI(+w)/ + Elliott(+w)/Squeeze + news_score) — Auswertung nach ein paar Wochen analog + `analyze_verdict_calibration.py`. + **Live-Kosten-Chip (2026-07-24):** Snapshot `cost_ratio` = (ask−bid)/ATR_M5 + (`pb_feats.atr`, Floor 0,06) → in der `vd-consensus`-Zeile „Kosten 0,14×ATR ✅ + günstig / · normal / ⚠ teuer" (Schwellen 0,20/0,32 aus `backtest_realcosts.py`: + Ø 0,265, Nacht 0,32–0,50 = Falle, 15–17 Uhr ~0,13). Reine Info — die ehrliche + Alternative zum abgeschalteten Dead-Hours-Gate. + **Ausrichtungs-Split im Order-Dialog (2026-07-24, Verhaltens-Hebel/B0):** Der + „trotzdem eröffnen?"-Dialog zeigt jetzt die EIGENEN Zahlen aus der DB + (`history.alignment_stats(40)` → Snapshot `alignment`, Engine-Cache ~120 s): + „📊 Deine Trades OHNE Signal: 42 % Trefferquote · −423 € (n36) / Mit Signal: …" + (Gruppen <5 Trades werden ausgeblendet). Real bei Einbau: 36 der letzten 40 + Trades liefen OHNE Signal-Deckung mit −423 € — exakt die dokumentierte + Kern-Leckage, jetzt im Moment der Entscheidung sichtbar. + **News-Konflikt-Chip (2026-07-24, Auslöser 23.07.: zwei Shorts gegen einen + +0,98-Sentiment-Burst):** Snapshot `news_sentiment` (Score −1..+1 aus + `news.sentiment`); Frontend `#news-conflict` (Meldungen-Karte, bernstein) warnt + bei |Score|≥0,5 gegen offene Position bzw. aktives Signal („📰 News-Flow stark + bullisch (+0,98) — steht gegen deine SHORT-Position"). Reiner Kontext — + Prädiktivität unbelegt (Kalibrier-Messung: H2 nur n=4/6); dafür wird + `recommendations.news_score` seit 2026-07-24 wieder BEFÜLLT (war auf None + degradiert) → in ein paar Monaten messbar. - **Karten** (Dashboard, von oben): Gesamtempfehlung · **Meldungen** · Marktstruktur · KI-Copilot · Letzte Trades · Kurslücken · **Statistik (live) ganz unten**. (Tagesziele-Kachel **entfernt** 2026-07-13. **Welle-Karte komplett entfernt @@ -1140,7 +1186,7 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: Signal → **Hinweis-Dialog „trotzdem eröffnen?"** (überschreibbar). CLOSE bei **negativem** P&L → Sicherheitsabfrage; im Plus/Breakeven direkt. Sonst keine Token-Dialoge (`require_token=false`). Audio braucht 1× Nutzer-Tap. -- Asset-Version aktuell **v=113** (in `web/index.html` hochzählen, siehe Workflows). +- Asset-Version aktuell **v=114** (in `web/index.html` hochzählen, siehe Workflows). Schriftgrößen 2026-07-24 global **+4px** (2× je +2px auf User-Wunsch; Body-Basis 14→18px). ⚠ Betrifft in `style.css` sowohl `font-size:Npx` (71×) ALS AUCH die `font: Npx/…`-**Shorthand** (3×: `.ms-chip`/`.ms-bos-lbl`/`.sqm-badge` — @@ -1227,7 +1273,7 @@ Aktivierung genullt (kein Altwert-Fehlalarm); Erkennung erst ab dem 2. Modify. T (Beobachtungen/Feedback) ergänzt diese Datei. - `oil_widget_config.ini` enthält **Live-Secrets** (Keys/Token) — nicht ins Repo/Logs leaken. -- **DB** `oil_widget_history.db` (trades · intended_trades · recommendations · **candles_m1**). +- **DB** `oil_widget_history.db` (trades · intended_trades · recommendations · **candles_m1** · **pbreak_predictions** · **verdict_votes**). Vor jedem Eingriff Backup (`*.bak-`); Zeitspalten sind lokale Epoch (Broker-Offset bereits korrigiert) — **AUSNAHME `candles_m1.time` = ROHE Broker-Zeit (UTC+3)**, deckungsgleich mit `copy_rates`. diff --git a/analyze_verdict_calibration.py b/analyze_verdict_calibration.py new file mode 100644 index 0000000..c662bb3 --- /dev/null +++ b/analyze_verdict_calibration.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Kalibrierung der Gesamtempfehlung (2026-07-24, Idee 2 „Verdict ans eigene +Erfolgsrezept halten"): Ist die angezeigte Konfidenz (conf_pct) PRÄDIKTIV — sagt +conf=80 mehr Erfolg voraus als conf=55? Und: sind die unbelegten Verdict-Stimmen +(KI-Copilot ai_sentiment, news_score) prädiktiv? + +Methodik wie beim P(break)-Modell (dem kalibrierten Erfolgsfall): + - `recommendations` (LONG/SHORT) aus der DB, Sampling ≥15 min Abstand + (Autokorrelation dämpfen — die Empfehlung ändert sich minütlich kaum). + - Forward-Return = Signalrichtung × (Close[t+H] − Close[t0]) / ATR(M5, t0) + für Horizonte 1 h und 2 h (M5-Bars von MT5; rec.timestamp = lokale Epoch, + Bar-Zeit = Broker-Zeit → −3 h). + - Bins über conf_pct → n / WR(fwd>0) / Ø-fwdR je Bin, in ZWEI Zeitraum-Hälften + (Sign-Stabilität wie immer). + - Copilot: ai_sentiment (LONG/bullish=+1, SHORT/bearish=−1) → Ø-fwdR in + Copilot-Richtung (unabhängig vom Wave-Signal!). News: Vorzeichen news_score. +Kalibriert = monoton steigende Ø-fwdR über die conf-Bins, in BEIDEN Hälften. +""" +import sqlite3, datetime as dt +import MetaTrader5 as mt5 + +_BROKER_OFF = 3*3600 +_SAMPLE_S = 15*60 # Mindestabstand zwischen zwei gewerteten Empfehlungen +_H1, _H2 = 12, 24 # Forward-Horizonte in M5-Bars (1 h / 2 h) +_ATR_P = 14 +_BINS = [(0,45),(45,55),(55,65),(65,75),(75,85),(85,101)] + + +def _atr_series(H, L, C, p=_ATR_P): + 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]))) + return [(sum(t[max(1,i-p+1):i+1])/max(1,len(t[max(1,i-p+1):i+1]))) if i else None for i in range(len(C))] + + +def _dirnum(s): + if not s: return 0 + s = str(s).strip().upper() + if s in ("LONG","BULLISH"): return 1 + if s in ("SHORT","BEARISH"): return -1 + return 0 + + +def line(lbl, rows, key): + v=[r[key] for r in rows] + if not v: return f" {lbl:<22} —" + n=len(v); wr=100*sum(1 for x in v if x>0)/n + return f" {lbl:<22} n={n:>5} WR={wr:>3.0f}% ØR={sum(v)/n:+.3f}" + + +def main(): + mt5.initialize() + sym = next((c for c in ("SpotCrude","USOIL","WTI","XTIUSD") if mt5.symbol_info(c)), None) + bars = None + for req in (100000, 80000, 60000, 40000): + bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, req) + if bars is not None and len(bars) > 2000: break + 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] + T=[int(b["time"])-_BROKER_OFF for b in bars] # → echte UTC-Epoch + A=_atr_series(H,L,C) + t2i = {t:i for i,t in enumerate(T)} + + con = sqlite3.connect("oil_widget_history.db"); con.row_factory=sqlite3.Row + recs = con.execute( + "SELECT timestamp, signal, conf_pct, ai_sentiment, news_score " + "FROM recommendations WHERE signal IN ('LONG','SHORT') ORDER BY timestamp" + ).fetchall() + + ev=[]; last_ts=0 + for r in recs: + ts=r["timestamp"] + if ts-last_ts < _SAMPLE_S: continue + bar_t = (ts//300)*300 # auf M5-Raster runden + i = t2i.get(bar_t) + if i is None or i+_H2 >= len(C) or not A[i]: continue + last_ts=ts + d = 1 if r["signal"]=="LONG" else -1 + atr=max(A[i],0.06) + ev.append(dict(ts=ts, conf=r["conf_pct"] or 0, d=d, + ai=_dirnum(r["ai_sentiment"]), + news=(r["news_score"] if r["news_score"] is not None else None), + f1=(C[i+_H1]-C[i])*d/atr, f2=(C[i+_H2]-C[i])*d/atr)) + if not ev: + print("Keine matchbaren Empfehlungen (History-Überlappung prüfen)."); return + mid_ts = ev[len(ev)//2]["ts"] + halves=[("H1 (alt)",[e for e in ev if e["ts"]=mid_ts])] + def span(rows): + return (f"{dt.datetime.fromtimestamp(rows[0]['ts']):%d.%m.%y}–" + f"{dt.datetime.fromtimestamp(rows[-1]['ts']):%d.%m.%y}") + print("="*84) + print(f" Verdict-Kalibrierung — {sym}, {len(ev)} gesampelte Signale (≥15 min Abstand)") + print(f" fwd-Return in Signalrichtung, ×ATR(M5) · Horizonte 1 h/2 h") + print("="*84) + + for lbl, rows in halves: + print(f"\n{lbl} ({span(rows)}, n={len(rows)}):") + print(" conf_pct-Bins (Horizont 2 h) — kalibriert = ØR steigt monoton:") + for lo,hi in _BINS: + sub=[e for e in rows if lo<=e["conf"]=0.3 if dd>0 else e["news"]<=-0.3)] + print(line(tag, sub, "f2")) + + print("\n Lesart: conf kalibriert → höhere Bins klar bessere ØR in BEIDEN Hälften.") + print(" Copilot/News prädiktiv → 'LONG'-Zeile positiv UND 'SHORT'-Zeile positiv") + print(" (jeweils fwd in der EIGENEN Richtung gemessen).") + + +if __name__ == "__main__": + main() diff --git a/backtest_entryroom_pbreak.py b/backtest_entryroom_pbreak.py new file mode 100644 index 0000000..fd649f3 --- /dev/null +++ b/backtest_entryroom_pbreak.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""P(break) × Entry-Raum (2026-07-24, Idee 5 aus „Gesamtempfehlung verbessern"): +Das Entry-Raum-Gate blockt Signale, deren GEGENLEVEL näher als X×ATR liegt — +Begründung: Ertrag dort gedeckelt (63 % Containment) − Kosten < 0. ABER: Wenn das +kalibrierte P(break)-Modell schon am ENTRY sagt, das Level bricht wahrscheinlich +(hoher Anlauf-Schwung), ist der Ertrag NICHT gedeckelt → das Gate blockt dann +möglicherweise zu Unrecht. + +Test: gleiche Sim wie backtest_entryroom.py (seq., Live-Exit inkl. S/R-Close, +Echtkosten). Für Trades mit Raum < 1,0×ATR (die das Gate ab heute blockt) wird +P(break) AM ENTRY berechnet (mom6/mom3/wt/dist zum Gegenlevel). Politik-Vergleich: + GATE = alle Raum<1,0 blocken (Live-Politik seit 2026-07-24) + FREI p≥X = Raum<1,0 nur blocken, wenn P(break)@Entry < X (X=0,5/0,6/0,7) +Einbau NUR, wenn die freigegebene Gruppe (p≥X) in BEIDEN Hälften klar positiv ist +(sonst bleibt das reine Distanz-Gate). ⚠ Caveat: Modell wurde auf Touch-Momente +trainiert, nicht auf Entry-Kontexte — der Test misst deshalb direkt den Ertrag. +""" +import sys, bisect +import MetaTrader5 as mt5 +from core.analysis import calc_trend_angle +from core.engine import _p_break +from core.wave_rec import (WaveRecommender, _atr, _ema_last, _ema_series, + _EMA_FAST, _EMA_SLOW, _N_BARS, _HTF_DEADBAND, _ANGLE_LR) +_MAXH = 288; _ATRMIN = 0.12; _PIV_K = 3; _LOOKBACK = 300; _PTHR = 0.60 +_ROOM = 1.0 + + +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]))) + return [(sum(t[max(1, i-p+1):i+1])/max(1, len(t[max(1, i-p+1):i+1]))) if i else None + for i in range(len(C))] + + +def st(Rs): + if not Rs: return None + n = len(Rs); w = sum(1 for x in Rs if x > 0); s = sum(Rs) + up = sum(x for x in Rs if x > 0); dn = -sum(x for x in Rs if x < 0) + return dict(n=n, wr=100*w/n, oR=s/n, pf=(up/dn if dn > 0 else 9.99), sum=s) + + +def line(lbl, s): + if not s: return f" {lbl:<30} —" + return (f" {lbl:<30} n={s['n']:>4} WR={s['wr']:>3.0f}% ØR={s['oR']:+.3f} " + f"PF={s['pf']:>4.2f} ΣR={s['sum']:>+6.0f}") + + +def main(): + n = int(sys.argv[1]) if len(sys.argv) > 1 else 80000 + mt5.initialize() + sym = next((c for c in ("SpotCrude", "USOIL", "WTI", "XTIUSD") if mt5.symbol_info(c)), None) + bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, n+_N_BARS+_MAXH+5) + m30 = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M30, 0, n//6+500) + h1 = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_H1, 0, n//12+500) + si = mt5.symbol_info(sym); point = si.point + mt5.shutdown() + T = [int(b["time"]) for b in bars]; 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] + A = _atr_series(H, L, C) + + def series(rr): + t = [int(b["time"]) for b in rr]; c = [float(b["close"]) for b in rr] + hh = [float(b["high"]) for b in rr]; ll = [float(b["low"]) for b in rr] + return t, _ema_series(c, _EMA_FAST), _ema_series(c, _EMA_SLOW), _atr_series(hh, ll, c) + mT, mEf, mEs, mA = series(m30) + hT, hEf, hEs, hA = series(h1) + + def tf_sign(tt, ef, es, aa, ts): + k = bisect.bisect_right(tt, ts)-1 + if k < _EMA_SLOW or aa[k] is None or aa[k] <= 0: return 0 + dd = ef[k]-es[k] + return 0 if abs(dd) < _HTF_DEADBAND*aa[k] else (1 if dd > 0 else -1) + + w = WaveRecommender(type("T", (), {"snapshot": lambda s: {"intervals": {}}})(), mt5.TIMEFRAME_M5) + EF = _ema_series(C, _EMA_FAST); ES = _ema_series(C, _EMA_SLOW) + sig = [0]*len(C) + for i in range(_N_BARS, len(C)-1): + wc = C[i-_N_BARS:i]; wh = H[i-_N_BARS:i]; wl = L[i-_N_BARS:i] + atr = _atr(wh, wl, wc) + if not atr or atr <= 0: continue + ef = _ema_last(wc, _EMA_FAST); es = _ema_last(wc, _EMA_SLOW) + a5 = calc_trend_angle(C[i-_ANGLE_LR-2:i], _ANGLE_LR) + rec, _ = w._build(ef, es, C[i-1], atr, "M5", 0, + htf_trend=tf_sign(mT, mEf, mEs, mA, T[i]), + h1_trend=tf_sign(hT, hEf, hEs, hA, T[i]), angle=a5) + s_ = rec["signal"] + sig[i] = 1 if s_ == "LONG" else -1 if s_ == "SHORT" else 0 + + phE = [(p+_PIV_K, H[p]) for p in range(_PIV_K, len(C)-_PIV_K) + if H[p] == max(H[p-_PIV_K:p+_PIV_K+1])] + plE = [(p+_PIV_K, L[p]) for p in range(_PIV_K, len(C)-_PIV_K) + if L[p] == min(L[p-_PIV_K:p+_PIV_K+1])] + phT = [e[0] for e in phE]; plT = [e[0] for e in plE] + + def next_level(i, px, d): + if d > 0: + k = bisect.bisect_right(phT, i) + cands = [pr for (t_, pr) in phE[max(0, k-80):k] if t_ >= i-_LOOKBACK and pr > px] + return min(cands) if cands else None + k = bisect.bisect_right(plT, i) + cands = [pr for (t_, pr) in plE[max(0, k-80):k] if t_ >= i-_LOOKBACK and pr < px] + return max(cands) if cands else None + + def cost(i, atr): return (SP[i] if SP[i] > 0 else 0.0225)/atr + + def run(lo, hi_): + """→ Liste (dist0, p_entry, R): Entry-Raum, P(break)@Entry, Netto-R.""" + out = [] + i = lo + while i < hi_: + d = sig[i] + if d == 0: + i += 1; continue + atr = max(A[i] or 0, _ATRMIN) + entry = C[i] + lvl0 = next_level(i, entry, d) + dist0 = (lvl0-entry)*d/atr if lvl0 else None + p_entry = None + if lvl0 is not None and i >= 7: + mom6 = (C[i]-C[i-6])/atr*d; mom3 = (C[i]-C[i-3])/atr*d + wt = 1.0 if (EF[i]-ES[i])*d > 0 else 0.0 + p_entry = _p_break(mom6, mom3, wt, abs(lvl0-entry)/atr) + lvl = lvl0 + eff = entry - d*2.0*atr; hw = entry + end = min(i+_MAXH, len(C)-1); exit_px = C[end]; exit_j = end + for j in range(i+1, end+1): + hj, lj = H[j], L[j] + if (lj <= eff) if d > 0 else (hj >= eff): + exit_px = eff; exit_j = j; break + hw = max(hw, hj) if d > 0 else min(hw, lj) + prof = (C[j]-entry)*d + if lvl is not None and ((hj >= lvl) if d > 0 else (lj <= lvl)): + a_j = max(A[j] or atr, _ATRMIN) + if j >= 7: + mom6 = (C[j]-C[j-6])/a_j*d; mom3 = (C[j]-C[j-3])/a_j*d + wt = 1.0 if (EF[j]-ES[j])*d > 0 else 0.0 + p = _p_break(mom6, mom3, wt, abs(lvl-entry)/a_j) + if p < _PTHR and (lvl-entry)*d > 0: + exit_px = lvl; exit_j = j; break + lvl = next_level(j, C[j], d) + if prof >= 0.3*atr: + cand = hw - d*1.5*atr + if prof >= 1.3*atr: + cand = max(cand, entry) if d > 0 else min(cand, entry) + eff = max(eff, cand) if d > 0 else min(eff, cand) + out.append((dist0, p_entry, (exit_px-entry)*d/atr - cost(i, atr))) + i = exit_j + 1 + return out + + mid = len(C)//2 + print("="*92) + print(f" P(break)×Entry-Raum — {sym} M5 (seq. Sim, Live-Exit inkl. S/R-Close, Echtkosten)") + print(f" Frage: dürfen Raum<{_ROOM}×ATR-Signale durch, wenn P(break)@Entry hoch ist?") + print("="*92) + for lbl, lo, hi_ in (("H1 (alt)", _N_BARS, mid), ("H2 (neu)", mid, len(C)-_MAXH-1)): + res = run(lo, hi_) + blocked = [(p, r) for dd, p, r in res if dd is not None and dd < _ROOM] + print(f"\n{lbl}: ({len(res)} Trades gesamt · {len(blocked)} im Raum<{_ROOM} = vom Gate geblockt)") + print(line("geblockte Gruppe GESAMT", st([r for _, r in blocked]))) + for X in (0.5, 0.6, 0.7): + freed = [r for p, r in blocked if p is not None and p >= X] + rest = [r for p, r in blocked if p is None or p < X] + print(line(f" FREI wenn p≥{X:.1f}", st(freed))) + print(line(f" bleibt geblockt (p<{X:.1f})", st(rest))) + print("\n Einbau NUR, wenn 'FREI p≥X' in BEIDEN Hälften klar positiv (ØR netto ≳ +0,1).") + + +if __name__ == "__main__": + main() diff --git a/core/config.py b/core/config.py index 7b35418..386a20b 100644 --- a/core/config.py +++ b/core/config.py @@ -313,8 +313,11 @@ DEFAULT_CONFIG = { # Entry-Raum-Gate: Signal → WARTEN, wenn das Gegenlevel (M5-Pivot in Trade- # Richtung) näher als X×ATR liegt (gemessen `backtest_entryroom.py` — Raum # <0,6 in beiden Hälften negativ: Ertrag am Level gedeckelt, Kosten fressen - # den Rest). 0 = aus. - "entry_room_atr": "0.6", + # den Rest). 0 = aus. **0,6→1,0 am 2026-07-24 (User: „baue alles ein"):** + # 1,0 war von Anfang an in BEIDEN Hälften besser (H1 +415/H2 +266 R), Start + # 0,6 war nur die konservative Stufe (1,0 filtert ~¾ der Signale → deutlich + # mehr WARTEN-Phasen; gemessen trotzdem mehr Gesamtertrag). + "entry_room_atr": "1.0", # Tageszeit-Gate: Komma-Liste der Stunden (Berlin), zu denen die Wellen- # Empfehlung WARTEN erzwingt. **Gemessener Default = 0–7,12,16** (Nacht- # Kostenfalle + 12/16 Uhr beidhälftig negativ, `backtest_hourly_split.py`). diff --git a/core/engine.py b/core/engine.py index 0cdbca3..40bf5e9 100644 --- a/core/engine.py +++ b/core/engine.py @@ -947,6 +947,13 @@ class TradingEngine: # history.log_recommendation → flutet nicht). try: adv = (self.agent.snapshot() or {}).get("advisory") or {} + # News-Sentiment mitloggen (2026-07-24, Idee 6): war seit einer Weile + # None — wieder befüllt, damit die Prädiktivität in ein paar Monaten + # messbar ist (analyze_verdict_calibration.py fand H2 nur n=4/6). + try: + _news = float((self.news.sentiment or {}).get("score") or 0.0) + except Exception: + _news = None self.history.log_recommendation( signal=rec.get("signal"), score=float(rec.get("score") or 0.0), conf_pct=int(rec.get("conf_pct") or 0), @@ -954,10 +961,31 @@ class TradingEngine: reversal=("rev" if "REV" in (rec.get("setup") or "") else None), ai_sentiment=adv.get("bias"), ai_confidence=adv.get("confidence"), setup=rec.get("setup"), regime=None, rsi=s.get("rsi_m15"), - news_score=None) + news_score=_news) except Exception as e: log.warning(f"log_recommendation: {e}") + # Verdict-Stimmen-Logging (2026-07-24, Idee 2b): Modul-Votes 1×/min in die + # DB (`verdict_votes`), damit die UNBELEGTEN Stimmen (KI-Copilot, Elliott) + # nach ein paar Wochen datenbasiert bewertet werden können (behalten / + # Gewicht senken / raus wie TU). Erste Messung (analyze_verdict_calibration. + # py, 2026-07-24): Copilot nicht robust prädiktiv, n aber klein → sammeln. + try: + vd = self._verdict(s, rec, self.wave.snapshot(), self.tu.snapshot(), + self.agent.snapshot(), self.elliott.snapshot()) + vv = {v["name"]: v for v in vd.get("votes") or []} + def _v(name, key="vote"): + return (vv.get(name) or {}).get(key, 0) + self.history.log_verdict_votes( + headline=vd.get("headline") or "WARTEN", + conf=int(vd.get("conf") or 0), bias=float(vd.get("bias") or 0.0), + tf=vd.get("tf") or "", wave=_v("Welle"), m30=_v("M30"), h1=_v("H1"), + ki=_v("KI-Copilot"), ki_w=float(_v("KI-Copilot", "weight") or 0.0), + elliott=_v("Elliott"), elliott_w=float(_v("Elliott", "weight") or 0.0), + squeeze=_v("Squeeze"), news_score=_news) + except Exception as e: + log.debug(f"log_verdict_votes: {e}") + # Kontext für Trade-Logging (set_open_context beim Öffnen) self._last_rec = { "rec_signal": rec.get("signal"), @@ -1651,6 +1679,21 @@ class TradingEngine: # ══════════════════════════════════════════════════════════════════════ _SNAP_TTL_S = 0.8 # geteilter Snapshot gilt so lange als frisch + def _alignment_cached(self) -> dict | None: + """Ausrichtungs-Split (mit/gegen/ohne Empfehlung, letzte 40 Trades) für den + Order-Bestätigungsdialog — ehrliche eigene Zahlen statt generischem Hinweis + (2026-07-24, Verhaltens-Hebel: Gegen-Signal-Trades sind die gemessene + Kern-Leckage). DB-Query gecacht (~120 s), Snapshot bleibt billig.""" + now = time.time() + if now - getattr(self, "_align_ts", 0.0) > 120: + try: + self._align_cache = self.history.alignment_stats(40) + except Exception as e: + log.debug(f"alignment_stats: {e}") + self._align_cache = None + self._align_ts = now + return getattr(self, "_align_cache", None) + def snapshot_cached(self) -> dict: """Teilt EINEN frisch berechneten Snapshot über alle WS-Clients/HTTP- Aufrufe (TTL ~0,8 s). Spart SQLite/Verdict-Last bei mehreren Geräten — @@ -1903,6 +1946,21 @@ class TradingEngine: "all": self._compact_stats("all")}, "close_alert_count": self._close_alert_count, "news_storm": self.news.storm_state(), + # News-Sentiment-Score (−1..+1) für den Konflikt-Chip (2026-07-24): + # steht der Headline-Flow stark gegen Signal/Position, zeigt das + # Frontend eine Warnung (reiner Kontext — Prädiktivität unbelegt, + # wird via recommendations.news_score gesammelt und später gemessen). + "news_sentiment": (lambda sc: round(float(sc), 2) if sc is not None else None)( + (self.news.sentiment or {}).get("score")), + "alignment": self._alignment_cached(), + # Live-Handelskosten relativ zur Vola (2026-07-24): Spread/ATR(M5). + # Gemessen (`backtest_realcosts.py`): Ø 0,265×ATR, Nacht 0,32–0,50 = + # Kostenfalle, 15–17 Uhr ~0,13 = günstig. Reine Anzeige (Chip) — die + # informierende Alternative zum abgeschalteten Dead-Hours-Gate. + "cost_ratio": (lambda b, a, atr: round((a - b) / max(atr, 0.06), 2) + if (b and a and atr) else None)( + (market or {}).get("bid"), (market or {}).get("ask"), + ((wave_snap or {}).get("pb_feats") or {}).get("atr")), "sr_close_hint": sr_close_hint, "stop_approach": stop_approach, "auto_sr_close": self._auto_sr_close, diff --git a/core/history.py b/core/history.py index fb725ba..2054891 100644 --- a/core/history.py +++ b/core/history.py @@ -138,6 +138,25 @@ SCHEMA = [ correct INTEGER -- NULL bis ausgewertet, dann 0/1 ) """, + """ + CREATE TABLE IF NOT EXISTS verdict_votes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER, -- lokale Epoch + headline TEXT, -- Wave-Signal (LONG/SHORT/WARTEN) + conf INTEGER, -- conf_pct der Welle + bias REAL, -- gewichteter Konsens-Bias −1..+1 + tf TEXT, -- Zeitebene der Empfehlung + wave INTEGER, -- Votes je Modul: +1/−1/0 + m30 INTEGER, + h1 INTEGER, + ki INTEGER, -- KI-Copilot-Vote (0 auch wenn ohne Aussage) + ki_w REAL, -- Copilot-Gewicht (0 = hatte keine Aussage) + elliott INTEGER, + elliott_w REAL, + squeeze INTEGER, + news_score REAL -- News-Sentiment zum Zeitpunkt (Kontext) + ) + """, ] # Indizes — werden NACH den Migrationen ausgeführt, weil sie Spalten @@ -156,6 +175,7 @@ INDEXES = [ "CREATE INDEX IF NOT EXISTS idx_intended_close ON intended_trades(close_ts)", "CREATE INDEX IF NOT EXISTS idx_pbreak_ts ON pbreak_predictions(ts)", "CREATE INDEX IF NOT EXISTS idx_pbreak_open ON pbreak_predictions(outcome) WHERE outcome IS NULL", + "CREATE INDEX IF NOT EXISTS idx_verdict_ts ON verdict_votes(ts)", ] # Migrations: Spalten, die in alten DBs evtl. fehlen. @@ -394,6 +414,61 @@ class HistoryLogger: "pending": pending, } + def log_verdict_votes(self, *, headline: str, conf: int, bias: float, tf: str, + wave: int, m30: int, h1: int, ki: int, ki_w: float, + elliott: int, elliott_w: float, squeeze: int, + news_score=None) -> None: + """Verdict-Modul-Stimmen für die spätere Kalibrier-Auswertung loggen + (Copilot/Elliott sind unbelegte Stimmen — nach ein paar Wochen kann + `analyze_verdict_calibration.py`-artig entschieden werden, ob sie + Gewicht behalten). Intern gedrosselt auf max. 1×/60 s.""" + now = time.time() + if now - getattr(self, "_verdict_log_ts", 0.0) < 60: + return + self._verdict_log_ts = now + with self._lock, self._connect() as conn: + conn.execute(""" + INSERT INTO verdict_votes + (ts, headline, conf, bias, tf, wave, m30, h1, ki, ki_w, + elliott, elliott_w, squeeze, news_score) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, (int(now), headline, conf, bias, tf, wave, m30, h1, ki, ki_w, + elliott, elliott_w, squeeze, news_score)) + conn.commit() + + def alignment_stats(self, last_n: int = 40) -> dict: + """Ausrichtungs-Split der letzten N geschlossenen Trades: liefen sie MIT, + GEGEN oder OHNE die Empfehlung (rec_signal beim Entry)? Für die ehrliche + Verhaltens-Anzeige im Order-Bestätigungsdialog (B0 als Software — die + gemessene Kern-Leckage sind diskretionäre Gegen-Signal-Trades).""" + with self._connect() as conn: + rows = conn.execute(""" + SELECT direction, rec_signal, pnl FROM trades + WHERE exit_time IS NOT NULL AND pnl IS NOT NULL + ORDER BY exit_time DESC LIMIT ? + """, (last_n,)).fetchall() + out = {} + for key, match in (("with", True), ("against", False)): + grp = [] + for r in rows: + rec = (r["rec_signal"] or "").upper() + if rec not in ("LONG", "SHORT"): + continue + aligned = (rec == "LONG") == (r["direction"] == "BUY") + if aligned == match: + grp.append(r["pnl"]) + n = len(grp) + out[key] = {"n": n, + "wr": round(100 * sum(1 for p in grp if p > 0) / n) if n else None, + "pnl": round(sum(grp), 2)} + none_grp = [r["pnl"] for r in rows + if (r["rec_signal"] or "").upper() not in ("LONG", "SHORT")] + n = len(none_grp) + out["none"] = {"n": n, + "wr": round(100 * sum(1 for p in none_grp if p > 0) / n) if n else None, + "pnl": round(sum(none_grp), 2)} + return out + # ══════════════════════════════════════════ # STATISTIK-QUERIES # ══════════════════════════════════════════ diff --git a/oil_widget_config.ini.example b/oil_widget_config.ini.example index 9ec57e8..b637693 100644 --- a/oil_widget_config.ini.example +++ b/oil_widget_config.ini.example @@ -30,7 +30,7 @@ tf_select = heuristic tf_min = M5 tf_max = M30 breakout_k = 0.3 -entry_room_atr = 0.6 +entry_room_atr = 1.0 dead_hours = auto_squeeze = true auto_squeeze_skip_night = true diff --git a/web/app.js b/web/app.js index 90c255c..3b243bc 100644 --- a/web/app.js +++ b/web/app.js @@ -68,7 +68,7 @@ function beepRecommendation(sig, isNew) { } // ── Gesamtempfehlung: Konfidenz-Ring + Bias-Balken + Modul-Chips ─────────── -function renderVerdict(v) { +function renderVerdict(v, snap) { const sig = v.headline || "—"; const cls = sig === "LONG" ? "long" : sig === "SHORT" ? "short" : ""; const arr = sig === "LONG" ? "▲" : sig === "SHORT" ? "▼" : "◌"; @@ -89,9 +89,18 @@ function renderVerdict(v) { const bias = typeof v.bias === "number" ? v.bias : 0; const strength = Math.abs(bias) >= 0.5 ? "stark" : Math.abs(bias) >= 0.2 ? "leicht" : "gemischt"; const dir = bias > 0.05 ? "LONG" : bias < -0.05 ? "SHORT" : "neutral"; + // Live-Kosten-Chip (Spread/ATR — gemessen Ø 0,265; Nacht 0,32–0,50 = Falle): + // reine Info, die die abgeschaltete Dead-Hours-Sperre ehrlich ersetzt. + const cr = snap && snap.cost_ratio; + let costTxt = ""; + if (cr != null) { + const tag = cr < 0.20 ? "✅ günstig" : cr <= 0.32 ? "· normal" : "⚠ teuer"; + costTxt = ` · Kosten ${fmt(cr, 2)}×ATR ${tag}`; + } $("vd-consensus").textContent = - sig === "—" ? "keine Daten" - : `Modul-Konsens: ${strength} ${dir} · ${agree}/${total} Module für ${sig}`; + (sig === "—" ? "keine Daten" + : `Modul-Konsens: ${strength} ${dir} · ${agree}/${total} Module für ${sig}`) + + costTxt; $("vd-needle").style.left = ((bias + 1) / 2 * 100).toFixed(1) + "%"; // Modul-Chips @@ -284,7 +293,7 @@ function render(d) { } // ── Gesamtempfehlung (aggregiert) ─────── - renderVerdict(d.verdict || {}); + renderVerdict(d.verdict || {}, d); renderStructure(d.structure); renderStatsCompact(d.stats_compact, d.market); renderGaps(d.gaps, d.market); @@ -518,6 +527,28 @@ function render(d) { stopEl.textContent = ""; } } + // News-Konflikt-Chip (2026-07-24, realer Auslöser 23.07.: zwei Shorts gegen einen + // +0,98-Sentiment-Burst): |Score|≥0,5 UND Richtung gegen offene Position bzw. + // gegen das aktive Signal → Warnung. Reiner Kontext, Prädiktivität unbelegt. + const ncEl = $("news-conflict"); + if (ncEl) { + const ns = d.news_sentiment; + let txt = ""; + if (ns != null && Math.abs(ns) >= 0.5) { + const newsDir = ns > 0 ? "LONG" : "SHORT"; + const newsWord = ns > 0 ? "bullisch" : "bärisch"; + if (hasPos) { + const posDir = posLong ? "LONG" : "SHORT"; + if (posDir !== newsDir) + txt = `📰 News-Flow stark ${newsWord} (${ns > 0 ? "+" : ""}${fmt(ns, 2)}) — steht gegen deine ${posDir}-Position`; + } else if (w.signal === "LONG" || w.signal === "SHORT") { + if (w.signal !== newsDir) + txt = `📰 News-Flow stark ${newsWord} (${ns > 0 ? "+" : ""}${fmt(ns, 2)}) — steht gegen das ${w.signal}-Signal`; + } + } + if (txt) { ncEl.className = "news-conflict warn"; ncEl.textContent = txt; } + else { ncEl.className = "news-conflict hidden"; ncEl.textContent = ""; } + } // Schalter-Zustand: Auto-Close am S/R an/aus + Mindestgewinn const srBtn = $("srclose-btn"); if (srBtn) { @@ -735,11 +766,26 @@ document.querySelectorAll(".btn[data-side]").forEach(btn => { const info = recInfo(side); // WARTEN/Gegen-Signal → Hinweis, der if (info.danger) { // bestätigt werden muss (kein Block) const sig = (lastSnap.wave_signal || {}).signal || "WARTEN"; - const head = (sig === "WARTEN" || sig === "—") + const isWait = (sig === "WARTEN" || sig === "—"); + const head = isWait ? "ℹ Keine Empfehlung (WARTEN)" : `ℹ Empfehlung steht GEGEN dich (${sig})`; + // Ehrliche EIGENE Zahlen (letzte 40 Trades, Ausrichtungs-Split aus der DB): + // die gemessene Kern-Leckage sind genau diese diskretionären Abweichungen. + let statLine = ""; + const al = lastSnap.alignment || null; + if (al) { + const grp = isWait ? al.none : al.against; + const ref = al.with; + const f = g => (g && g.n >= 5) + ? `${g.wr}% Trefferquote · ${g.pnl >= 0 ? "+" : ""}${fmt(g.pnl, 0)} € (n${g.n})` + : null; + const own = f(grp), rf = f(ref); + if (own) statLine += `\n\n📊 Deine Trades ${isWait ? "OHNE Signal" : "GEGEN das Signal"}: ${own}`; + if (rf) statLine += `\n📊 Mit Signal: ${rf}`; + } const ok = await modal(head, - info.line + `\n\n${side.toUpperCase()} trotzdem eröffnen?`, + info.line + statLine + `\n\n${side.toUpperCase()} trotzdem eröffnen?`, "confirm", true); if (!ok) return; // Abbrechen = doch nicht; sonst läuft er } diff --git a/web/index.html b/web/index.html index 13df9ee..3ca2ac0 100644 --- a/web/index.html +++ b/web/index.html @@ -6,7 +6,7 @@ Oil · MT5 - + @@ -91,6 +91,7 @@
+
Setup
Lauf
@@ -216,7 +217,7 @@ - - + + diff --git a/web/style.css b/web/style.css index 76c6e7e..36c6847 100644 --- a/web/style.css +++ b/web/style.css @@ -167,7 +167,8 @@ main{display:grid;grid-template-columns:1fr 1fr;gap:11px;padding:11px} /* ── Meldungen (EINHEITLICHES Design: Box + farbiger Links-Akzent) ─────────── */ #card-pos .squeeze-ind, #card-pos .bounce-ind, #card-pos .cur-near, -#card-pos .pos-srhint, #card-pos .pos-stophint, #card-pos .reasons{ +#card-pos .pos-srhint, #card-pos .pos-stophint, #card-pos .news-conflict, +#card-pos .reasons{ display:block; margin:0 0 7px; padding:8px 11px; border-radius:var(--r-sm); background:var(--surface-2); border:1px solid var(--border); border-left:3px solid var(--line); @@ -179,6 +180,9 @@ main{display:grid;grid-template-columns:1fr 1fr;gap:11px;padding:11px} /* Gegen-/Stop-Seiten-Hinweis (reine Anzeige): hält = grün, bricht = rot */ #card-pos .pos-stophint.hold{color:var(--up); border-left-color:var(--up)} #card-pos .pos-stophint.break{color:var(--down); border-left-color:var(--down)} +/* News-Konflikt (Flow steht gegen Signal/Position) — bernstein wie Warnungen */ +#card-pos .news-conflict.warn{color:var(--accent); border-left-color:var(--accent)} +#card-pos .news-conflict.hidden, #card-pos .news-conflict:empty{display:none} /* Farbvarianten — Links-Akzent + Textfarbe (Fläche bleibt einheitlich) */ #card-pos .squeeze-ind.idle, #card-pos .bounce-ind.idle, #card-pos .reasons{color:var(--dim)}