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:
co-authored by
Claude Opus 5
parent
7e9b0196a5
commit
cc4f1a0863
+93
-13
@@ -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 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.
|
||||
@@ -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()},
|
||||
}
|
||||
|
||||
+31
-2
@@ -22,6 +22,7 @@ import MetaTrader5 as mt5
|
||||
|
||||
from core.config import (
|
||||
HISTORY_DB_FILE, MAGIC, load_config, set_margin_buffer, set_risk_per_trade,
|
||||
get_margin_buffer as _cfg_margin_buffer,
|
||||
)
|
||||
# ⚠ `parse_zones` wurde am 2026-07-31 hier entfernt (letzter Nutzer war `_sr_levels`,
|
||||
# s. dort): die `[zones]`-Config stammte aus einem ~10 $ tieferen Kursregime und
|
||||
@@ -1845,10 +1846,15 @@ class TradingEngine:
|
||||
if "manual_margin" in st:
|
||||
from core.config import set_manual_margin as _set
|
||||
self._manual_margin = _set(st["manual_margin"] or 0)
|
||||
if "margin_pct" in st:
|
||||
from core.config import set_margin_buffer as _setp, get_margin_buffer
|
||||
_setp(float(st["margin_pct"] or 0) or 95.0)
|
||||
self._margin_pct = round(get_margin_buffer() * 100.0, 1)
|
||||
log.info(f"Runtime-State geladen: BRK={'AN' if self._auto_squeeze else 'aus'} · "
|
||||
f"S/R-Close={'AN' if self._auto_sr_close else 'aus'} · "
|
||||
f"MinGewinn={self._sr_close_min_gain:+.2f} · "
|
||||
f"Einsatz={'auto' if not getattr(self, '_manual_margin', 0) else f'{self._manual_margin:.2f}'}")
|
||||
f"Einsatz={'auto' if not getattr(self, '_manual_margin', 0) else f'{self._manual_margin:.2f}'}"
|
||||
f" · Einsatz-%={getattr(self, '_margin_pct', 0):.0f}")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception as e:
|
||||
@@ -1863,7 +1869,8 @@ class TradingEngine:
|
||||
"auto_signal": self._auto_signal,
|
||||
"auto_sr_close": self._auto_sr_close,
|
||||
"sr_close_min_gain": self._sr_close_min_gain,
|
||||
"manual_margin": getattr(self, "_manual_margin", 0.0)}, f)
|
||||
"manual_margin": getattr(self, "_manual_margin", 0.0),
|
||||
"margin_pct": getattr(self, "_margin_pct", 0.0)}, f)
|
||||
os.replace(tmp, self._runtime_state_path)
|
||||
except Exception as e:
|
||||
log.warning(f"runtime_state speichern: {e}")
|
||||
@@ -2781,6 +2788,25 @@ class TradingEngine:
|
||||
self._save_runtime_state()
|
||||
return v
|
||||
|
||||
def set_margin_pct(self, pct: float) -> float:
|
||||
"""Einsatz als PROZENT der freien Margin (User-Wunsch 2026-08-05).
|
||||
|
||||
Das ist die zweite Hälfte des Einsatz-Felds: `set_manual_margin` gibt einen
|
||||
FESTEN Betrag vor, das hier den ANTEIL. Beides existierte bisher nur in der
|
||||
`ini` (`margin_buffer_pct`, live 95) — die Prozent-Schraube hatte keine UI.
|
||||
|
||||
⚠ VORRANG: ein fester Betrag (`manual_margin > 0`) schlägt den Prozentsatz —
|
||||
`calc_lots` prüft ihn zuerst. Der Prozentsatz bleibt dabei die Obergrenze
|
||||
(freie Margin × pct), ein zu hoher Fixbetrag wird darauf gedeckelt.
|
||||
⚠ Wirkt auf ALLE neuen Positionen, auch die autonomen. Neustart-fest.
|
||||
⚠ `set_margin_buffer` klemmt auf 1–99 % — 100 % wäre ohnehin unhaltbar
|
||||
(kein Puffer für Spread/Swap → Margin-Call beim Öffnen)."""
|
||||
from core.config import set_margin_buffer as _set, get_margin_buffer
|
||||
_set(pct)
|
||||
self._margin_pct = round(get_margin_buffer() * 100.0, 1)
|
||||
self._save_runtime_state()
|
||||
return self._margin_pct
|
||||
|
||||
def _manual_stats_cached(self) -> dict | None:
|
||||
"""Kennzahlen der manuellen Trades für die Karte „Manuelle Trades"
|
||||
(2026-08-04). Gleiches Cache-Muster wie `_alignment_cached` — die
|
||||
@@ -3342,6 +3368,9 @@ class TradingEngine:
|
||||
"manual_stats": self._manual_stats_cached(),
|
||||
# Manuell vorgegebene Einsatz-Margin (0 = automatisch, s. set_manual_margin)
|
||||
"manual_margin": getattr(self, "_manual_margin", 0.0),
|
||||
# Einsatz als % der freien Margin (`margin_buffer_pct`). Greift, solange
|
||||
# kein fester Betrag gesetzt ist — der hat Vorrang (s. set_margin_pct).
|
||||
"margin_pct": round(_cfg_margin_buffer() * 100.0, 1),
|
||||
# Live-Handelskosten relativ zur Vola (2026-07-24): Spread/ATR(M5).
|
||||
# Gemessen (`backtest_realcosts.py`): Ø 0,265×ATR, Nacht 0,32–0,50 =
|
||||
# Kostenfalle, 15–17 Uhr ~0,13 = günstig. Reine Anzeige (Chip) — die
|
||||
|
||||
Reference in New Issue
Block a user