"""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}
