"""core/cone.py — Wahrscheinlichkeits-Kegel (Streuung, NICHT Richtung). User-Wunsch 2026-07-31: „ein Kursvorhersagemodul, das mögliche Verläufe in MT5 einzeichnet". Eingezeichnete PFADE würden eine Präzision suggerieren, die es nicht gibt — das Projekt hat mit `analyze_verdict_calibration.py` teuer gelernt, dass eine Zahl ohne Kalibrierungsprüfung in die Irre führt (conf_pct war in H2 invertiert). Deshalb bewusst WENIGER, dafür belegt: der Kegel sagt nicht, WOHIN der Kurs geht, sondern WIE WEIT er in 30/60/120 min plausibel gelaufen sein kann. GEMESSEN & OUT-OF-SAMPLE VALIDIERT (`analyze_cone.py`, 80k M5-Bars, Fit auf H1, Abdeckung gemessen auf H2): Band 30 min 60 min 120 min 80 % nom. 76,7 % 76,6 % 75,3 % 90 % nom. 87,5 % 87,2 % 86,5 % ⚠ Die Bänder sind konsistent ~3–5 Prozentpunkte ZU ENG (H2 war relativ zum ATR etwas volatiler als H1). Das wird NICHT weggefittet — die Anzeige nennt die real gemessene Abdeckung, nicht den Nennwert. Damit ist der Kegel nach dem P(break)- Modell die zweite kalibriert geprüfte Komponente im Projekt. Quantile final auf allen 80k Bars gefittet (dasselbe Vorgehen wie beim P(break)- Modell: erst out-of-sample validieren, dann für den Live-Einsatz auf der vollen History fitten). ATR-normiert → über Vola-Regime hinweg gültig. ⚠ KEIN Signal, kein Verdict-Gewicht, kein Trade-Trigger. Der Kegel ist um den AKTUELLEN Kurs zentriert (keine Drift addiert — der Median lag bei ±0,0…0,16×ATR, also klein gegen die Bandbreite; eine Drift-Korrektur wäre eine Richtungsaussage und genau die ist hier nicht belegt). """ from __future__ import annotations # Horizont (M5-Bars) → {Nennband: (unteres, oberes Quantil in ×ATR)} _CONE = { 6: {50: (-0.915, +0.940), 80: (-2.000, +1.962), 90: (-2.800, +2.713)}, 12: {50: (-1.333, +1.346), 80: (-2.854, +2.833), 90: (-4.064, +4.000)}, 24: {50: (-1.949, +2.000), 80: (-4.183, +4.177), 90: (-5.955, +5.938)}, } # real gemessene Abdeckung (H2, out-of-sample) — das steht in der UI, nicht der Nennwert _COVERAGE = {6: {50: 44.4, 80: 76.7, 90: 87.5}, 12: {50: 45.1, 80: 76.6, 90: 87.2}, 24: {50: 45.5, 80: 75.3, 90: 86.5}} _ATRMIN = 0.06 MINUTES = {6: 30, 12: 60, 24: 120} # ── ADAPTIVE CONFORMAL INFERENCE (2026-08-08) ──────────────────────────────── # ⚠⚠ WOZU: die Baender sind oben dokumentiert **3–5 Pp ZU ENG** (80 % nominal → # real 76,7 / 76,6 / 75,3 %). Das stand seit dem 31.07. so da und wurde NICHT # behoben, weil ein Nachfitten das Problem nur verschiebt: die naechste Periode # hat wieder eine andere Vola. # ACI loest genau das — es justiert das Niveau ONLINE nach jedem aufgeloesten # Fenster und konvergiert nachweislich gegen die Zielabdeckung, AUCH unter # Verteilungsdrift (Gibbs & Candes; DtACI schaetzt zusaetzlich gamma selbst). # # ⚠ EHRLICH ZUR BAUART: die Lehrbuch-Form justiert `alpha` und schlaegt das # zugehoerige Quantil nach. Hier liegt nur eine feste Quantil-TABELLE vor, kein # vollstaendiges Verteilungsobjekt — deshalb die **multiplikative Variante**: # ein Skalenfaktor je Horizont, aktualisiert als # s <- s * exp(gamma * (1{verfehlt} - alpha_ziel)) # Verfehlt das Fenster oefter als alpha_ziel, waechst s (Band wird breiter), # sonst schrumpft es. Dieselbe Regelungsidee, aber die Konvergenz-GARANTIE der # Originalform ist damit NICHT woertlich uebertragen — es ist ACI im Geiste. _ACI_GAMMA = 0.02 # Schrittweite (Literatur ~0,005–0,05) _ACI_MIN, _ACI_MAX = 0.7, 2.0 # Skalen-Deckel gegen Ausreisser/Endlos-Drift def aci_update(skala: float, verfehlt: bool, band: int = 80, gamma: float = _ACI_GAMMA) -> float: """Ein ACI-Schritt. `verfehlt` = der Kurs lag AUSSERHALB des Bandes.""" ziel = 1.0 - band / 100.0 # z. B. 0,20 bei 80 % import math s = skala * math.exp(gamma * ((1.0 if verfehlt else 0.0) - ziel)) return max(_ACI_MIN, min(_ACI_MAX, s)) def build(price: float, atr: float, bands=(50, 80), skalen: dict | None = None) -> dict | None: """→ {'atr':…, 'levels':[{minutes, bars, band, lo, hi, coverage, skala}, …]} `price` = aktueller Kurs (Bid), `atr` = ATR der M5-Basis. `skalen` = {bars: Faktor} aus ACI; ohne Angabe bleibt alles wie bisher (⚠ ADDITIV — ein Aufruf ohne `skalen` liefert bitgenau die alten Werte). """ if not price or not atr or atr <= 0: return None a = max(float(atr), _ATRMIN) sk = skalen or {} out = [] for bars in sorted(_CONE): for band in bands: q = _CONE[bars].get(band) if not q: continue s = float(sk.get(bars) or sk.get(str(bars)) or 1.0) out.append({ "minutes": MINUTES[bars], "bars": bars, "band": band, "lo": round(price + q[0] * a * s, 3), "hi": round(price + q[1] * a * s, 3), "coverage": _COVERAGE.get(bars, {}).get(band), "skala": round(s, 3), }) return {"atr": round(a, 4), "price": round(float(price), 3), "levels": out}