Provider-Failover bei hartem Fehlschlag + Einsatz-Prozentfeld im Dashboard

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Axel Hocks
2026-08-05 08:38:17 +02:00
co-authored by Claude Opus 5
parent 7e9b0196a5
commit cc4f1a0863
7 changed files with 244 additions and 26 deletions
+93 -13
View File
@@ -145,6 +145,17 @@ def _has_cjk(rec: dict) -> bool:
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",
@@ -189,6 +200,11 @@ class TradingAgent:
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
@@ -216,7 +232,43 @@ class TradingAgent:
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
@@ -240,10 +292,7 @@ class TradingAgent:
"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
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
@@ -623,9 +672,17 @@ class TradingAgent:
}
# ── 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:
prov = self._active_provider()
if prov is None:
if self._active_provider() is None:
with self._lock:
self._error = ("Kein Provider (config.ini [ollama] model / "
"[anthropic] / [gemini] / [openai])")
@@ -634,6 +691,7 @@ class TradingAgent:
if self._busy:
return False
self._busy = True
prov = None
try:
ctx = self._gather_context()
prompt = ("Aktueller Systemzustand (JSON):\n"
@@ -641,13 +699,30 @@ class TradingAgent:
+ "\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))
# ── Fallback-Kette (2026-08-05): bei einem HARTEN Fehlschlag (Key/
# Guthaben/Modell) den Provider markieren und den NÄCHSTEN nehmen.
# ⚠ Nur bei hart — nach Timeout/429/5xx wird abgebrochen statt die
# ganze Kette durchzuprobieren, sonst kostet eine Lastspitze bei jedem
# Zyklus die volle Timeout-Summe aller Anbieter (hier bis 90120 s je
# Stück) und der bevorzugte Anbieter geht unnötig verloren.
raw = None
for kandidat in self._provider_chain():
prov = kandidat
try:
raw = self._call(prov, prompt)
break
except Exception as e:
grund = self._mark_dead(prov, e)
if grund is None:
raise # vorübergehend → nicht wechseln
naechster = next(iter(self._provider_chain()), None)
log.warning(f"Provider '{prov}' ausgefallen: {grund}"
+ (f" — weiter mit '{naechster}'" if naechster
else " — KEIN Provider mehr verfügbar"))
if raw is None:
with self._lock:
self._error = "alle Provider ausgefallen (Key/Guthaben prüfen)"
return False
parsed = self._parse(raw)
# CN-/lokale Modelle (Qwen, z.ai, Kimi, DeepSeek) driften gelegentlich ins
# Chinesische → einmal mit verschärfter Anweisung neu versuchen.
@@ -688,4 +763,9 @@ class TradingAgent:
"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()},
}