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
+31 -7
View File
@@ -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 (`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%)"). Projekt ohnehin für `daily_levels` genutzt; live verifiziert („[zai] NEUTRAL (30%)").
Kimi (`kimi-k2.6`) antwortet ebenfalls (7,6 s), ist aber kostenpflichtig. Kimi (`kimi-k2.6`) antwortet ebenfalls (7,6 s), ist aber kostenpflichtig.
⚠⚠ **Der eigentliche Befund: die Fallback-Kette hat NICHT gegriffen.** `_active_ ⚠⚠ **Der eigentliche Befund: die Fallback-Kette hatte NICHT gegriffen.** `_active_
provider` prüft in `avail` nur das **Key-FORMAT** (`startswith("sk-")`), nicht ob der provider` prüfte in `avail` nur das **Key-FORMAT** (`startswith("sk-")`), nicht ob der
Key noch funktioniert. Ein Provider ohne Guthaben gilt damit als „verfügbar" und Key noch funktioniert. Ein Provider ohne Guthaben galt damit als „verfügbar" und
**blockiert die gesamte Kette** (`deepseek→kimi→zai→local`) — der Copilot lief **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 **~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 außer einer WARNING-Zeile passierte.
Fehlschlag (401/402) den Provider für X Minuten als tot markieren und den nächsten **BEHOBEN am selben Tag (`agent._mark_dead`/`_provider_chain`/`_chain_raw`):** ein
nehmen. Ohne das wiederholt sich dasselbe, sobald das z.ai-Kontingent endet. 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 (90120 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`. Zurück auf DeepSeek nach Aufladen: `provider = deepseek`.
- **LLM-Provider (Historie):** **DeepSeek (`[deepseek]`) war der aktive - **LLM-Provider (Historie):** **DeepSeek (`[deepseek]`) war der aktive
Copilot-Provider** (`[agent] provider=deepseek`, 2026-07-20 bis 2026-08-05). `api.deepseek.com`, 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 · `POST /api/manualmargin {value}` · Snapshot `manual_margin` · neustart-fest über
`runtime_state.json`. Ende-zu-Ende getestet (250 → Snapshot → 0 → persistiert). `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 **199 %** (`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 **Sprachausgabe: Piper, lokal** (`tools/speak.py`, `tools/piper/`). Anlass: die
Windows-Sprachausgabe funktioniert zwar, aber auf diesem Rechner ist **keine Windows-Sprachausgabe funktioniert zwar, aber auf diesem Rechner ist **keine
deutsche Stimme installiert** — weder SAPI5 noch OneCore (nur David/Zira/Mark, deutsche Stimme installiert** — weder SAPI5 noch OneCore (nur David/Zira/Mark,
+93 -13
View File
@@ -145,6 +145,17 @@ def _has_cjk(rec: dict) -> bool:
return any("" <= c <= "鿿" for c in txt) 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) # JSON-Schema für strukturierte Ausgabe (Claude: output_config.format erzwingt es)
_SCHEMA = { _SCHEMA = {
"type": "object", "type": "object",
@@ -189,6 +200,11 @@ class TradingAgent:
self.tg_push = (ac.get("telegram", "false") or "false").lower() == "true" self.tg_push = (ac.get("telegram", "false") or "false").lower() == "true"
self._lock = threading.Lock() 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._busy = False
self._last: dict | None = None # letzte Beurteilung (geparst) self._last: dict | None = None # letzte Beurteilung (geparst)
self._error: str | None = None self._error: str | None = None
@@ -216,7 +232,43 @@ class TradingAgent:
except Exception: except Exception:
return "" 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: 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 """Bevorzugt den konfigurierten Provider, fällt sonst der Reihe nach
auf einen verfügbaren zurück. 'local' (Ollama) gilt als verfügbar, auf einen verfügbaren zurück. 'local' (Ollama) gilt als verfügbar,
sobald ein Modellname gesetzt ist — Erreichbarkeit wird erst beim sobald ein Modellname gesetzt ist — Erreichbarkeit wird erst beim
@@ -240,10 +292,7 @@ class TradingAgent:
"kimi": ["kimi", "deepseek", "zai", "local"], "kimi": ["kimi", "deepseek", "zai", "local"],
"deepseek": ["deepseek", "kimi", "zai", "local"], "deepseek": ["deepseek", "kimi", "zai", "local"],
} }
for p in orders.get(self.provider, default): return [p for p in orders.get(self.provider, default) if avail[p]]
if avail[p]:
return p
return None
def is_configured(self) -> bool: def is_configured(self) -> bool:
return self._active_provider() is not None return self._active_provider() is not None
@@ -623,9 +672,17 @@ class TradingAgent:
} }
# ── Hauptlauf (blockierend — im Hintergrund-Thread aufrufen) ───────────── # ── 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: def analyze(self) -> bool:
prov = self._active_provider() if self._active_provider() is None:
if prov is None:
with self._lock: with self._lock:
self._error = ("Kein Provider (config.ini [ollama] model / " self._error = ("Kein Provider (config.ini [ollama] model / "
"[anthropic] / [gemini] / [openai])") "[anthropic] / [gemini] / [openai])")
@@ -634,6 +691,7 @@ class TradingAgent:
if self._busy: if self._busy:
return False return False
self._busy = True self._busy = True
prov = None
try: try:
ctx = self._gather_context() ctx = self._gather_context()
prompt = ("Aktueller Systemzustand (JSON):\n" prompt = ("Aktueller Systemzustand (JSON):\n"
@@ -641,13 +699,30 @@ class TradingAgent:
+ "\n\nGib deine Beurteilung als JSON zurück. " + "\n\nGib deine Beurteilung als JSON zurück. "
"Alle Texte auf DEUTSCH, nur lateinische Schrift " "Alle Texte auf DEUTSCH, nur lateinische Schrift "
"(keine chinesischen Zeichen).") "(keine chinesischen Zeichen).")
raw = (self._call_ollama(prompt) if prov == "local" # ── Fallback-Kette (2026-08-05): bei einem HARTEN Fehlschlag (Key/
else self._call_zai(prompt) if prov == "zai" # Guthaben/Modell) den Provider markieren und den NÄCHSTEN nehmen.
else self._call_kimi(prompt) if prov == "kimi" # ⚠ Nur bei hart — nach Timeout/429/5xx wird abgebrochen statt die
else self._call_deepseek(prompt) if prov == "deepseek" # ganze Kette durchzuprobieren, sonst kostet eine Lastspitze bei jedem
else self._call_claude(prompt) if prov == "claude" # Zyklus die volle Timeout-Summe aller Anbieter (hier bis 90120 s je
else self._call_openai(prompt) if prov == "openai" # Stück) und der bevorzugte Anbieter geht unnötig verloren.
else self._call_gemini(prompt)) 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) parsed = self._parse(raw)
# CN-/lokale Modelle (Qwen, z.ai, Kimi, DeepSeek) driften gelegentlich ins # CN-/lokale Modelle (Qwen, z.ai, Kimi, DeepSeek) driften gelegentlich ins
# Chinesische → einmal mit verschärfter Anweisung neu versuchen. # Chinesische → einmal mit verschärfter Anweisung neu versuchen.
@@ -688,4 +763,9 @@ class TradingAgent:
"busy": self._busy, "busy": self._busy,
"provider": self._active_provider(), "provider": self._active_provider(),
"configured": self.is_configured(), "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
View File
@@ -22,6 +22,7 @@ import MetaTrader5 as mt5
from core.config import ( from core.config import (
HISTORY_DB_FILE, MAGIC, load_config, set_margin_buffer, set_risk_per_trade, 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`, # ⚠ `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 # s. dort): die `[zones]`-Config stammte aus einem ~10 $ tieferen Kursregime und
@@ -1845,10 +1846,15 @@ class TradingEngine:
if "manual_margin" in st: if "manual_margin" in st:
from core.config import set_manual_margin as _set from core.config import set_manual_margin as _set
self._manual_margin = _set(st["manual_margin"] or 0) 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'} · " 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"S/R-Close={'AN' if self._auto_sr_close else 'aus'} · "
f"MinGewinn={self._sr_close_min_gain:+.2f} · " 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: except FileNotFoundError:
pass pass
except Exception as e: except Exception as e:
@@ -1863,7 +1869,8 @@ class TradingEngine:
"auto_signal": self._auto_signal, "auto_signal": self._auto_signal,
"auto_sr_close": self._auto_sr_close, "auto_sr_close": self._auto_sr_close,
"sr_close_min_gain": self._sr_close_min_gain, "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) os.replace(tmp, self._runtime_state_path)
except Exception as e: except Exception as e:
log.warning(f"runtime_state speichern: {e}") log.warning(f"runtime_state speichern: {e}")
@@ -2781,6 +2788,25 @@ class TradingEngine:
self._save_runtime_state() self._save_runtime_state()
return v 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 199 % — 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: def _manual_stats_cached(self) -> dict | None:
"""Kennzahlen der manuellen Trades für die Karte „Manuelle Trades" """Kennzahlen der manuellen Trades für die Karte „Manuelle Trades"
(2026-08-04). Gleiches Cache-Muster wie `_alignment_cached` — die (2026-08-04). Gleiches Cache-Muster wie `_alignment_cached` — die
@@ -3342,6 +3368,9 @@ class TradingEngine:
"manual_stats": self._manual_stats_cached(), "manual_stats": self._manual_stats_cached(),
# Manuell vorgegebene Einsatz-Margin (0 = automatisch, s. set_manual_margin) # Manuell vorgegebene Einsatz-Margin (0 = automatisch, s. set_manual_margin)
"manual_margin": getattr(self, "_manual_margin", 0.0), "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). # Live-Handelskosten relativ zur Vola (2026-07-24): Spread/ATR(M5).
# Gemessen (`backtest_realcosts.py`): Ø 0,265×ATR, Nacht 0,320,50 = # Gemessen (`backtest_realcosts.py`): Ø 0,265×ATR, Nacht 0,320,50 =
# Kostenfalle, 1517 Uhr ~0,13 = günstig. Reine Anzeige (Chip) — die # Kostenfalle, 1517 Uhr ~0,13 = günstig. Reine Anzeige (Chip) — die
+20
View File
@@ -364,6 +364,26 @@ async def set_manual_margin(body: dict,
return {"ok": True, "value": val} 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 199 % 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") @app.post("/api/autosignal")
async def toggle_autosignal(x_auth_token: str | None = Header(default=None)): async def toggle_autosignal(x_auth_token: str | None = Header(default=None)):
"""Autonomen Entry auf die EMPFEHLUNG an/aus (User-Wunsch 2026-07-30). """Autonomen Entry auf die EMPFEHLUNG an/aus (User-Wunsch 2026-07-30).
+54
View File
@@ -908,6 +908,24 @@ function render(d) {
mgi.value = mm > 0 ? String(mm) : ""; mgi.value = mm > 0 ? String(mm) : "";
mgi.classList.toggle("on", mm > 0); 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 (199). "
+ "Greift, solange links kein fester Betrag steht.";
}
}
// Einblendung: der Bot hat am S/R automatisch geschlossen (Zähler stieg) // Einblendung: der Bot hat am S/R automatisch geschlossen (Zähler stieg)
const src = d.sr_close_count; const src = d.sr_close_count;
if (src != null) { 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 199 % 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. // Notfall-Stop: manueller Verlust-Wert, bei dem der Server sofort schließt.
// Wieder eingeblendet 2026-07-22 (Backend/Persistenz waren die ganze Zeit aktiv). // Wieder eingeblendet 2026-07-22 (Backend/Persistenz waren die ganze Zeit aktiv).
async function setEmg() { async function setEmg() {
+11 -4
View File
@@ -6,7 +6,7 @@
<meta name="theme-color" content="#0d1117"> <meta name="theme-color" content="#0d1117">
<title>Oil · MT5</title> <title>Oil · MT5</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<link rel="stylesheet" href="style.css?v=136"> <link rel="stylesheet" href="style.css?v=137">
</head> </head>
<body> <body>
<!-- Modul-Menü (ganz oben) --> <!-- Modul-Menü (ganz oben) -->
@@ -239,10 +239,17 @@
(margin_buffer_pct % der freien Margin). ⚠ BEWUSST hier und nicht in der (margin_buffer_pct % der freien Margin). ⚠ BEWUSST hier und nicht in der
Trade-Leiste: die ist nur bei OFFENER Position sichtbar, der Einsatz muss Trade-Leiste: die ist nur bei OFFENER Position sichtbar, der Einsatz muss
aber VOR dem Öffnen einstellbar sein. Wirkt auf ALLE neuen Positionen. --> aber VOR dem Öffnen einstellbar sein. Wirkt auf ALLE neuen Positionen. -->
<span class="mg-field" title="Feste Einsatz-Margin in € — leer/0 = automatisch (95 % der freien Margin). Gilt auch für autonome Trades."> <span class="mg-field" title="Feste Einsatz-Margin in € — leer/0 = automatisch (dann gilt der Prozentsatz rechts). Gilt auch für autonome Trades.">
<label for="margin-input">Einsatz</label> <label for="margin-input">Einsatz</label>
<input id="margin-input" type="number" inputmode="decimal" step="10" min="0" placeholder="auto"> <input id="margin-input" type="number" inputmode="decimal" step="10" min="0" placeholder="auto">
</span> </span>
<!-- Prozent-Gegenstück (User 2026-08-05). Steuert `margin_buffer_pct`, das
bisher nur in der ini stand. ⚠ Der feste €-Betrag hat VORRANG — steht er,
wird dieses Feld ausgegraut, damit nicht unklar bleibt, was gilt. -->
<span class="mg-field" id="marginpct-wrap" title="Einsatz als Prozent der freien Margin (199). Greift, solange links kein fester Betrag steht.">
<label for="marginpct-input">Einsatz %</label>
<input id="marginpct-input" type="number" inputmode="decimal" step="5" min="1" max="99" placeholder="95">
</span>
<button class="btn long" data-side="long">▲ LONG</button> <button class="btn long" data-side="long">▲ LONG</button>
<button class="btn short" data-side="short">▼ SHORT</button> <button class="btn short" data-side="short">▼ SHORT</button>
<button class="btn close" data-side="close">✕ CLOSE</button> <button class="btn close" data-side="close">✕ CLOSE</button>
@@ -267,6 +274,6 @@
<div id="toast" class="toast hidden"></div> <div id="toast" class="toast hidden"></div>
<script src="lightweight-charts.standalone.production.js?v=114"></script> <script src="lightweight-charts.standalone.production.js?v=114"></script>
<script src="app.js?v=136"></script> <script src="app.js?v=137"></script>
</body> </body>
</html> </html>
+4
View File
@@ -528,3 +528,7 @@ main{padding-bottom:74px} /* Platz für die fixe Leiste */
/* aktiv gesetzt = sichtbar hervorgehoben, damit ein fester Einsatz nicht /* aktiv gesetzt = sichtbar hervorgehoben, damit ein fester Einsatz nicht
unbemerkt bestehen bleibt */ unbemerkt bestehen bleibt */
.mg-field input.on { border-color: #d29922; color: #d29922; } .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; }