Files
AH-Oil-Trader/core/agent.py
T
Axel HocksandClaude Opus 4.8 75d28827e8 Initial commit: Oil Trading Bot (MT5, WTI)
Headless FastAPI-Backend (server.py + core/engine.py) mit Mobile-PWA (web/),
Strategie-/Backtest-Suite und Doku. Secrets, DB, Logs und Laufzeit-State sind
via .gitignore ausgeschlossen; Config-Vorlage: oil_widget_config.ini.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 08:29:23 +02:00

616 lines
29 KiB
Python
Raw 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 "
"(Wellen-Signal als ATR-ZigZag, TradersUnion-Tachos, 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"
"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)
# 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):
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).
return {
"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(),
}
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
# (4000), sonst content leer. temperature 0.3 ok. CJK-Drift-Schutz im Aufrufer.
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": 4000},
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(),
}