"""
core/agent.py — TradingAgent (Phase 1: read-only Copilot)
==========================================================
Ein KI-Copilot, der den kompletten Systemzustand liest, in Klartext
beurteilt und eine begründete Empfehlung gibt — OHNE etwas auszuführen.
Die schnellen, deterministischen Entscheidungen (Wellen-Signal, Trailing,
Emergency-Close) bleiben im Code; der Agent ist die langsame, denkende
Schicht darüber (Intervall ~5 min, nicht im Tick-Pfad).

Read-Tools (liefern vorhandene snapshot()-Methoden):
  _tool_market       → Wellen-Signal + TradersUnion-Tachos
  _tool_position     → offene Position + Live-P&L + Trailing-Phase
  _tool_account      → Symbol/Preis/Spread/Balance/Equity/RSI/ATR/Reversal
  _tool_performance  → Tages-/Wochen-Statistik + letzte Trades + Dry-Run
  _tool_news         → News-Sentiment

Phase 1 ruft die Tools deterministisch auf (ein LLM-Call pro Runde, schont
das Quota). Die saubere Tool-Trennung erlaubt in Phase 2 echtes
Function-Calling + Trade-Vorschläge.

Provider: lokales LLM via Ollama (Default — kein Key, kein Quota) oder Claude
(offizielles anthropic-SDK), Gemini/OpenAI als Fallback. Konfiguration in
oil_widget_config.ini ([ollama]/[anthropic]/[gemini]/[openai]).
"""

from __future__ import annotations
import json
import threading
import time

from core.logger import get_logger
from core.market_hours import session_state

log = get_logger("agent")


def _agent_session() -> dict:
    """Kompakter Session-Status für den Agent-Kontext."""
    ss = session_state()
    return {
        "phase":       ss["phase"],
        "active":      ss["active"],
        "just_opened": ss["just_opened"],
        "next_open":   ss["next_open"],
    }

_GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
_OPENAI_URL = "https://api.openai.com/v1/chat/completions"

_SYS = (
    "Du bist ein nüchterner Trading-Copilot für WTI-Rohöl (Intraday-Scalping). "
    "Du bekommst den aktuellen Systemzustand eines automatischen Handels-Bots "
    # ⚠ KORRIGIERT 2026-08-02: hier stand „Wellen-Signal als ATR-ZigZag,
    # TradersUnion-Tachos". BEIDES war falsch und hat das Modell in die Irre
    # geführt: (a) die Richtung kommt seit Langem aus EMA12 vs. EMA50 — ZigZag
    # wurde verworfen, weil es ~0 Edge hatte; (b) TradersUnion ist seit
    # 2026-07-19 gar nicht mehr im Copilot-Kontext. Das Modell wurde also auf
    # Daten hingewiesen, die es nie sieht, und hat die Natur des Hauptsignals
    # missverstanden.
    "(Wellen-Signal aus EMA12 gegen EMA50 mit Totband, Durchbruch-"
    "Wahrscheinlichkeit am nächsten S/R-Level, erwartete Kursspanne, offene "
    "Position, Konto, Track-Record, News). Beurteile die Lage knapp und ehrlich — keine "
    "Garantien, keine Hype-Sprache. Wenn die Signale widersprüchlich oder dünn "
    "sind, sag das klar und empfiehl NEUTRAL/abwarten.\n\n"
    "WICHTIG — nutze den Track-Record zur Kalibrierung: 'current_setup_history' "
    "zeigt, wie das aktuell anstehende Setup bisher real gelaufen ist, "
    "'by_setup' die übrigen, 'week'/'today' die Gesamtbilanz. Hat das aktuelle "
    "Setup eine schwache Trefferquote oder negative Durchschnitts-PnL (avg_pnl), "
    "dämpfe deine Konfidenz deutlich oder empfiehl NEUTRAL — auch wenn das "
    "Live-Signal stark wirkt. Hat es sich bewährt, darf das deine Konfidenz "
    "stützen. Folge dem Signal nicht blind gegen eine klar negative Historie; "
    "nenne den Bezug in 'reasoning' kurz (z.B. Setup-Trefferquote).\n\n"
    # ── Die zwei EINZIGEN kalibriert geprüften Größen des Projekts ────────────
    # ⚠ Ergänzt 2026-08-02. Der Copilot sah bis dahin NUR das Wellen-Signal und
    # die Session — er war damit das schlechtest informierte Modul im System und
    # bekam ausgerechnet die beiden Komponenten nicht, deren Treffsicherheit
    # out-of-sample nachgewiesen ist. Die Lese-Anleitung ist Pflicht: ohne sie
    # deutet ein LLM „P(break) 30 %" als schwaches Signal statt als klare
    # Aussage „das Level hält mit 70 %".
    "ZWEI KALIBRIERTE GRÖSSEN — nutze sie bevorzugt, sie sind die einzigen "
    "Zahlen hier mit nachgewiesener Treffsicherheit:\n"
    "1) 'p_break_target' / 'p_break_stop' = Wahrscheinlichkeit in PROZENT, dass "
    "der Kurs das jeweilige S/R-Level DURCHBRICHT (Ziel-Level in Trade-Richtung, "
    "Stop-Level dagegen). Out-of-sample geprüft (AUC 0,65 bzw. 0,68–0,72). "
    "Lies sie richtig: 30 % Durchbruch heißt 70 % ABPRALL — das ist eine starke "
    "Aussage, kein schwaches Signal. Ein Abprall am Ziel-Level deckelt den "
    "Ertrag (spricht gegen einen Einstieg dorthin); ein Durchbruch am "
    "Stop-Level ist eine Warnung für eine offene Position. Werte zwischen 45 "
    "und 55 % sind ein Münzwurf — dann NICHT darauf argumentieren.\n"
    "2) 'cone' = erwartete Kursspanne in 30/60/120 Minuten (80 %-Band). "
    "Gemessene Abdeckung real 77 %, nicht 80 % — das Band ist eher etwas ZU "
    "ENG. Es sagt NICHTS über die Richtung, nur über die plausible Weite. "
    "Nutze es, um Ziele/Stops auf Realismus zu prüfen: liegt ein Kursziel "
    "außerhalb des 120-min-Bands, ist es für einen Intraday-Trade unrealistisch.\n\n"
    "Wähle außerdem den Analyse-Timeframe 'timeframe' für die Wellen-Erkennung "
    "(M1|M5|M15|M30|H1): M1/M5 bei enger Range und ruhigem Markt (Scalping, "
    "kleine Wellen); höhere TF (M15/M30/H1) bei klarem Trend, hoher Volatilität "
    "oder wenn die niedrige TF laut Track-Record zu verrauscht ist (viele "
    "Fehlsignale). Nimm den Timeframe, auf dem die Welle am klarsten und "
    "verlässlichsten handelbar ist. Im Zweifel M5.\n\n"
    "Beziehe die Elliott-Wave-Struktur ('elliott') ein, falls vorhanden und "
    "valid=true: Steht der Kurs nahe einem projizierten Wellen-5-Ziel "
    "(target/1.618) oder ist 'exhaustion'=true bzw. der Impuls vollendet, ist "
    "der Trend ERSCHÖPFT — sei vorsichtig mit Einstiegen in Trendrichtung und "
    "rechne mit einem Reversal (spricht für NEUTRAL oder Gegenrichtung). Ein "
    "offener FVG ('fvg') ist eine Reaktionszone (bullish=Support unter, "
    "bearish=Widerstand über dem Kurs). Ist 'valid'=false oder die Struktur "
    "unklar, ignoriere die Welle und entscheide nach dem Wellen-Signal. EW ist "
    "Heuristik — nenne den Bezug in 'reasoning' nur, wenn er klar ist.\n\n"
    "S/R-Level ('zones' → 'resistances'/'supports' mit 'price' und 'dist'): das "
    "sind die ECHTEN, aktuell berechneten M5-Pivot-Level (dieselben wie im Chart). "
    "⚠ WICHTIG: Nenne in deiner Antwort AUSSCHLIESSLICH diese übergebenen Preise. "
    "ERFINDE KEINE eigenen runden Marken (nicht '80 $'/'85 $', wenn sie nicht in "
    "der Liste stehen) und runde die Level nicht. Kurs nahe einer Resistance "
    "('dist' klein) → Abprall/Short möglich; nahe einem Support → Bounce/Long "
    "möglich. Level sind Reaktionsbereiche, kein Selbstläufer.\n\n"
    "Beachte die Börsen-Session ('session'): direkt nach einem Open "
    "('just_opened' gesetzt, Frankfurt 9:00 / US 15:00) ist der Markt volatil "
    "und whipsaw-anfällig — sei vorsichtiger (Konfidenz eher senken). In aktiver "
    "US-/DE-Session ('active') gibt es mehr Liquidität und klarere Trends; "
    "außerhalb (dünn) ist Vorsicht angebracht.\n\n"
    "Antworte AUSSCHLIESSLICH mit einem JSON-Objekt in genau dieser Form "
    "(deutsche Texte):\n"
    "{\n"
    '  "bias": "LONG" | "SHORT" | "NEUTRAL",\n'
    '  "confidence": <0-100>,\n'
    '  "timeframe": "M1" | "M5" | "M15" | "M30" | "H1",\n'
    '  "headline": "<ein prägnanter Satz>",\n'
    '  "reasoning": "<2-4 Sätze Begründung>",\n'
    '  "risks": ["<Risiko 1>", "<Risiko 2>"],\n'
    '  "position_note": "<Hinweis zur offenen Position, sonst leer>"\n'
    "}\n"
    "Kein Markdown, keine Code-Fences, nur das JSON.\n"
    "SPRACHE: Alle Texte (headline, reasoning, risks, position_note) MÜSSEN auf "
    "DEUTSCH sein. Verwende ausschließlich lateinische Buchstaben — KEINE "
    "chinesischen, japanischen oder kyrillischen Zeichen, kein Englisch."
)

def _has_cjk(rec: dict) -> bool:
    """True, wenn die Text-Felder chinesische/CJK-Zeichen enthalten (Modell hat die
    Deutsch-Vorgabe ignoriert — v.a. bei lokalen Qwen-Modellen)."""
    txt = " ".join(str(rec.get(k, "")) for k in
                   ("headline", "reasoning", "position_note"))
    txt += " ".join(str(x) for x in (rec.get("risks") or []))
    return any("一" <= c <= "鿿" for c in txt)


def _req_conn_errors() -> tuple:
    """Exception-Klassen für „Anbieter nicht erreichbar". `requests` wird im Modul
    absichtlich nur lazy importiert (die Provider-Aufrufe tun das ebenso), deshalb
    hier zur Laufzeit auflösen. Fehlt requests, greift der Zweig einfach nicht."""
    try:
        import requests
        return (requests.exceptions.ConnectionError,)
    except Exception:
        return ()


# JSON-Schema für strukturierte Ausgabe (Claude: output_config.format erzwingt es)
_SCHEMA = {
    "type": "object",
    "properties": {
        "bias":          {"type": "string", "enum": ["LONG", "SHORT", "NEUTRAL"]},
        "confidence":    {"type": "integer"},
        "timeframe":     {"type": "string",
                          "enum": ["M1", "M5", "M15", "M30", "H1"]},
        "headline":      {"type": "string"},
        "reasoning":     {"type": "string"},
        "risks":         {"type": "array", "items": {"type": "string"}},
        "position_note": {"type": "string"},
    },
    "required": ["bias", "confidence", "timeframe", "headline", "reasoning",
                 "risks", "position_note"],
    "additionalProperties": False,
}


class TradingAgent:
    def __init__(self, cfg, *, data, trader, trail, tu, wave, history,
                 news, elliott=None, engine=None):
        # `engine` (optional, 2026-08-02): Zugriff auf die kalibrierten Größen
        # P(break) und Kegel, die die Engine ohnehin je Snapshot berechnet.
        # Optional gehalten, damit Tests/Standalone-Nutzung ohne Engine laufen.
        self.engine  = engine
        self.cfg     = cfg
        self.data    = data
        self.trader  = trader
        self.trail   = trail
        self.tu      = tu
        self.wave    = wave
        self.history = history
        self.news    = news
        self.elliott = elliott

        ac = cfg["agent"] if cfg.has_section("agent") else {}
        self.provider      = (ac.get("provider", "local") or "local").lower()
        self._model_cfg    = ac.get("model", "") or ""
        self.interval_min  = max(1, int(ac.get("refresh_min", "5") or 5))
        self.auto_enabled  = (ac.get("enabled", "true") or "true").lower() == "true"
        self.tg_push       = (ac.get("telegram", "false") or "false").lower() == "true"

        self._lock  = threading.Lock()
        # Provider → Zeitpunkt, bis zu dem er als tot gilt (s. `_mark_dead`).
        # Absichtlich OHNE `self._lock`: `_active_provider` wird aus `snapshot()`
        # heraus aufgerufen, das den Lock bereits hält (nicht reentrant → Deadlock).
        # dict-get/set sind unter dem GIL atomar, mehr braucht es hier nicht.
        self._dead: dict[str, float] = {}
        self._busy  = False
        self._last: dict | None = None   # letzte Beurteilung (geparst)
        self._error: str | None = None
        self._ts: float | None = None

    # ── Provider-Konfiguration ───────────────────────────────────────────────
    def _key(self, section: str) -> str:
        try:
            return (self.cfg[section]["api_key"] or "").strip()
        except Exception:
            return ""

    def _anthropic_key(self) -> str:
        return self._key("anthropic")

    def _gemini_key(self) -> str:
        return self._key("gemini")

    def _openai_key(self) -> str:
        return self._key("openai")

    def _local_model(self) -> str:
        try:
            return (self.cfg["ollama"]["model"] or "").strip()
        except Exception:
            return ""

    # ── Ausfall-Erkennung (2026-08-05) ───────────────────────────────────────
    # ⚠ ANLASS: `avail` unten prüft nur das Key-FORMAT. Ein Key OHNE GUTHABEN gilt
    # damit als „verfügbar" und blockiert die gesamte Fallback-Kette. Real lief der
    # Copilot ~18 h ins Leere (DeepSeek `HTTP 402 Insufficient Balance`, 29 Fehl-
    # versuche ab 04.08. 14:52) — sichtbar nur als WARNING-Zeile, ohne dass je auf
    # zai/kimi umgeschaltet wurde. `_dead` schliesst diese Lücke: ein hart
    # gescheiterter Provider wird zeitweise übersprungen.
    _DEAD_HARD_S = 1800.0    # 401/402/403/404 = Key/Guthaben/Modell kaputt → 30 min
    _DEAD_CONN_S = 300.0     # nicht erreichbar (z. B. Ollama aus) → 5 min
    # BEWUSST NICHT als tot markiert: Timeout, 429 und 5xx. Das sind vorübergehende
    # Zustände; wer darauf den Provider wechselt, verliert bei jeder Lastspitze den
    # konfigurierten (i. d. R. besten) Anbieter.

    def _mark_dead(self, prov: str, exc: Exception) -> str | None:
        """Klassifiziert einen Fehlschlag. → Grundtext, wenn als tot markiert."""
        code = getattr(getattr(exc, "response", None), "status_code", None)
        if code in (401, 402, 403, 404):
            grund = {401: "Key ungültig", 402: "kein Guthaben",
                     403: "Zugriff verweigert", 404: "Modell/Endpoint fehlt"}[code]
            dauer, txt = self._DEAD_HARD_S, f"HTTP {code} ({grund})"
        elif isinstance(exc, _req_conn_errors()):
            dauer, txt = self._DEAD_CONN_S, "nicht erreichbar"
        else:
            return None
        self._dead[prov] = time.time() + dauer
        return f"{txt} — für {dauer/60:.0f} min übersprungen"

    def _provider_chain(self) -> list:
        """Die Fallback-Reihenfolge, OHNE aktuell tote Provider."""
        return [p for p in self._chain_raw() if self._dead.get(p, 0.0) <= time.time()]

    def _active_provider(self) -> str | None:
        """Erster verfügbarer Provider der Kette (tote übersprungen)."""
        ch = self._provider_chain()
        return ch[0] if ch else None

    def _chain_raw(self) -> list:
        """Bevorzugt den konfigurierten Provider, fällt sonst der Reihe nach
        auf einen verfügbaren zurück. 'local' (Ollama) gilt als verfügbar,
        sobald ein Modellname gesetzt ist — Erreichbarkeit wird erst beim
        Aufruf geprüft (kein Live-Probe im häufig aufgerufenen Pfad)."""
        avail = {
            "local":  bool(self._local_model()),
            "claude": self._anthropic_key().startswith("sk-ant"),
            "gemini": self._gemini_key().startswith("AIza"),
            "openai": self._openai_key().startswith("sk-"),
            "zai":      bool(self._key("zai")),
            "kimi":     self._key("kimi").startswith("sk-"),
            "deepseek": self._key("deepseek").startswith("sk-"),
        }
        default = ["local", "claude", "gemini", "openai"]
        orders = {
            "local":    ["local", "claude", "gemini", "openai"],
            "claude":   ["claude", "local", "gemini", "openai"],
            "gemini":   ["gemini", "openai", "claude", "local"],
            "openai":   ["openai", "gemini", "claude", "local"],
            "zai":      ["zai", "kimi", "deepseek", "local"],
            "kimi":     ["kimi", "deepseek", "zai", "local"],
            "deepseek": ["deepseek", "kimi", "zai", "local"],
        }
        return [p for p in orders.get(self.provider, default) if avail[p]]

    def is_configured(self) -> bool:
        return self._active_provider() is not None

    # ── Read-Tools (lesen vorhandene Snapshots) ──────────────────────────────
    def _tool_market(self, wsig: dict | None = None) -> dict:
        wsig = wsig if wsig is not None else self.wave.signal()
        wsnap = self.wave.snapshot()
        # TU entfernt (2026-07-08): war nach der TU-Entfernung aus Empfehlung/Verdict
        # die letzte Hintertür — der Copilot ist eine Verdict-Stimme, TU floss so
        # indirekt wieder ein (lagging, nicht backtestbar).
        out = {
            "wave_signal":     wsig.get("signal"),
            "wave_confidence": wsig.get("conf_pct"),
            "wave_setup":      wsig.get("setup"),
            "wave_tf":         wsnap.get("tf"),
            "wave_direction":  wsnap.get("direction"),
            "wave_move_atr":   wsnap.get("move_atr"),
            "wave_reasons":    wsig.get("reasons", [])[:4],
            "session":         _agent_session(),
        }
        # ── Die kalibrierten Größen dazugeben (2026-08-02) ────────────────────
        # ⚠ Bis hierher sah der Copilot NUR Wellen-Signal + Session. Er war damit
        # das schlechtest informierte Modul im System — und ihm fehlten
        # ausgerechnet die beiden Komponenten, deren Kalibrierung out-of-sample
        # nachgewiesen ist (P(break) AUC 0,65 / Kegel 77 % reale Abdeckung).
        # Fail-safe: fehlt die Engine oder wirft ein Teil, bleibt das Feld weg —
        # ein fehlender Kontext darf die Analyse nie verhindern.
        eng = getattr(self, "engine", None)
        if eng is not None:
            try:
                # ⚠ `p_break` steht im Snapshot BEREITS in Prozent (3 = 3 %,
                # deckungsgleich mit der Chart-Zeile „97 % Abprall") — NICHT
                # nochmal ×100 rechnen. Beim Bau zuerst falsch angenommen und
                # an den Live-Daten korrigiert.
                hint = getattr(eng, "_last_sr_close_hint", None) or {}
                if hint.get("p_break") is not None:
                    out["p_break_target"] = round(float(hint["p_break"]))
                    out["p_break_target_level"] = hint.get("level")
            except Exception:
                pass
            try:
                stop = getattr(eng, "_last_stop_approach", None) or {}
                if stop.get("p_break") is not None:
                    out["p_break_stop"] = round(float(stop["p_break"]))
                    out["p_break_stop_level"] = stop.get("level")
            except Exception:
                pass
            try:
                cone = getattr(eng, "_last_cone", None) or {}
                lv = [l for l in (cone.get("levels") or []) if l.get("band") == 80]
                if lv:
                    out["cone"] = [{"minutes": l["minutes"], "lo": l["lo"], "hi": l["hi"],
                                    "abdeckung_real_pct": l.get("coverage")} for l in lv]
            except Exception:
                pass
        return out

    def _tool_position(self) -> dict:
        ps = self.trader.snapshot()
        if ps.get("ticket") is None:
            return {"open": False}
        s = self.data.snapshot()
        live = None
        if s.get("bid") and s.get("ask"):
            live = self.trader.live_pnl(s["bid"], s["ask"])
        ts = self.trail.snapshot()
        return {
            "open":      True,
            "direction": "LONG" if ps.get("order_type") == 0 else "SHORT",
            "lots":      round(ps.get("lots") or 0.0, 2),
            "entry":     round(ps.get("entry_price") or 0.0, 3),
            "pnl":       round((live if live is not None else ps.get("pnl") or 0.0), 2),
            "trailing":  ts.get("enabled"),
            "trail_phase": ts.get("phase"),
        }

    def _tool_account(self) -> dict:
        s = self.data.snapshot()
        return {
            "symbol":   s.get("symbol"),
            "bid":      s.get("bid"), "ask": s.get("ask"),
            "spread":   s.get("spread"),
            "change":   s.get("change"), "pct": s.get("pct"),
            "balance":  s.get("balance"), "equity": s.get("equity"),
            "rsi_m15":  round(s["rsi_m15"], 1) if s.get("rsi_m15") else None,
            "atr_m15":  round(s["atr_m15"], 3) if s.get("atr_m15") else None,
            "angles":   {k: round(v) for k, v in (s.get("angles") or {}).items()},
            "reversal": s.get("reversal"),
        }

    def _tool_performance(self, cur_setup: str = "") -> dict:
        out: dict = {}
        # cur_setup: aktuelles Wellen-Setup, um seine Historie hervorzuheben
        for period in ("today", "week"):
            try:
                t = self.history.stats_overview(period)
                out[period] = {"trades": t["n_trades"], "winrate": round(t["winrate"]),
                               "pnl": round(t["total_pnl"], 2),
                               "profit_factor": (round(t["profit_factor"], 2)
                                                 if t["profit_factor"] else None)}
            except Exception:
                pass
        try:
            last = self.history.last_closed_trades(5)
            out["last_trades"] = [
                {"dir": r["direction"], "pnl": round(r["pnl"] or 0, 2),
                 "by": r["closed_by"], "setup": r.get("setup")}
                for r in last]
        except Exception:
            pass
        try:
            dry = self.history.intended_summary("today")
            out["dry_run_today"] = {"n": dry["n"], "winrate": round(dry["winrate"])}
        except Exception:
            pass
        # Setup-Historie: alle (gefiltert) + das aktuell anstehende Setup separat,
        # damit das Modell seine Konfidenz an der echten Bilanz kalibrieren kann
        try:
            ss = self.history.setup_stats("all")
            out["by_setup"] = [
                {"setup": s["setup"], "n": s["n"], "winrate": round(s["winrate"]),
                 "avg_pnl": round(s["avg_pnl"], 2)}
                for s in ss if s["n"] >= 3][:6]
            match = next((s for s in ss if s["setup"] == cur_setup), None)
            if match and cur_setup:
                out["current_setup_history"] = {
                    "setup": cur_setup, "n": match["n"],
                    "winrate": round(match["winrate"]),
                    "avg_pnl": round(match["avg_pnl"], 2),
                    "total_pnl": round(match["total_pnl"], 2)}
            elif cur_setup:
                out["current_setup_history"] = {
                    "setup": cur_setup, "n": 0, "note": "noch keine Trades"}
        except Exception:
            pass
        return out

    def _tool_news(self) -> dict:
        try:
            ns = self.news.sentiment_snapshot()
            return {"score": ns.get("score"), "label": ns.get("label"),
                    "drivers": ns.get("drivers", [])[:3]}
        except Exception:
            return {}

    def _tool_elliott(self) -> dict:
        """Elliott-Wave-/FVG-Heuristik (plausibel, nicht sicher)."""
        if not self.elliott:
            return {}
        s = self.elliott.snapshot()
        if s.get("stale") or s.get("pattern") in (None, "unclear"):
            return {"struktur": "keine klare Welle erkennbar"}
        return {
            "tf":           s.get("tf"),
            "pattern":      s.get("pattern"),
            "wave":         s.get("wave"),
            "valid":        s.get("valid"),
            "target":       s.get("target"),
            "target_label": s.get("target_label"),
            "exhaustion":   s.get("exhaustion"),
            "invalidation": s.get("invalidation"),
            "fvg":          s.get("fvg"),
            "note":         s.get("note"),
        }

    def _tool_zones(self) -> dict:
        """ECHTE S/R-Level = geclusterte M5-Pivots (`wave.pb_levels`, dieselbe Quelle
        wie Chart/Dashboard/Auto-Close) — NICHT mehr die stale [zones]-Config (2026-
        07-15). Nächste ~3 Widerstände über / ~3 Unterstützungen unter dem Kurs."""
        ws = self.wave.snapshot() or {}
        lv = ws.get("pb_levels") or {}
        atr = (ws.get("pb_feats") or {}).get("atr") or 0.2
        cur = (self.data.snapshot() or {}).get("bid")
        if not cur or not (lv.get("ph") or lv.get("pl")):
            return {}

        def cluster(vals):
            out = []
            for v in sorted(vals):
                if out and v - out[-1][-1] <= 0.5 * atr:
                    out[-1].append(v)
                else:
                    out.append([v])
            return [round(sum(g) / len(g), 3) for g in out]

        ph = cluster(lv.get("ph") or [])
        pl = cluster(lv.get("pl") or [])
        res = [{"price": p, "dist": round(p - cur, 3)} for p in ph if p > cur][:3]
        sup = [{"price": p, "dist": round(cur - p, 3)}
               for p in reversed(pl) if p < cur][:3]
        return {"current_price": round(cur, 3),
                "resistances": res, "supports": sup}

    def _gather_context(self) -> dict:
        try:
            wsig = self.wave.signal()
        except Exception:
            wsig = {}
        return {
            "market":      self._tool_market(wsig),
            "elliott":     self._tool_elliott(),
            "zones":       self._tool_zones(),
            "position":    self._tool_position(),
            "account":     self._tool_account(),
            "performance": self._tool_performance(wsig.get("setup", "") or ""),
            "news":        self._tool_news(),
        }

    # ── LLM-Aufruf (Provider-Dispatch) ───────────────────────────────────────
    def _call_gemini(self, prompt: str) -> str:
        import requests
        model = self._model_cfg or (self.cfg["gemini"].get("model")
                                    or "gemini-2.0-flash")
        resp = requests.post(
            _GEMINI_URL.format(model=model),
            headers={"x-goog-api-key": self._gemini_key(),
                     "Content-Type": "application/json"},
            json={"contents": [{"role": "user",
                                "parts": [{"text": _SYS + "\n\n" + prompt}]}],
                  "generationConfig": {"temperature": 0.3, "maxOutputTokens": 700}},
            timeout=60)
        resp.raise_for_status()
        data = resp.json()
        return data["candidates"][0]["content"]["parts"][0]["text"]

    def _call_openai(self, prompt: str) -> str:
        import requests
        model = self._model_cfg or "gpt-4o-mini"
        resp = requests.post(
            _OPENAI_URL,
            headers={"Authorization": f"Bearer {self._openai_key()}",
                     "Content-Type": "application/json"},
            json={"model": model,
                  "messages": [{"role": "system", "content": _SYS},
                               {"role": "user", "content": prompt}],
                  "temperature": 0.3, "max_tokens": 700},
            timeout=60)
        resp.raise_for_status()
        return resp.json()["choices"][0]["message"]["content"]

    def _call_zai(self, prompt: str) -> str:
        # z.ai/GLM (OpenAI-kompatibel). Thinking AUS (sonst geht das Budget ins
        # „Denken" und content bleibt leer). Web-Suche hier NICHT nötig (analysiert
        # nur den Snapshot). CJK-Drift-Schutz via _has_cjk-Retry im Aufrufer.
        import requests
        zc = self.cfg["zai"] if self.cfg.has_section("zai") else {}
        base = (zc.get("base_url") or "https://api.z.ai/api/paas/v4").rstrip("/")
        model = self._model_cfg or (zc.get("model") or "glm-4.5-flash")
        resp = requests.post(
            base + "/chat/completions",
            headers={"Authorization": "Bearer " + (zc.get("api_key") or "").strip()},
            json={"model": model,
                  "messages": [{"role": "system", "content": _SYS},
                               {"role": "user", "content": prompt}],
                  "thinking": {"type": "disabled"},
                  "temperature": 0.3, "max_tokens": 900},
            timeout=90)
        resp.raise_for_status()
        return (resp.json()["choices"][0]["message"].get("content") or "").strip()

    def _call_kimi(self, prompt: str) -> str:
        # Kimi / Moonshot AI (OpenAI-kompatibel, Endpoint .ai). kimi-k2.6 ist ein
        # Reasoning-Modell: es schreibt VARIABLE „reasoning_tokens" (real 1000–1200
        # beim echten _SYS) in message.reasoning_content VOR dem eigentlichen
        # `content` → max_tokens muss großzügig sein, sonst frisst das Reasoning das
        # Budget und content bleibt leer (gemessen: 2000 = teils leer, 4000 = ok).
        # temperature MUSS 1 sein (Modell-Vorgabe, andere Werte → 400). CJK-Drift-
        # Schutz via _has_cjk-Retry im Aufrufer (wie zai/local — Kimi ist CN-Modell).
        import requests
        kc = self.cfg["kimi"] if self.cfg.has_section("kimi") else {}
        base = (kc.get("base_url") or "https://api.moonshot.ai/v1").rstrip("/")
        model = self._model_cfg or (kc.get("model") or "kimi-k2.6")
        resp = requests.post(
            base + "/chat/completions",
            headers={"Authorization": "Bearer " + (kc.get("api_key") or "").strip(),
                     "Content-Type": "application/json"},
            json={"model": model,
                  "messages": [{"role": "system", "content": _SYS},
                               {"role": "user", "content": prompt}],
                  "temperature": 1, "max_tokens": 4000},
            timeout=120)
        resp.raise_for_status()
        return (resp.json()["choices"][0]["message"].get("content") or "").strip()

    def _call_deepseek(self, prompt: str) -> str:
        # DeepSeek (OpenAI-kompatibel, api.deepseek.com). deepseek-v4-flash ist ein
        # Reasoning-Modell (content nach reasoning_content) → max_tokens großzügig,
        # sonst content leer. temperature 0.3 ok. CJK-Drift-Schutz im Aufrufer.
        # ⚠ 4000 → 8000 (2026-08-02): bei 4000 brach das JSON regelmäßig MITTEN im
        # Text ab („Unterminated string …"), real 2× in ~14 Zyklen ≈ 14 % stille
        # Ausfälle — das Reasoning frisst das Budget, bevor der content fertig ist.
        # Ein Fehlschlag verwirft die Analyse und lässt die ALTE Empfehlung stehen,
        # ist also von außen kaum sichtbar. Mit dem ergänzten P(break)/Kegel-Kontext
        # wird die Antwort eher länger, nicht kürzer.
        import requests
        dc = self.cfg["deepseek"] if self.cfg.has_section("deepseek") else {}
        base = (dc.get("base_url") or "https://api.deepseek.com").rstrip("/")
        model = self._model_cfg or (dc.get("model") or "deepseek-v4-flash")
        resp = requests.post(
            base + "/chat/completions",
            headers={"Authorization": "Bearer " + (dc.get("api_key") or "").strip(),
                     "Content-Type": "application/json"},
            json={"model": model,
                  "messages": [{"role": "system", "content": _SYS},
                               {"role": "user", "content": prompt}],
                  "temperature": 0.3, "max_tokens": 8000},
            timeout=120)
        resp.raise_for_status()
        return (resp.json()["choices"][0]["message"].get("content") or "").strip()

    def _call_ollama(self, prompt: str) -> str:
        # Lokales LLM via Ollama (/api/chat). `format`=JSON-Schema erzwingt
        # strukturierte Ausgabe. `keep_alive` hält das Modell zwischen den
        # 5-Min-Ticks resident auf der GPU — sonst entlädt Ollama nach 5 min
        # und lädt neu (Risiko: CPU-Rückfall bei knappem VRAM). Großzügiger
        # Timeout, da CPU-Inferenz langsam ist.
        import requests
        oc = self.cfg["ollama"]
        base = (oc.get("base_url") or "http://localhost:11434").rstrip("/")
        model = self._model_cfg or (oc.get("model") or "qwen2.5:7b")
        keep_alive = oc.get("keep_alive") or "30m"
        resp = requests.post(
            f"{base}/api/chat",
            json={"model": model, "stream": False, "format": _SCHEMA,
                  "keep_alive": keep_alive,
                  "options": {"temperature": 0.3},
                  "messages": [{"role": "system", "content": _SYS},
                               {"role": "user", "content": prompt}]},
            timeout=180)
        resp.raise_for_status()
        return resp.json()["message"]["content"]

    def _call_claude(self, prompt: str) -> str:
        # Offizielles anthropic-SDK. Adaptives Thinking (für die Abwägung der
        # Signale) + erzwungenes JSON via output_config.format; effort=low, da
        # es eine kurze Routine-Beurteilung alle paar Minuten ist.
        import anthropic
        model = self._model_cfg or (self.cfg["anthropic"].get("model")
                                    or "claude-opus-4-8")
        client = anthropic.Anthropic(api_key=self._anthropic_key())
        resp = client.messages.create(
            model=model,
            max_tokens=4096,
            system=_SYS,
            thinking={"type": "adaptive"},
            output_config={"effort": "low",
                           "format": {"type": "json_schema", "schema": _SCHEMA}},
            messages=[{"role": "user", "content": prompt}],
        )
        if resp.stop_reason == "refusal":
            raise RuntimeError("Claude-Refusal (Sicherheits-Klassifikator)")
        # output_config.format garantiert: erster text-Block ist valides JSON
        return next((b.text for b in resp.content if b.type == "text"), "")

    @staticmethod
    def _parse(text: str) -> dict:
        """Robustes JSON-Parsing (Code-Fences/Prosa drumherum tolerieren)."""
        t = text.strip()
        if "{" in t and "}" in t:
            t = t[t.index("{"): t.rindex("}") + 1]
        data = json.loads(t)
        bias = str(data.get("bias", "NEUTRAL")).upper()
        if bias not in ("LONG", "SHORT", "NEUTRAL"):
            bias = "NEUTRAL"
        tf = str(data.get("timeframe", "M5")).upper().strip()
        if tf not in ("M1", "M5", "M15", "M30", "H1"):
            tf = "M5"
        return {
            "bias":          bias,
            "confidence":    int(data.get("confidence", 0) or 0),
            "timeframe":     tf,
            "headline":      str(data.get("headline", "")).strip(),
            "reasoning":     str(data.get("reasoning", "")).strip(),
            "risks":         [str(r) for r in (data.get("risks") or [])][:4],
            "position_note": str(data.get("position_note", "")).strip(),
        }

    # ── Hauptlauf (blockierend — im Hintergrund-Thread aufrufen) ─────────────
    def _call(self, prov: str, prompt: str) -> str:
        return (self._call_ollama(prompt) if prov == "local"
                else self._call_zai(prompt) if prov == "zai"
                else self._call_kimi(prompt) if prov == "kimi"
                else self._call_deepseek(prompt) if prov == "deepseek"
                else self._call_claude(prompt) if prov == "claude"
                else self._call_openai(prompt) if prov == "openai"
                else self._call_gemini(prompt))

    def analyze(self) -> bool:
        if self._active_provider() is None:
            with self._lock:
                self._error = ("Kein Provider (config.ini [ollama] model / "
                               "[anthropic] / [gemini] / [openai])")
            return False
        with self._lock:
            if self._busy:
                return False
            self._busy = True
        prov = None
        try:
            ctx = self._gather_context()
            prompt = ("Aktueller Systemzustand (JSON):\n"
                      + json.dumps(ctx, ensure_ascii=False, indent=1)
                      + "\n\nGib deine Beurteilung als JSON zurück. "
                        "Alle Texte auf DEUTSCH, nur lateinische Schrift "
                        "(keine chinesischen Zeichen).")
            # ── Fallback-Kette (2026-08-05): bei einem HARTEN Fehlschlag (Key/
            # Guthaben/Modell) den Provider markieren und den NÄCHSTEN nehmen.
            # ⚠ Nur bei hart — nach Timeout/429/5xx wird abgebrochen statt die
            # ganze Kette durchzuprobieren, sonst kostet eine Lastspitze bei jedem
            # Zyklus die volle Timeout-Summe aller Anbieter (hier bis 90–120 s je
            # Stück) und der bevorzugte Anbieter geht unnötig verloren.
            raw = None
            for kandidat in self._provider_chain():
                prov = kandidat
                try:
                    raw = self._call(prov, prompt)
                    break
                except Exception as e:
                    grund = self._mark_dead(prov, e)
                    if grund is None:
                        raise                       # vorübergehend → nicht wechseln
                    naechster = next(iter(self._provider_chain()), None)
                    log.warning(f"Provider '{prov}' ausgefallen: {grund}"
                                + (f" — weiter mit '{naechster}'" if naechster
                                   else " — KEIN Provider mehr verfügbar"))
            if raw is None:
                with self._lock:
                    self._error = "alle Provider ausgefallen (Key/Guthaben prüfen)"
                return False
            parsed = self._parse(raw)
            # CN-/lokale Modelle (Qwen, z.ai, Kimi, DeepSeek) driften gelegentlich ins
            # Chinesische → einmal mit verschärfter Anweisung neu versuchen.
            if prov in ("local", "zai", "kimi", "deepseek") and _has_cjk(parsed):
                log.warning(f"[{prov}] CJK-Zeichen erkannt — Wiederholung auf Deutsch")
                retry = (self._call_zai if prov == "zai"
                         else self._call_kimi if prov == "kimi"
                         else self._call_deepseek if prov == "deepseek"
                         else self._call_ollama)
                raw = retry(prompt + "\n\nACHTUNG: Schreibe AUSSCHLIESSLICH auf "
                            "DEUTSCH, NUR lateinische Buchstaben, KEINE chinesischen "
                            "Zeichen.")
                p2 = self._parse(raw)
                if not _has_cjk(p2):
                    parsed = p2
            with self._lock:
                self._last  = parsed
                self._error = None
                self._ts    = time.time()
            log.info(f"[{prov}] {parsed['bias']} ({parsed['confidence']}%) — "
                     f"{parsed['headline'][:80]}")
            return True
        except Exception as e:
            with self._lock:
                self._error = str(e)[:140]
            log.warning(f"Agent-Analyse fehlgeschlagen ({prov}): {e}")
            return False
        finally:
            with self._lock:
                self._busy = False

    def snapshot(self) -> dict:
        with self._lock:
            return {
                "advisory":    dict(self._last) if self._last else None,
                "error":       self._error,
                "last_update": self._ts,
                "busy":        self._busy,
                "provider":    self._active_provider(),
                "configured":  self.is_configured(),
                # Aktuell übersprungene Anbieter + Restminuten. Macht den Ausfall
                # sichtbar, statt ihn (wie bisher) nur ins Log zu schreiben — genau
                # das hat die 18 h DeepSeek-Ausfall unbemerkt bleiben lassen.
                "provider_dead": {p: round((t - time.time()) / 60.0, 1)
                                  for p, t in self._dead.items() if t > time.time()},
            }
