From cd8ee5b01b434efe791c4a793ed6cf70d416b9b7 Mon Sep 17 00:00:00 2001 From: Axel Hocks Date: Mon, 27 Jul 2026 17:39:55 +0200 Subject: [PATCH] =?UTF-8?q?Circuit=20Breaker:=20Tagesverlust-Stopp=20(dail?= =?UTF-8?q?y=5Floss=5Flimit=5Fpct=3D8)=20=E2=80=94=20=C3=9Cberlebens-Basis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-Ziel autonomes Handeln → Notbremse im Code (vorher abgelehnt, reaktiviert). Erreicht der Tages-P&L (realisiert+offen) -8% der Balance, schließt der Bot die Position und sperrt neue Auto-Trades bis zum naechsten Tag. _check_circuit_breaker im _pos_loop (Startup-Grace, ~30s realized-cache, Berlin-Datum-Reset), blockt Auto-Squeeze, Snapshot + roter Frontend-Banner (v=115). 80%-Margin bleibt (User). Mit 5 Szenarien getestet. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 23 +++++++++++-- core/config.py | 7 ++++ core/engine.py | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++ web/app.js | 20 +++++++++++ web/index.html | 6 ++-- web/style.css | 7 ++++ 6 files changed, 148 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c5baa32..dfc813a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -805,8 +805,25 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: Heuristik-TF nur im Speicher (kein `save_config` mehr) — sonst würde die Secrets-`ini` bei jedem Switch neu geschrieben (Kommentar-Verlust/Churn). Beim Neustart wählt die Heuristik den TF binnen 1 min neu. -- **Kein automatischer Tagesverlust-Schalter (Circuit Breaker):** vom User - abgelehnt — NICHT einbauen. Bei 90 %-Margin-Sizing bleibt Drawdown-Risiko hoch. +- **Circuit Breaker (Tagesverlust-Stopp) = AKTIV `[trading] daily_loss_limit_pct=8` + (User-Vorgabe 2026-07-27, im Rahmen des „Überlebens-Basis"-Pakets — vorher jahrelang + abgelehnt, mit dem Autonomie-Ziel reaktiviert):** erreicht der **Tages-P&L + (realisiert heute + offene Position)** −pct % der Balance, schließt der Bot die + Position (`closed_by=circuit_breaker`) und sperrt **neue Auto-Trades bis zum + nächsten Tag** (`_check_auto_squeeze` prüft `_circuit_halted()`). `_check_circuit_ + breaker` im `_pos_loop` (1 s, unter Startup-Grace pausiert), realisierter Tages-P&L + ~30 s gecacht (`_day_realized_cached`), Berlin-Datum als Trip-Marker (`_circuit_ + tripped_date`) → automatischer Reset am neuen Tag. Log 🛑 + Telegram (eigener, + nicht vom Close-Gate betroffen). Snapshot `circuit_breaker {enabled, limit_pct, + limit_eur, day_total, tripped}`, Frontend `#circuit-banner` (rot bei ausgelöst, + bernstein ab 60 % des Limits, v=115). ⚠ **80 %-Margin-Sizing bleibt** (User- + Entscheidung, 3.+ Mal bestätigt; `risk_pct=0`) → der 8 %-Tagesstopp schützt v. a. + gegen die **Verlust-Serie/-Kaskade**, NICHT vollständig gegen den Einzeltrade + (dessen 2×ATR-SL unter 80 % Margin allein schon >8 % verlieren kann). 0 = aus. + Mit 5 synthetischen Szenarien getestet (nicht-zu-früh · Trip+Close · kein + Doppel-Close · aus · Auto-Squeeze-Block). ⚠ Der frühere „NICHT einbauen"-Grund + gilt nicht mehr — Ziel hat sich zu **autonomem Handel** verschoben, und ein + unbeaufsichtigter Agent braucht die Notbremse im Code. - **Notfall-Stop = AUS beim Öffnen (Stand 2026-07-24, `auto_emergency_margin_pct=0`, `auto_emergency_pct=0`, `auto_emergency_loss=0`).** User-Vorgabe 2026-07-24: „gewinn close und notfall close … nicht setzen beim Eröffnen" → alle drei Auto-Arm-Modi 0, @@ -1197,7 +1214,7 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: Signal → **Hinweis-Dialog „trotzdem eröffnen?"** (überschreibbar). CLOSE bei **negativem** P&L → Sicherheitsabfrage; im Plus/Breakeven direkt. Sonst keine Token-Dialoge (`require_token=false`). Audio braucht 1× Nutzer-Tap. -- Asset-Version aktuell **v=114** (in `web/index.html` hochzählen, siehe Workflows). +- Asset-Version aktuell **v=115** (in `web/index.html` hochzählen, siehe Workflows). Schriftgrößen 2026-07-24 global **+4px** (2× je +2px auf User-Wunsch; Body-Basis 14→18px). ⚠ Betrifft in `style.css` sowohl `font-size:Npx` (71×) ALS AUCH die `font: Npx/…`-**Shorthand** (3×: `.ms-chip`/`.ms-bos-lbl`/`.sqm-badge` — diff --git a/core/config.py b/core/config.py index 8c7d318..8a89cba 100644 --- a/core/config.py +++ b/core/config.py @@ -271,6 +271,13 @@ DEFAULT_CONFIG = { # sofort und vererbt sich per _emergency_remember auf alle Folge-Trades; # real 2026-07-27 killte ein −1-€-Stop jeden Trade in Sekunden). 0 = kein Floor. "emergency_min_eur": "3", + # Circuit Breaker (Tagesverlust-Stopp, User-Vorgabe 2026-07-27): erreicht der + # Tages-P&L (realisiert + offen) −pct% der Balance, schließt der Bot die + # Position und sperrt neue Auto-Trades bis zum nächsten Tag. Überlebens-Netz + # gegen Verlust-Kaskaden (früher abgelehnt, mit dem Autonomie-Ziel reaktiviert). + # ⚠ Unter 80 %-Margin schützt es v. a. gegen die Serie, nicht den Einzeltrade. + # 0 = aus. + "daily_loss_limit_pct": "8", # Gewinn-mitnehmen automatisch beim Öffnen setzen (Kontowährung). 0 = aus # (dann nur manuell im UI). Merkt den zuletzt gesetzten Wert. "auto_takeprofit": "0", diff --git a/core/engine.py b/core/engine.py index c75f3b4..64aae2d 100644 --- a/core/engine.py +++ b/core/engine.py @@ -298,6 +298,19 @@ class TradingEngine: except (TypeError, ValueError): self._emergency_margin_pct = 0.0 self._emergency_margin_armed_ticket = None # Retry-Schutz wie bei sr_close_min_gain + # ── Circuit Breaker (Tagesverlust-Stopp, User-Vorgabe 2026-07-27) ────────── + # `[trading] daily_loss_limit_pct` > 0: erreicht der TAGES-P&L (realisiert heute + # + offene Position) −pct% der Balance, schließt der Bot die Position und + # eröffnet KEINE neuen Auto-Trades mehr bis zum nächsten Tag. Überlebens-Netz + # gegen Verlust-Kaskaden (früher abgelehnt; mit dem Autonomie-Ziel reaktiviert + # — unter 80 %-Margin schützt es v. a. gegen die Serie, nicht den Einzeltrade, + # dessen 2×ATR-SL allein schon >pct% verlieren kann). 0 = aus. + try: + self._cb_limit_pct = max(0.0, float(self.cfg["trading"].get("daily_loss_limit_pct", "0") or 0)) + except (TypeError, ValueError): + self._cb_limit_pct = 0.0 + self._circuit_tripped_date = None # Berlin-Datum, an dem der Breaker griff + self._cb_realized = (0.0, 0.0) # (ts, realisierter Tages-P&L) gecacht ~30 s # Gewinn-mitnehmen (Take-Profit) — symmetrisch zum Notfall-Stop: schließt # bei P&L ≥ +Wert. Gemerkter Wert über Trades hinweg (0 = Auto-Arm aus). self._takeprofit: float | None = None @@ -476,6 +489,7 @@ class TradingEngine: try: if self.data.symbol: self.trader.refresh(self.data.symbol) + self._check_circuit_breaker() # Tagesverlust-Stopp (Überleben zuerst) self._check_auto_close() self._check_sr_close() self._check_auto_squeeze() # autonomer Entry auf Squeeze-Ausbruch @@ -585,6 +599,71 @@ class TradingEngine: Bot keinen laufenden Trade selbst (Daten noch nicht stabil nach Neustart).""" return time.time() < self._no_autoclose_until + # ── Circuit Breaker (Tagesverlust-Stopp) ───────────────────────────────────── + def _day_realized_cached(self) -> float: + """Realisierter Tages-P&L (heute geschlossene Trades), gecacht ~30 s — + damit die SQLite-Abfrage nicht jeden 1-s-Tick läuft.""" + now = time.time() + if now - self._cb_realized[0] < 30: + return self._cb_realized[1] + try: + v = float((self.history.stats_overview("today") or {}).get("total_pnl") or 0.0) + except Exception: + v = self._cb_realized[1] + self._cb_realized = (now, v) + return v + + def _circuit_halted(self) -> bool: + """True, wenn der Circuit Breaker HEUTE (Berlin) bereits ausgelöst hat → + keine neuen Auto-Trades bis zum nächsten Tag.""" + if self._cb_limit_pct <= 0 or not self._circuit_tripped_date: + return False + return self._circuit_tripped_date == datetime.now(_BERLIN).date() + + def _check_circuit_breaker(self): + """Tagesverlust-Stopp (User-Vorgabe 2026-07-27): erreicht der Tages-P&L + (realisiert heute + offene Position) −`daily_loss_limit_pct`% der Balance, + schließt der Bot die Position und sperrt neue Auto-Trades bis zum nächsten + Tag. Wirkt auch bei gesperrtem Handy. Fail-safe.""" + if self._cb_limit_pct <= 0: + return + if self._in_startup_grace(): # nach Neustart nicht auf halb-init Daten feuern + return + today = datetime.now(_BERLIN).date() + if self._circuit_tripped_date and self._circuit_tripped_date != today: + self._circuit_tripped_date = None # neuer Tag → Reset + log.info("Circuit Breaker zurückgesetzt (neuer Handelstag)") + if self._circuit_tripped_date == today: + return # heute schon ausgelöst + bal = float(self.data.balance or 0.0) + if bal <= 0: + return + ps = self.trader.snapshot() + open_pnl = float(ps.get("pnl") or 0.0) + day_total = self._day_realized_cached() + open_pnl + limit_eur = self._cb_limit_pct / 100.0 * bal + if day_total > -limit_eur: + return + self._circuit_tripped_date = today + log.warning(f"🛑 CIRCUIT BREAKER: Tages-P&L {day_total:+.2f} ≤ -{limit_eur:.2f} € " + f"({self._cb_limit_pct:.0f}% von {bal:.0f}) → Position schließen, " + f"keine Auto-Trades mehr heute") + sym = self.data.symbol or "" + tg = self.cfg["telegram"] + tg_on = (tg.get("enabled", "false").lower() == "true" and tg.get("bot_token")) + if ps.get("ticket"): + err = self.trader.close(reason="circuit_breaker") + if err: + log.error(f"Circuit-Breaker-Close fehlgeschlagen: {err}") + if tg_on: + try: + send_telegram(f"🛑 CIRCUIT BREAKER\n{sym} Tagesverlust {day_total:+.2f} € " + f"≤ -{limit_eur:.2f} € ({self._cb_limit_pct:.0f}% der Balance). " + f"Position geschlossen, keine Auto-Trades mehr heute.", + tg["bot_token"], tg["chat_id"]) + except Exception as e: + log.warning(f"Circuit-Breaker-Telegram: {e}") + def _check_auto_close(self): """Server-seitiger Auto-Close (wirkt auch bei gesperrtem Handy): Notfall-Stop (P&L ≤ −Schwelle) UND Gewinn-mitnehmen (P&L ≥ +Ziel). Beide werden bei einer @@ -762,6 +841,8 @@ class TradingEngine: armiert beim Öffnen automatisch (2 % Konto, via `_check_auto_close`). Fail-safe.""" if not self._auto_squeeze: return + if self._circuit_halted(): # Tagesverlust-Stopp aktiv → heute keine Auto-Trades + return try: sq = (self.wave.snapshot() or {}).get("squeeze") or {} if sq.get("state") != "active": @@ -1921,6 +2002,14 @@ class TradingEngine: open_pnl = float(position.get("pnl") or 0.0) day_pl = {"realized": round(day_realized, 2), "open": round(open_pnl, 2), "total": round(day_realized + open_pnl, 2)} + # Circuit Breaker — Status für die UI (Tagesverlust-Stopp) + _bal = float(self.data.balance or 0.0) + _cb_limit_eur = round(self._cb_limit_pct / 100.0 * _bal, 2) if self._cb_limit_pct > 0 else None + circuit_breaker = { + "enabled": self._cb_limit_pct > 0, "limit_pct": self._cb_limit_pct, + "limit_eur": _cb_limit_eur, "day_total": day_pl["total"], + "tripped": self._circuit_halted(), + } sr_close_hint = self._sr_close_hint(market, position, wave_snap) stop_approach = self._stop_approach_hint(market, position, wave_snap) return { @@ -1940,6 +2029,7 @@ class TradingEngine: "session": session_state(), "position": position, "day_pl": day_pl, + "circuit_breaker": circuit_breaker, "emergency_loss": self._emergency_loss, "emergency_remember": self._emergency_remember, "emergency_pct": self._emergency_pct, diff --git a/web/app.js b/web/app.js index 3b243bc..05f0071 100644 --- a/web/app.js +++ b/web/app.js @@ -205,6 +205,25 @@ function renderStructure(s) { } } +// Circuit Breaker — roter Banner wenn ausgelöst, bernstein wenn nah (>60% des Limits). +function renderCircuitBreaker(cb) { + const el = $("circuit-banner"); + if (!el) return; + if (!cb || !cb.enabled) { el.className = "circuit hidden"; el.textContent = ""; return; } + const limit = cb.limit_eur, day = cb.day_total; + if (cb.tripped) { + el.className = "circuit tripped"; + el.textContent = `🛑 CIRCUIT BREAKER AKTIV — Tagesverlust ${fmt(day, 2)} € erreicht ` + + `(Limit −${fmt(limit, 0)} €). Keine Auto-Trades mehr heute.`; + } else if (limit && day < 0 && Math.abs(day) >= 0.6 * limit) { + el.className = "circuit near"; + el.textContent = `⚠ Tagesverlust ${fmt(day, 2)} € — Stopp bei −${fmt(limit, 0)} € ` + + `(${Math.round(100 * Math.abs(day) / limit)} % erreicht)`; + } else { + el.className = "circuit hidden"; el.textContent = ""; + } +} + function render(d) { // ── Header ────────────────────────────── $("conn").className = "dot " + (d.connected ? "on" : "off"); @@ -294,6 +313,7 @@ function render(d) { // ── Gesamtempfehlung (aggregiert) ─────── renderVerdict(d.verdict || {}, d); + renderCircuitBreaker(d.circuit_breaker); renderStructure(d.structure); renderStatsCompact(d.stats_compact, d.market); renderGaps(d.gaps, d.market); diff --git a/web/index.html b/web/index.html index 3ca2ac0..18a1088 100644 --- a/web/index.html +++ b/web/index.html @@ -6,7 +6,7 @@ Oil · MT5 - + @@ -40,6 +40,8 @@
+ +