""" 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": "",\n' ' "reasoning": "<2-4 Sätze Begründung>",\n' ' "risks": ["", ""],\n' ' "position_note": ""\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) # 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() 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 "" def _active_provider(self) -> str | None: """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"], } for p in orders.get(self.provider, default): if avail[p]: return p return None 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 analyze(self) -> bool: prov = self._active_provider() if prov 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 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).") raw = (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)) 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(), }