Files
AH-Oil-Trader/core/agent.py
T
Axel HocksandClaude Opus 5 00c78201fa KI-Copilot: zwei Prompt-Fehler behoben + kalibrierte Groessen ergaenzt
Diagnose zur Frage "Bias fast immer neutral": es ist NICHT der Prompt, es ist
der Provider. Bruch exakt am 20.07. (DeepSeek-Wechsel) - bis 19.07. war der
Copilot zu 42-90 % gerichtet, ab 21.07. nur noch zu 1,5-9,3 %. Der Prompt war
unveraendert. Und er hedged aus KORREKTER Schlussfolgerung: ~90 % WARTEN-Signal,
negativer Track-Record, plus die Prompt-Anweisung, genau dann NEUTRAL zu sagen.

Gemessen, ob mehr Meinung ueberhaupt Wert haette: KI-LONG n=286 +0,29 $ / 76 %
Treffer, KI-SHORT n=93 -0,02 $ / 55 %. ABER die 286 sind 20 Episoden an 5 Tagen
(Minutentakt-Logger), die 93 sind 16 Episoden -> effektiv n~20 gegen n~16, ein
Regime. Kein Beleg. Deshalb BEWUSST nicht am Prompt gedreht, um Meinung zu
erzwingen; Verdict-Gewicht unveraendert.

Zwei echte Prompt-Fehler behoben (Korrektheit, keine Meinungsmache):
- _SYS beschrieb das Wellen-Signal als "ATR-ZigZag" - es ist EMA12 vs EMA50
- _SYS nannte "TradersUnion-Tachos" als Input - TU ist seit 19.07. raus

Kalibrierte Groessen ergaenzt: _tool_market gab dem Copiloten NUR Wellen-Signal
und Session. Neu: p_break_target / p_break_stop (AUC 0,65 bzw. 0,68-0,72) und
cone (80%-Baender mit der REAL gemessenen Abdeckung 77 %), plus Lese-Anleitung
im Prompt (45-55 % explizit als Muenzwurf markiert).
Zwei Fallen an Live-Daten korrigiert: p_break steht bereits in PROZENT (die
zuerst gebaute x100-Umrechnung haette 300 erzeugt), und die Engine-Felder plus
die engine-Referenz im Agent existierten gar nicht - der Fail-safe haette still
nichts geliefert. Snapshot weist die Werte jetzt nur zu, rechnet sie nicht neu.

DeepSeek max_tokens 4000 -> 8000: das JSON brach regelmaessig mitten im Text ab
("Unterminated string"), real 2x in ~14 Zyklen = ~14 % stille Ausfaelle.

Kerzen/Muster bewusst NICHT eingespeist - die Lehrbuch-Lesart ist hier gemessen
invertiert (langer Koerper im Trend -0,132/-0,042; 2x Volumen -0,117/-0,204;
Muster-Kursziel trifft nur 13-38 %). Roh eingespeist wuerden sie schaden.

Verifiziert: Stub-Test von _tool_market (Werte unveraendert durchgereicht, nur
80%-Baender, ohne Engine kein Absturz); Prozess juenger als beide Dateien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:44:35 +02:00

692 lines
34 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 "
# ⚠ 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)
# 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 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 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(),
}