Files
Axel HocksandClaude Opus 5 cc4f1a0863 Provider-Failover bei hartem Fehlschlag + Einsatz-Prozentfeld im Dashboard
1) LLM-Failover (core/agent.py): ein hart gescheiterter Provider wird zeitweise
uebersprungen, analyze() nimmt den naechsten aus der Kette. Hart = 401/402/403/
404 (Key/Guthaben/Modell) -> 30 min, nicht erreichbar -> 5 min. Timeout/429/5xx
loesen BEWUSST keinen Wechsel aus (voruebergehend; sonst kostet jede Lastspitze
die volle Timeout-Summe aller Anbieter). Neu im Snapshot: agent.provider_dead
mit Restminuten - der DeepSeek-Ausfall stand vorher nur im Log und blieb
deshalb 18 h unbemerkt. 9 Szenarien getestet.

2) Einsatz-Prozentfeld (web, v=137): margin_buffer_pct hatte bisher keine UI.
Neues Feld "Einsatz %" neben "Einsatz EUR", POST /api/marginpct ->
engine.set_margin_pct -> config.set_margin_buffer, Snapshot margin_pct,
neustart-fest. Der feste EUR-Betrag hat Vorrang; das Prozentfeld wird dann
ausgegraut, damit nicht unklar bleibt was gilt. Auf 1-99 % geklemmt.
Ende-zu-Ende getestet (50, 150->99, 0 und -5 abgelehnt, 95, persistiert).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 08:38:17 +02:00

772 lines
38 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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,680,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 10001200
# 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 90120 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()},
}