diff --git a/CLAUDE.md b/CLAUDE.md index 38a53b4..59e110c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,14 +91,25 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: (`glm-4.5-flash`) ist kostenlos**, antwortet in ~2,3 s (DeepSeek ~6 s) und wird im Projekt ohnehin für `daily_levels` genutzt; live verifiziert („[zai] NEUTRAL (30%)"). Kimi (`kimi-k2.6`) antwortet ebenfalls (7,6 s), ist aber kostenpflichtig. - ⚠⚠ **Der eigentliche Befund: die Fallback-Kette hat NICHT gegriffen.** `_active_ - provider` prüft in `avail` nur das **Key-FORMAT** (`startswith("sk-")`), nicht ob der - Key noch funktioniert. Ein Provider ohne Guthaben gilt damit als „verfügbar" und - **blockiert die gesamte Kette** (`deepseek→kimi→zai→local`) — der Copilot lief + ⚠⚠ **Der eigentliche Befund: die Fallback-Kette hatte NICHT gegriffen.** `_active_ + provider` prüfte in `avail` nur das **Key-FORMAT** (`startswith("sk-")`), nicht ob der + Key noch funktioniert. Ein Provider ohne Guthaben galt damit als „verfügbar" und + **blockierte die gesamte Kette** (`deepseek→kimi→zai→local`) — der Copilot lief **~18 h still ins Leere** (29 Fehlversuche ab 04.08. 14:52), ohne dass irgendetwas - außer einer WARNING-Zeile passierte. **Offen, noch nicht gebaut:** bei hartem - Fehlschlag (401/402) den Provider für X Minuten als tot markieren und den nächsten - nehmen. Ohne das wiederholt sich dasselbe, sobald das z.ai-Kontingent endet. + außer einer WARNING-Zeile passierte. + ✅ **BEHOBEN am selben Tag (`agent._mark_dead`/`_provider_chain`/`_chain_raw`):** ein + hart gescheiterter Provider wird zeitweise übersprungen und `analyze()` nimmt den + nächsten aus der Kette. **Hart = 401/402/403/404** (Key/Guthaben/Modell) → **30 min**; + **nicht erreichbar** (z. B. Ollama aus) → 5 min. **Bewusst NICHT hart: Timeout, 429, + 5xx** — das sind vorübergehende Zustände; wer darauf wechselt, verliert bei jeder + Lastspitze den bevorzugten Anbieter und zahlt pro Zyklus die volle Timeout-Summe + ALLER Anbieter (90–120 s je Stück). Sichtbar im Snapshot als + **`agent.provider_dead {provider: Restminuten}`** — der Ausfall stand vorher nur im + Log, genau deshalb blieb er 18 h unbemerkt. `_dead` läuft absichtlich **ohne** + `self._lock` (`snapshot()` hält ihn bereits, nicht reentrant → Deadlock; dict-Zugriffe + sind unter dem GIL atomar). Mit **9 Szenarien getestet** (402→Fallback · zweiter Zyklus + überspringt direkt · zwei tote Anbieter · Timeout/429 lösen KEINEN Wechsel aus · + ConnectionError 5 min · alle tot → sauberer Fehler · Erholung nach Ablauf · Snapshot). Zurück auf DeepSeek nach Aufladen: `provider = deepseek`. - **LLM-Provider (Historie):** **DeepSeek (`[deepseek]`) war der aktive Copilot-Provider** (`[agent] provider=deepseek`, 2026-07-20 bis 2026-08-05). `api.deepseek.com`, @@ -3218,6 +3229,19 @@ Enter blurrt nur, `change` sendet einmal (gleicher Fix wie beim Mindestgewinn). · `POST /api/manualmargin {value}` · Snapshot `manual_margin` · neustart-fest über `runtime_state.json`. Ende-zu-Ende getestet (250 → Snapshot → 0 → persistiert). +**Einsatz in PROZENT (2026-08-05, v=137):** das Gegenstück zum festen Betrag — +`margin_buffer_pct` (live 95) hatte bisher **keine UI** und war nur in der ini +änderbar. Neu: Feld **„Einsatz %"** (`#marginpct-input`) direkt neben „Einsatz €" in +der Order-Leiste · `POST /api/marginpct {value}` → `engine.set_margin_pct` → +`config.set_margin_buffer` · Snapshot `margin_pct` · neustart-fest. +⚠ **VORRANG ist sichtbar gemacht:** steht links ein fester €-Betrag, wird das +Prozent-Feld **ausgegraut** (`.mg-field.muted`, gestrichelter Rand) und der Tooltip +sagt, dass der Prozentsatz dann nur noch die Obergrenze ist — zwei scheinbar +gleichrangige Felder ohne Hinweis wären die schlechtere Lösung. +⚠ Geklemmt auf **1–99 %** (`set_margin_buffer`); 100 % ließe keinen Puffer für +Spread/Swap. Ende-zu-Ende getestet: 50 → 50 · 150 → auf 99 geklemmt · 0 und −5 +abgelehnt · 95 → 95, in `runtime_state.json` persistiert. + **Sprachausgabe: Piper, lokal** (`tools/speak.py`, `tools/piper/`). Anlass: die Windows-Sprachausgabe funktioniert zwar, aber auf diesem Rechner ist **keine deutsche Stimme installiert** — weder SAPI5 noch OneCore (nur David/Zira/Mark, diff --git a/core/agent.py b/core/agent.py index a88162d..c615fb3 100644 --- a/core/agent.py +++ b/core/agent.py @@ -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()}, } diff --git a/core/engine.py b/core/engine.py index 7d5a88b..4b076b7 100644 --- a/core/engine.py +++ b/core/engine.py @@ -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 diff --git a/server.py b/server.py index 95e4bbd..4fed5ca 100644 --- a/server.py +++ b/server.py @@ -364,6 +364,26 @@ async def set_manual_margin(body: dict, return {"ok": True, "value": val} +@app.post("/api/marginpct") +async def set_margin_pct(body: dict, + x_auth_token: str | None = Header(default=None)): + """Einsatz als PROZENT der freien Margin setzen (User-Wunsch 2026-08-05). + + Gegenstück zu `/api/manualmargin` (fester Betrag). ⚠ Ein gesetzter FESTER + Betrag hat Vorrang; der Prozentsatz bleibt dann nur noch die Obergrenze. + Wird auf 1–99 % geklemmt — 100 % ließe keinen Puffer für Spread/Swap.""" + _require_auth(x_auth_token) + try: + v = float(body.get("value") or 0) + except (TypeError, ValueError): + return {"ok": False, "error": "ungültiger Wert"} + if v <= 0: + return {"ok": False, "error": "Prozentsatz muss > 0 sein"} + val = app.state.engine.set_margin_pct(v) + get_logger("server").info(f"[WEB] Einsatz-Prozentsatz: {val:.0f} % der freien Margin") + return {"ok": True, "value": val} + + @app.post("/api/autosignal") async def toggle_autosignal(x_auth_token: str | None = Header(default=None)): """Autonomen Entry auf die EMPFEHLUNG an/aus (User-Wunsch 2026-07-30). diff --git a/web/app.js b/web/app.js index f34bdef..a0ae1b7 100644 --- a/web/app.js +++ b/web/app.js @@ -908,6 +908,24 @@ function render(d) { mgi.value = mm > 0 ? String(mm) : ""; mgi.classList.toggle("on", mm > 0); } + // Einsatz in Prozent der freien Margin. Ausgegraut, sobald links ein fester + // Betrag steht — der hat Vorrang, und ohne den Hinweis wäre unklar, was gilt. + const mpi = $("marginpct-input"); + if (mpi && document.activeElement !== mpi) { + const mp = d.margin_pct || 0; + mpi.value = mp > 0 ? String(mp) : ""; + const fest = (d.manual_margin || 0) > 0; + mpi.classList.toggle("on", mp > 0 && !fest); + const wrap = $("marginpct-wrap"); + if (wrap) { + wrap.classList.toggle("muted", fest); + wrap.title = fest + ? `Ohne Wirkung: links steht ein fester Einsatz (${fmt(d.manual_margin, 2)} €). ` + + `Der Prozentsatz (${mp} %) bleibt nur die Obergrenze.` + : "Einsatz als Prozent der freien Margin (1–99). " + + "Greift, solange links kein fester Betrag steht."; + } + } // Einblendung: der Bot hat am S/R automatisch geschlossen (Zähler stieg) const src = d.sr_close_count; if (src != null) { @@ -1307,6 +1325,42 @@ async function setManualMargin() { }); })(); +// Einsatz in PROZENT der freien Margin (User-Wunsch 2026-08-05) — das Gegenstück +// zum festen Betrag oben. Steuert `margin_buffer_pct`, das bisher nur in der ini +// stand. ⚠ Ein gesetzter fester Betrag hat Vorrang; darauf weist der Toast hin. +async function setMarginPct() { + const el = $("marginpct-input"); if (!el) return; + const v = parseFloat(el.value); + if (isNaN(v) || v < 1 || v > 99) { + toast("Bitte 1–99 % eingeben (100 % ließe keinen Puffer für Spread/Swap)", false); + return; + } + const tok = await ensureToken(); + if (tok === null) return; + el.blur(); + try { + const r = await fetch("api/marginpct", { method: "POST", + headers: { "X-Auth-Token": tok, "Content-Type": "application/json" }, + body: JSON.stringify({ value: v }) }); + if (r.status === 401) { clearToken(); throw new Error("401"); } + const j = await r.json(); + if (j.ok === false) { toast(j.error || "abgelehnt", false); return; } + const fest = parseFloat(($("margin-input") || {}).value || "0") > 0; + toast(`Einsatz: ${fmt(j.value ?? v, 0)} % der freien Margin` + + (fest ? " — greift erst, wenn der feste Betrag links leer ist" : ""), true); + } catch (e) { + toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false); + } +} +(() => { + const el = $("marginpct-input"); + if (!el) return; // null-sicher (Cache-Regel) + el.addEventListener("change", setMarginPct); + el.addEventListener("keydown", e => { + if (e.key === "Enter") { e.preventDefault(); e.target.blur(); } + }); +})(); + // Notfall-Stop: manueller Verlust-Wert, bei dem der Server sofort schließt. // Wieder eingeblendet 2026-07-22 (Backend/Persistenz waren die ganze Zeit aktiv). async function setEmg() { diff --git a/web/index.html b/web/index.html index ca7fe5a..5d8bf1c 100644 --- a/web/index.html +++ b/web/index.html @@ -6,7 +6,7 @@ Oil · MT5 - + @@ -239,10 +239,17 @@ (margin_buffer_pct % der freien Margin). ⚠ BEWUSST hier und nicht in der Trade-Leiste: die ist nur bei OFFENER Position sichtbar, der Einsatz muss aber VOR dem Öffnen einstellbar sein. Wirkt auf ALLE neuen Positionen. --> - - + + + + + + + @@ -267,6 +274,6 @@ - + diff --git a/web/style.css b/web/style.css index eda3fb3..73a828a 100644 --- a/web/style.css +++ b/web/style.css @@ -528,3 +528,7 @@ main{padding-bottom:74px} /* Platz für die fixe Leiste */ /* aktiv gesetzt = sichtbar hervorgehoben, damit ein fester Einsatz nicht unbemerkt bestehen bleibt */ .mg-field input.on { border-color: #d29922; color: #d29922; } +/* Prozent-Feld ohne Wirkung, weil links ein FESTER Betrag steht — sichtbar + zurückgenommen, statt zwei scheinbar gleichrangige Felder zu zeigen. */ +.mg-field.muted { opacity: .45; } +.mg-field.muted input { border-style: dashed; }