From 027995d1e7a1768c8db66af4b3e77c967a737935 Mon Sep 17 00:00:00 2001 From: Axel Hocks Date: Fri, 24 Jul 2026 16:19:39 +0200 Subject: [PATCH] Gegen-/Stop-Seiten-Anzeige P(Durchbruch) (stop_approach, v=109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zeigt die Durchbruchwahrscheinlichkeit fuer das Level, dem sich der Kurs auf der Gegen-/Stop-Seite der offenen Position naehert (LONG->Support darunter, SHORT->Resistance darueber) — reine Anzeige, kein Auto-Close. Vorher per backtest_pbreak_calibration.py belegt: Modell haelt auf der Gegen-Seite out-of-sample (AUC 0,68/0,72 in beiden Haelften), niedriger P-Bereich kalibriert. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 21 ++++- backtest_pbreak_calibration.py | 159 +++++++++++++++++++++++++++++++++ core/engine.py | 63 +++++++++++++ web/app.js | 23 +++++ web/index.html | 5 +- web/style.css | 9 +- 6 files changed, 274 insertions(+), 6 deletions(-) create mode 100644 backtest_pbreak_calibration.py diff --git a/CLAUDE.md b/CLAUDE.md index 0c8fcce..09de8cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -559,6 +559,25 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: mit synthetischen Szenarien (break/bounce/Gegenteil-falsch/Timeout) — alle 4 exakt wie erwartet. **Reine Messung, kein Signal-/Exit-Eingriff** — noch keine UI-Karte (Datensammlung; B4-artige Auswertung später möglich, wenn genug Zeilen vorliegen). + **Gegen-/Stop-Seiten-Anzeige `stop_approach` (2026-07-24, User-Frage „Kurs nähert + sich Support, aber keine Durchbruchwahrscheinlichkeit angezeigt"):** Der reguläre + `sr_close_hint` beobachtet nur das **Ziel-Level** (LONG→Resistance, SHORT→Support) + und wirkt nur im Plus — nähert sich der Kurs dem **Gegen-/Stop-Level** (LONG→Support + darunter, SHORT→Resistance darüber, typ. Verlust-Trade), war die App bewusst still. + Neu: `engine._stop_approach_hint` liefert für GENAU dieses Level eine **reine + Anzeige** von P(Durchbruch) — Break-Richtung `-d` (gegen den Trade), Feature-/ + 60-s-Glättung (`_stop_pb_state`) identisch zu `_sr_close_hint`. **KEIN Auto-Close.** + Snapshot `stop_approach {level, side, p_break, at_level, gap_atr, confirm, reject}`, + Frontend `#pos-stophint` (grün „Level hält wahrscheinlich" bei P<50, rot „bricht + wahrscheinlich durch" bei ≥50; nur wenn KEIN Ziel-`sr_close_hint` läuft; v=109). + **Vorher gemessen (`backtest_pbreak_calibration.py`, 2026-07-24, Gate „erst messen, + dann bauen"):** Das kalibrierte P(break)-Modell (auf Ziel-Seite trainiert) hält + auch auf der **Gegen-/Stop-Seite** — AUC **0,68/0,72** out-of-sample in BEIDEN + Hälften (≈ Ziel-Seite 0,67/0,71), im niedrigen P-Bereich sauber kalibriert (der + praktisch wichtige „hält der Support?"-Fall). ⚠ Einzige Schwäche: im HOHEN Bereich + (P≥0,5) GEGEN den Trend unterschätzt es die Bruch-Rate ~10–13 Pp (predicted 58 % → + echt 71 %) — also **konservativ** (warnt eher zu wenig vor einem Bruch), kein + Kalibrierungs-Bruch. Deshalb reine Anzeige, kein Trade-Trigger. **Anti-Flattern (2026-07-14, gemessen `analyze_pbreak_flicker.py`:** mom3 der laufenden M5-Kerze ließ P 7–15 Pp/Bar springen, Max 77 Pp; 8 % der Annäherungen hatten ≥2 Schwellen-Flips): (1) **P zeit-geglättet** (~60-s-EMA in `_pb_state`, @@ -1084,7 +1103,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=108** (in `web/index.html` hochzählen, siehe Workflows). +- Asset-Version aktuell **v=109** (in `web/index.html` hochzählen, siehe Workflows). ## Web-API (server.py) `GET /api/snapshot` (inkl. `verdict`-Aggregat) `/api/health` `/api/logs` `/api/stats` `/api/news` `/api/bars?tf=&n=` (Charts, read-only) `/api/squeeze_monitor` (B4-Monitor, read-only, Engine-Cache 60 s) · `WS /ws` · diff --git a/backtest_pbreak_calibration.py b/backtest_pbreak_calibration.py new file mode 100644 index 0000000..3b355ee --- /dev/null +++ b/backtest_pbreak_calibration.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Kalibrierungs-Test des P(break)-Modells auf der GEGEN-/STOP-Seite (User-Frage: +Kurs naehert sich einem Level GEGEN Trade/Trend — ist die P(break)-Zahl dort +belastbar?). Vorgehen wie backtest_srclose_prob.py: + + - Modell auf H1 ZIEL-Seiten-Touches trainieren (identisch zum Live-Modell). + - Auswertung auf H1 UND H2, getrennt nach: + * Quelle: ZIEL-Seite (mit-Trade, wie trainiert) vs STOP-Seite (gegen-Trade) + * wt: mit-Trend (1) vs gegen-Trend (0) + - Metriken je Gruppe: Kalibrierung (Ø-P vs echte Break-Rate je P-Bin) + AUC + n. +Break/Bounce-Definition IDENTISCH zum Training: _BRK_ATR=0.5, _BRK_W=12 (=60min). +""" +import sys +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; _SL_ATR=2.0 +_PIV_K=3; _LOOKBACK=300; _BRK_W=12; _BRK_ATR=0.5 + +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]))) + 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 _signal_dir(i,C,EF,ES,AT): + atr=AT[i] + if not atr or atr<=0: return 0,None + 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 + if stretch<=-_REVERSAL_STRETCH and ad>=_ANGLE_DEAD: return 1,atr + if stretch>=_REVERSAL_STRETCH and ad<=-_ANGLE_DEAD: return -1,atr + if abs(stretch)<_STRETCH_MAX: return (1 if ef>es else -1 if ef=level) if dd>0 else (L[j]<=level): jt=j; break + # Abbruch, wenn Kurs vorher 2*atr in Gegenrichtung (weg vom Level) laeuft + if ((C[j]-entry)*(-dd)) >= 2.0*atr: break + if jt is None: return None + up=level+dd*_BRK_ATR*atr; dn=level-dd*_BRK_ATR*atr; brk=None + for j in range(jt, min(jt+_BRK_W, len(H))): + if (H[j]>=up) if dd>0 else (L[j]<=up): brk=1.0; break + if (L[j]<=dn) if dd>0 else (H[j]>=dn): brk=0.0; break + if brk is None: brk=0.0 # Timeout = Bounce (wie im Training) + feat=[(C[jt]-C[max(0,jt-6)])*dd/atr,(C[jt]-C[max(0,jt-3)])*dd/atr, + 1.0 if (EF[jt]-ES[jt])*dd>0 else 0.0, abs(level-entry)/atr] + return {"feat":feat,"brk":brk,"wt":feat[2]} + +def collect(a,b,H,L,C,EF,ES,AT,side): + """side='target' → mit-Trade Ziel-Level (wie Training); 'stop' → Gegen-Level.""" + out=[] + for i in range(max(a,_N_BARS,_LOOKBACK), min(b,len(C)-_MAXH-1)): + d,atr=_signal_dir(i,C,EF,ES,AT) + if not d: continue + entry=C[i]; phis,plos=_pivots(i,H,L) + if side=="target": + if d>0: + cands=[p for p in phis if p>entry+0.3*atr]; level=min(cands) if cands else None + else: + cands=[p for p in plos if p0: + cands=[p for p in plos if pentry+0.3*atr]; level=min(cands) if cands else None + dd=-d + if level is None: continue + r=_touch_and_break(i,entry,dd,atr,level,H,L,C,EF,ES) + if r: out.append(r) + 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(p,y): + p=np.asarray(p); y=np.asarray(y) + pos=p[y==1]; neg=p[y==0] + if len(pos)==0 or len(neg)==0: return float('nan') + # Mann-Whitney via Rang + order=np.argsort(np.concatenate([pos,neg])) + ranks=np.empty_like(order,dtype=float); ranks[order]=np.arange(1,len(order)+1) + r_pos=ranks[:len(pos)].sum() + return (r_pos-len(pos)*(len(pos)+1)/2)/(len(pos)*len(neg)) + +def calib_table(name,ev,pf): + if not ev: + print(f" {name:<28} (keine Events)"); return + P=np.array([pf(e) for e in ev]); Y=np.array([e["brk"] for e in ev]) + a=auc(P,Y); base=Y.mean() + print(f" {name:<28} n={len(ev):>5} Break-Basisrate={base*100:>4.0f}% AUC={a:.2f}") + bins=[(0,0.3),(0.3,0.5),(0.5,0.7),(0.7,1.01)] + for lo,hi in bins: + m=(P>=lo)&(P4.0f}% → echt {Y[m].mean()*100:>4.0f}% (n{m.sum()})") + +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=None + for req in (n,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] + EF=_ema_series(C,_EMA_FAST); ES=_ema_series(C,_EMA_SLOW); AT=_atr_series(H,L,C) + mid=len(C)//2 + print("="*94) + print(f" P(break)-Kalibrierung Ziel- vs Stop-Seite — {sym} M5, {len(bars)} Bars") + print("="*94) + tgt1=collect(_N_BARS,mid,H,L,C,EF,ES,AT,"target") + tgt2=collect(mid,len(C),H,L,C,EF,ES,AT,"target") + stp1=collect(_N_BARS,mid,H,L,C,EF,ES,AT,"stop") + stp2=collect(mid,len(C),H,L,C,EF,ES,AT,"stop") + # Modell auf H1-ZIEL-Touches trainieren (identisch zum Live-Modell) + X1=np.array([e["feat"] for e in tgt1]); y1=np.array([e["brk"] for e in tgt1]) + mu=X1.mean(0); sd=X1.std(0)+1e-9 + w=fit_logreg((X1-mu)/sd,y1) + def pf(e): + x=(np.array(e["feat"])-mu)/sd + return 1/(1+np.exp(-(w@np.concatenate([[1.0],x])))) + print(f" Modell (auf H1-Ziel trainiert): mom6={w[1]:+.2f} mom3={w[2]:+.2f} wt={w[3]:+.2f} dist={w[4]:+.2f}\n") + for lbl,tgt,stp in (("H1 (Trainings-Halfte)",tgt1,stp1),("H2 (OUT-OF-SAMPLE)",tgt2,stp2)): + print(f"── {lbl} ──") + calib_table("ZIEL-Seite (mit-Trade)",tgt,pf) + calib_table("STOP-Seite (gegen-Trade)",stp,pf) + # STOP-Seite zusaetzlich nach wt gesplittet (der User-Fall ist wt=0) + calib_table(" davon gegen-Trend (wt=0)",[e for e in stp if e["wt"]==0.0],pf) + calib_table(" davon mit-Trend (wt=1)",[e for e in stp if e["wt"]==1.0],pf) + print() + print(" Kalibriert = 'vorhergesagt Ø' ≈ 'echt' in jedem Bin, UND das in H2 (out-of-sample).") + +if __name__=="__main__": + main() diff --git a/core/engine.py b/core/engine.py index 65fa98d..0cdbca3 100644 --- a/core/engine.py +++ b/core/engine.py @@ -236,6 +236,7 @@ class TradingEngine: except (TypeError, ValueError): self._sr_close_min_gain_pct = 1.0 self._pb_state = None # geglättetes P(break) + Hysterese-Zone je Level + self._stop_pb_state = None # dito für die Gegen-/Stop-Seiten-Anzeige # (gemessen `analyze_pbreak_flicker.py`: mom3 der # laufenden Kerze ließ P ~7–15 Pp je M5-Bar springen) # UI-Einstellungen neustart-fest (Fix 2026-07-19): Toggles/Werte, die über @@ -1862,6 +1863,7 @@ class TradingEngine: day_pl = {"realized": round(day_realized, 2), "open": round(open_pnl, 2), "total": round(day_realized + open_pnl, 2)} sr_close_hint = self._sr_close_hint(market, position, wave_snap) + stop_approach = self._stop_approach_hint(market, position, wave_snap) return { "ts": time.time(), "connected": self._connected, @@ -1902,6 +1904,7 @@ class TradingEngine: "close_alert_count": self._close_alert_count, "news_storm": self.news.storm_state(), "sr_close_hint": sr_close_hint, + "stop_approach": stop_approach, "auto_sr_close": self._auto_sr_close, "sr_close_min_gain": self._sr_close_min_gain, "sr_close_pbreak": self._sr_close_pbreak, @@ -2016,3 +2019,63 @@ class TradingEngine: except Exception as e: log.debug(f"sr_close_hint: {e}") return None + + def _stop_approach_hint(self, market, position, wave_snap) -> dict | None: + """REINE ANZEIGE: P(Durchbruch) für das Level, dem sich der Kurs auf der + **Gegen-/Stop-Seite** der offenen Position nähert (LONG → Support darunter, + SHORT → Resistance darüber). Beantwortet die User-Frage „mein Trade läuft auf + ein Level zu — bricht es durch (Verlust läuft) oder prallt es ab (erholt sich)?". + NICHT der S/R-Auto-Close (der wirkt nur auf der Ziel-Seite im Plus) — hier wird + NICHTS geschlossen. Kalibrierung der Gegen-Seite gemessen + (`backtest_pbreak_calibration.py`, 2026-07-24): AUC 0,68/0,72 out-of-sample in + beiden Hälften, im niedrigen P-Bereich sauber kalibriert; im hohen (≥0,5) gegen + den Trend leicht KONSERVATIV (unterschätzt die Bruch-Rate ~10–13 Pp) → die Zahl + ist eine belastbare, eher vorsichtige Schätzung. Feature-/Glättungs-Logik + identisch zu `_sr_close_hint`, nur Level = Gegen-Level und Richtung = -d.""" + try: + if not position.get("ticket"): + self._stop_pb_state = None + return None + is_long = position.get("order_type") == 0 + bid = (market or {}).get("bid") + pf = (wave_snap or {}).get("pb_feats") or {} + atr = pf.get("atr"); mom6 = pf.get("mom6"); mom3 = pf.get("mom3") + ediff = pf.get("ema_diff") + entry = position.get("entry_price") or bid + dl = self._draw_levels() + # Gegen-Level: LONG → Support darunter, SHORT → Resistance darüber + if is_long: + level = (dl.get("sup") or [None])[0] + else: + level = (dl.get("res") or [None])[0] + if not (bid and atr and level) or mom6 is None or mom3 is None or ediff is None: + self._stop_pb_state = None + return None + dd = -1 if is_long else 1 # Break-Richtung GEGEN den Trade + gap = (level - bid) * dd # >0 solange Kurs noch vor dem Level + if gap <= 0 or gap > 0.6 * atr: # weit weg / schon durch → kein Hinweis + self._stop_pb_state = None + return None + wt = 1.0 if ediff * dd > 0 else 0.0 + dist = abs(level - entry) / atr + p_raw = _p_break(mom6 * dd, mom3 * dd, wt, dist) + import math as _m + now = time.time(); st = self._stop_pb_state + if not st or abs(st["level"] - level) > 1e-9: + st = {"level": level, "p": p_raw, "ts": now} + else: + a = 1.0 - _m.exp(-max(0.0, now - st["ts"]) / 60.0) + st["p"] += a * (p_raw - st["p"]); st["ts"] = now + self._stop_pb_state = st + p_s = st["p"] + confirm = round(level + dd * 0.5 * atr, 3) # Durchbruch bestätigt ab + reject = round(level - dd * 0.5 * atr, 3) # Abprall bestätigt ab + return {"level": round(level, 3), + "side": ("sup" if is_long else "res"), + "p_break": round(100 * p_s), + "at_level": gap <= 0.15 * atr, + "gap_atr": round(gap / atr, 2), + "confirm": confirm, "reject": reject} + except Exception as e: + log.debug(f"stop_approach_hint: {e}") + return None diff --git a/web/app.js b/web/app.js index e7443fb..335fd13 100644 --- a/web/app.js +++ b/web/app.js @@ -495,6 +495,29 @@ function render(d) { hintEl.textContent = ""; } } + // Gegen-/Stop-Seiten-Hinweis (REINE ANZEIGE, kein Auto-Close): Kurs läuft auf das + // Level GEGEN die Position zu → bricht es durch (Verlust läuft) oder prallt es ab? + // P(break)-Modell auf der Gegen-Seite gemessen kalibriert (AUC 0,72 o.o.s.), + // im hohen Bereich leicht konservativ. Nur zeigen, wenn KEIN Ziel-Level-Hint läuft. + const stopEl = $("pos-stophint"); + if (stopEl) { + const sa = d.stop_approach || null; + if (hasPos && sa && !srHint) { + const lvlName = sa.side === "sup" ? "Support" : "Widerstand"; + const dir = sa.side === "sup" ? "nach unten" : "nach oben"; + const confirmTxt = sa.confirm != null ? ` (Bruch ab ${fmt(sa.confirm, 3)})` : ""; + const holds = sa.p_break < 50; + stopEl.className = "pos-stophint " + (holds ? "hold" : "break"); + const lead = sa.at_level ? `Am ${lvlName}` : `Nähert sich ${lvlName}`; + stopEl.textContent = + `ℹ️ ${lead} ${fmt(sa.level, 3)} (${sa.gap_atr}×ATR) · Durchbruch ${dir} ` + + `${sa.p_break}%${confirmTxt} → ` + + (holds ? "Level hält wahrscheinlich" : "bricht wahrscheinlich durch"); + } else { + stopEl.className = "pos-stophint hidden"; + stopEl.textContent = ""; + } + } // Schalter-Zustand: Auto-Close am S/R an/aus + Mindestgewinn const srBtn = $("srclose-btn"); if (srBtn) { diff --git a/web/index.html b/web/index.html index c0c1030..97526a4 100644 --- a/web/index.html +++ b/web/index.html @@ -6,7 +6,7 @@ Oil · MT5 - + @@ -90,6 +90,7 @@
+
Setup
Lauf
@@ -216,6 +217,6 @@ - + diff --git a/web/style.css b/web/style.css index 3fcc153..07fe022 100644 --- a/web/style.css +++ b/web/style.css @@ -165,15 +165,18 @@ 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 .reasons{ +#card-pos .pos-srhint, #card-pos .pos-stophint, #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); font-size:12px; line-height:1.4; font-weight:600; letter-spacing:0; box-shadow:none; } /* leere Meldungen ausblenden (cur-near/srhint/reasons ohne Inhalt) */ -#card-pos .cur-near:empty, #card-pos .pos-srhint:empty, -#card-pos .pos-srhint.hidden, #card-pos .reasons:empty{display:none} +#card-pos .cur-near:empty, #card-pos .pos-srhint:empty, #card-pos .pos-stophint:empty, +#card-pos .pos-srhint.hidden, #card-pos .pos-stophint.hidden, #card-pos .reasons:empty{display:none} +/* 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)} /* Farbvarianten — Links-Akzent + Textfarbe (Fläche bleibt einheitlich) */ #card-pos .squeeze-ind.idle, #card-pos .bounce-ind.idle, #card-pos .reasons{color:var(--dim)}