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>
772 lines
38 KiB
Python
772 lines
38 KiB
Python
"""
|
||
core/agent.py — TradingAgent (Phase 1: read-only Copilot)
|
||
==========================================================
|
||
Ein KI-Copilot, der den kompletten Systemzustand liest, in Klartext
|
||
beurteilt und eine begründete Empfehlung gibt — OHNE etwas auszuführen.
|
||
Die schnellen, deterministischen Entscheidungen (Wellen-Signal, Trailing,
|
||
Emergency-Close) bleiben im Code; der Agent ist die langsame, denkende
|
||
Schicht darüber (Intervall ~5 min, nicht im Tick-Pfad).
|
||
|
||
Read-Tools (liefern vorhandene snapshot()-Methoden):
|
||
_tool_market → Wellen-Signal + TradersUnion-Tachos
|
||
_tool_position → offene Position + Live-P&L + Trailing-Phase
|
||
_tool_account → Symbol/Preis/Spread/Balance/Equity/RSI/ATR/Reversal
|
||
_tool_performance → Tages-/Wochen-Statistik + letzte Trades + Dry-Run
|
||
_tool_news → News-Sentiment
|
||
|
||
Phase 1 ruft die Tools deterministisch auf (ein LLM-Call pro Runde, schont
|
||
das Quota). Die saubere Tool-Trennung erlaubt in Phase 2 echtes
|
||
Function-Calling + Trade-Vorschläge.
|
||
|
||
Provider: lokales LLM via Ollama (Default — kein Key, kein Quota) oder Claude
|
||
(offizielles anthropic-SDK), Gemini/OpenAI als Fallback. Konfiguration in
|
||
oil_widget_config.ini ([ollama]/[anthropic]/[gemini]/[openai]).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
import json
|
||
import threading
|
||
import time
|
||
|
||
from core.logger import get_logger
|
||
from core.market_hours import session_state
|
||
|
||
log = get_logger("agent")
|
||
|
||
|
||
def _agent_session() -> dict:
|
||
"""Kompakter Session-Status für den Agent-Kontext."""
|
||
ss = session_state()
|
||
return {
|
||
"phase": ss["phase"],
|
||
"active": ss["active"],
|
||
"just_opened": ss["just_opened"],
|
||
"next_open": ss["next_open"],
|
||
}
|
||
|
||
_GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
|
||
_OPENAI_URL = "https://api.openai.com/v1/chat/completions"
|
||
|
||
_SYS = (
|
||
"Du bist ein nüchterner Trading-Copilot für WTI-Rohöl (Intraday-Scalping). "
|
||
"Du bekommst den aktuellen Systemzustand eines automatischen Handels-Bots "
|
||
# ⚠ KORRIGIERT 2026-08-02: hier stand „Wellen-Signal als ATR-ZigZag,
|
||
# TradersUnion-Tachos". BEIDES war falsch und hat das Modell in die Irre
|
||
# geführt: (a) die Richtung kommt seit Langem aus EMA12 vs. EMA50 — ZigZag
|
||
# wurde verworfen, weil es ~0 Edge hatte; (b) TradersUnion ist seit
|
||
# 2026-07-19 gar nicht mehr im Copilot-Kontext. Das Modell wurde also auf
|
||
# Daten hingewiesen, die es nie sieht, und hat die Natur des Hauptsignals
|
||
# missverstanden.
|
||
"(Wellen-Signal aus EMA12 gegen EMA50 mit Totband, Durchbruch-"
|
||
"Wahrscheinlichkeit am nächsten S/R-Level, erwartete Kursspanne, offene "
|
||
"Position, Konto, Track-Record, News). Beurteile die Lage knapp und ehrlich — keine "
|
||
"Garantien, keine Hype-Sprache. Wenn die Signale widersprüchlich oder dünn "
|
||
"sind, sag das klar und empfiehl NEUTRAL/abwarten.\n\n"
|
||
"WICHTIG — nutze den Track-Record zur Kalibrierung: 'current_setup_history' "
|
||
"zeigt, wie das aktuell anstehende Setup bisher real gelaufen ist, "
|
||
"'by_setup' die übrigen, 'week'/'today' die Gesamtbilanz. Hat das aktuelle "
|
||
"Setup eine schwache Trefferquote oder negative Durchschnitts-PnL (avg_pnl), "
|
||
"dämpfe deine Konfidenz deutlich oder empfiehl NEUTRAL — auch wenn das "
|
||
"Live-Signal stark wirkt. Hat es sich bewährt, darf das deine Konfidenz "
|
||
"stützen. Folge dem Signal nicht blind gegen eine klar negative Historie; "
|
||
"nenne den Bezug in 'reasoning' kurz (z.B. Setup-Trefferquote).\n\n"
|
||
# ── Die zwei EINZIGEN kalibriert geprüften Größen des Projekts ────────────
|
||
# ⚠ Ergänzt 2026-08-02. Der Copilot sah bis dahin NUR das Wellen-Signal und
|
||
# die Session — er war damit das schlechtest informierte Modul im System und
|
||
# bekam ausgerechnet die beiden Komponenten nicht, deren Treffsicherheit
|
||
# out-of-sample nachgewiesen ist. Die Lese-Anleitung ist Pflicht: ohne sie
|
||
# deutet ein LLM „P(break) 30 %" als schwaches Signal statt als klare
|
||
# Aussage „das Level hält mit 70 %".
|
||
"ZWEI KALIBRIERTE GRÖSSEN — nutze sie bevorzugt, sie sind die einzigen "
|
||
"Zahlen hier mit nachgewiesener Treffsicherheit:\n"
|
||
"1) 'p_break_target' / 'p_break_stop' = Wahrscheinlichkeit in PROZENT, dass "
|
||
"der Kurs das jeweilige S/R-Level DURCHBRICHT (Ziel-Level in Trade-Richtung, "
|
||
"Stop-Level dagegen). Out-of-sample geprüft (AUC 0,65 bzw. 0,68–0,72). "
|
||
"Lies sie richtig: 30 % Durchbruch heißt 70 % ABPRALL — das ist eine starke "
|
||
"Aussage, kein schwaches Signal. Ein Abprall am Ziel-Level deckelt den "
|
||
"Ertrag (spricht gegen einen Einstieg dorthin); ein Durchbruch am "
|
||
"Stop-Level ist eine Warnung für eine offene Position. Werte zwischen 45 "
|
||
"und 55 % sind ein Münzwurf — dann NICHT darauf argumentieren.\n"
|
||
"2) 'cone' = erwartete Kursspanne in 30/60/120 Minuten (80 %-Band). "
|
||
"Gemessene Abdeckung real 77 %, nicht 80 % — das Band ist eher etwas ZU "
|
||
"ENG. Es sagt NICHTS über die Richtung, nur über die plausible Weite. "
|
||
"Nutze es, um Ziele/Stops auf Realismus zu prüfen: liegt ein Kursziel "
|
||
"außerhalb des 120-min-Bands, ist es für einen Intraday-Trade unrealistisch.\n\n"
|
||
"Wähle außerdem den Analyse-Timeframe 'timeframe' für die Wellen-Erkennung "
|
||
"(M1|M5|M15|M30|H1): M1/M5 bei enger Range und ruhigem Markt (Scalping, "
|
||
"kleine Wellen); höhere TF (M15/M30/H1) bei klarem Trend, hoher Volatilität "
|
||
"oder wenn die niedrige TF laut Track-Record zu verrauscht ist (viele "
|
||
"Fehlsignale). Nimm den Timeframe, auf dem die Welle am klarsten und "
|
||
"verlässlichsten handelbar ist. Im Zweifel M5.\n\n"
|
||
"Beziehe die Elliott-Wave-Struktur ('elliott') ein, falls vorhanden und "
|
||
"valid=true: Steht der Kurs nahe einem projizierten Wellen-5-Ziel "
|
||
"(target/1.618) oder ist 'exhaustion'=true bzw. der Impuls vollendet, ist "
|
||
"der Trend ERSCHÖPFT — sei vorsichtig mit Einstiegen in Trendrichtung und "
|
||
"rechne mit einem Reversal (spricht für NEUTRAL oder Gegenrichtung). Ein "
|
||
"offener FVG ('fvg') ist eine Reaktionszone (bullish=Support unter, "
|
||
"bearish=Widerstand über dem Kurs). Ist 'valid'=false oder die Struktur "
|
||
"unklar, ignoriere die Welle und entscheide nach dem Wellen-Signal. EW ist "
|
||
"Heuristik — nenne den Bezug in 'reasoning' nur, wenn er klar ist.\n\n"
|
||
"S/R-Level ('zones' → 'resistances'/'supports' mit 'price' und 'dist'): das "
|
||
"sind die ECHTEN, aktuell berechneten M5-Pivot-Level (dieselben wie im Chart). "
|
||
"⚠ WICHTIG: Nenne in deiner Antwort AUSSCHLIESSLICH diese übergebenen Preise. "
|
||
"ERFINDE KEINE eigenen runden Marken (nicht '80 $'/'85 $', wenn sie nicht in "
|
||
"der Liste stehen) und runde die Level nicht. Kurs nahe einer Resistance "
|
||
"('dist' klein) → Abprall/Short möglich; nahe einem Support → Bounce/Long "
|
||
"möglich. Level sind Reaktionsbereiche, kein Selbstläufer.\n\n"
|
||
"Beachte die Börsen-Session ('session'): direkt nach einem Open "
|
||
"('just_opened' gesetzt, Frankfurt 9:00 / US 15:00) ist der Markt volatil "
|
||
"und whipsaw-anfällig — sei vorsichtiger (Konfidenz eher senken). In aktiver "
|
||
"US-/DE-Session ('active') gibt es mehr Liquidität und klarere Trends; "
|
||
"außerhalb (dünn) ist Vorsicht angebracht.\n\n"
|
||
"Antworte AUSSCHLIESSLICH mit einem JSON-Objekt in genau dieser Form "
|
||
"(deutsche Texte):\n"
|
||
"{\n"
|
||
' "bias": "LONG" | "SHORT" | "NEUTRAL",\n'
|
||
' "confidence": <0-100>,\n'
|
||
' "timeframe": "M1" | "M5" | "M15" | "M30" | "H1",\n'
|
||
' "headline": "<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 1000–1200
|
||
# beim echten _SYS) in message.reasoning_content VOR dem eigentlichen
|
||
# `content` → max_tokens muss großzügig sein, sonst frisst das Reasoning das
|
||
# Budget und content bleibt leer (gemessen: 2000 = teils leer, 4000 = ok).
|
||
# temperature MUSS 1 sein (Modell-Vorgabe, andere Werte → 400). CJK-Drift-
|
||
# Schutz via _has_cjk-Retry im Aufrufer (wie zai/local — Kimi ist CN-Modell).
|
||
import requests
|
||
kc = self.cfg["kimi"] if self.cfg.has_section("kimi") else {}
|
||
base = (kc.get("base_url") or "https://api.moonshot.ai/v1").rstrip("/")
|
||
model = self._model_cfg or (kc.get("model") or "kimi-k2.6")
|
||
resp = requests.post(
|
||
base + "/chat/completions",
|
||
headers={"Authorization": "Bearer " + (kc.get("api_key") or "").strip(),
|
||
"Content-Type": "application/json"},
|
||
json={"model": model,
|
||
"messages": [{"role": "system", "content": _SYS},
|
||
{"role": "user", "content": prompt}],
|
||
"temperature": 1, "max_tokens": 4000},
|
||
timeout=120)
|
||
resp.raise_for_status()
|
||
return (resp.json()["choices"][0]["message"].get("content") or "").strip()
|
||
|
||
def _call_deepseek(self, prompt: str) -> str:
|
||
# DeepSeek (OpenAI-kompatibel, api.deepseek.com). deepseek-v4-flash ist ein
|
||
# Reasoning-Modell (content nach reasoning_content) → max_tokens großzügig,
|
||
# sonst content leer. temperature 0.3 ok. CJK-Drift-Schutz im Aufrufer.
|
||
# ⚠ 4000 → 8000 (2026-08-02): bei 4000 brach das JSON regelmäßig MITTEN im
|
||
# Text ab („Unterminated string …"), real 2× in ~14 Zyklen ≈ 14 % stille
|
||
# Ausfälle — das Reasoning frisst das Budget, bevor der content fertig ist.
|
||
# Ein Fehlschlag verwirft die Analyse und lässt die ALTE Empfehlung stehen,
|
||
# ist also von außen kaum sichtbar. Mit dem ergänzten P(break)/Kegel-Kontext
|
||
# wird die Antwort eher länger, nicht kürzer.
|
||
import requests
|
||
dc = self.cfg["deepseek"] if self.cfg.has_section("deepseek") else {}
|
||
base = (dc.get("base_url") or "https://api.deepseek.com").rstrip("/")
|
||
model = self._model_cfg or (dc.get("model") or "deepseek-v4-flash")
|
||
resp = requests.post(
|
||
base + "/chat/completions",
|
||
headers={"Authorization": "Bearer " + (dc.get("api_key") or "").strip(),
|
||
"Content-Type": "application/json"},
|
||
json={"model": model,
|
||
"messages": [{"role": "system", "content": _SYS},
|
||
{"role": "user", "content": prompt}],
|
||
"temperature": 0.3, "max_tokens": 8000},
|
||
timeout=120)
|
||
resp.raise_for_status()
|
||
return (resp.json()["choices"][0]["message"].get("content") or "").strip()
|
||
|
||
def _call_ollama(self, prompt: str) -> str:
|
||
# Lokales LLM via Ollama (/api/chat). `format`=JSON-Schema erzwingt
|
||
# strukturierte Ausgabe. `keep_alive` hält das Modell zwischen den
|
||
# 5-Min-Ticks resident auf der GPU — sonst entlädt Ollama nach 5 min
|
||
# und lädt neu (Risiko: CPU-Rückfall bei knappem VRAM). Großzügiger
|
||
# Timeout, da CPU-Inferenz langsam ist.
|
||
import requests
|
||
oc = self.cfg["ollama"]
|
||
base = (oc.get("base_url") or "http://localhost:11434").rstrip("/")
|
||
model = self._model_cfg or (oc.get("model") or "qwen2.5:7b")
|
||
keep_alive = oc.get("keep_alive") or "30m"
|
||
resp = requests.post(
|
||
f"{base}/api/chat",
|
||
json={"model": model, "stream": False, "format": _SCHEMA,
|
||
"keep_alive": keep_alive,
|
||
"options": {"temperature": 0.3},
|
||
"messages": [{"role": "system", "content": _SYS},
|
||
{"role": "user", "content": prompt}]},
|
||
timeout=180)
|
||
resp.raise_for_status()
|
||
return resp.json()["message"]["content"]
|
||
|
||
def _call_claude(self, prompt: str) -> str:
|
||
# Offizielles anthropic-SDK. Adaptives Thinking (für die Abwägung der
|
||
# Signale) + erzwungenes JSON via output_config.format; effort=low, da
|
||
# es eine kurze Routine-Beurteilung alle paar Minuten ist.
|
||
import anthropic
|
||
model = self._model_cfg or (self.cfg["anthropic"].get("model")
|
||
or "claude-opus-4-8")
|
||
client = anthropic.Anthropic(api_key=self._anthropic_key())
|
||
resp = client.messages.create(
|
||
model=model,
|
||
max_tokens=4096,
|
||
system=_SYS,
|
||
thinking={"type": "adaptive"},
|
||
output_config={"effort": "low",
|
||
"format": {"type": "json_schema", "schema": _SCHEMA}},
|
||
messages=[{"role": "user", "content": prompt}],
|
||
)
|
||
if resp.stop_reason == "refusal":
|
||
raise RuntimeError("Claude-Refusal (Sicherheits-Klassifikator)")
|
||
# output_config.format garantiert: erster text-Block ist valides JSON
|
||
return next((b.text for b in resp.content if b.type == "text"), "")
|
||
|
||
@staticmethod
|
||
def _parse(text: str) -> dict:
|
||
"""Robustes JSON-Parsing (Code-Fences/Prosa drumherum tolerieren)."""
|
||
t = text.strip()
|
||
if "{" in t and "}" in t:
|
||
t = t[t.index("{"): t.rindex("}") + 1]
|
||
data = json.loads(t)
|
||
bias = str(data.get("bias", "NEUTRAL")).upper()
|
||
if bias not in ("LONG", "SHORT", "NEUTRAL"):
|
||
bias = "NEUTRAL"
|
||
tf = str(data.get("timeframe", "M5")).upper().strip()
|
||
if tf not in ("M1", "M5", "M15", "M30", "H1"):
|
||
tf = "M5"
|
||
return {
|
||
"bias": bias,
|
||
"confidence": int(data.get("confidence", 0) or 0),
|
||
"timeframe": tf,
|
||
"headline": str(data.get("headline", "")).strip(),
|
||
"reasoning": str(data.get("reasoning", "")).strip(),
|
||
"risks": [str(r) for r in (data.get("risks") or [])][:4],
|
||
"position_note": str(data.get("position_note", "")).strip(),
|
||
}
|
||
|
||
# ── Hauptlauf (blockierend — im Hintergrund-Thread aufrufen) ─────────────
|
||
def _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 90–120 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()},
|
||
}
|