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>
This commit is contained in:
+615
@@ -0,0 +1,615 @@
|
||||
"""
|
||||
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 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
|
||||
# (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(),
|
||||
}
|
||||
Reference in New Issue
Block a user