Circuit Breaker: Tagesverlust-Stopp (daily_loss_limit_pct=8) — Überlebens-Basis

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 <noreply@anthropic.com>
This commit is contained in:
Axel Hocks
2026-07-27 17:39:55 +02:00
co-authored by Claude Opus 4.8
parent 873b67a521
commit cd8ee5b01b
6 changed files with 148 additions and 5 deletions
+7
View File
@@ -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",
+90
View File
@@ -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"🛑 <b>CIRCUIT BREAKER</b>\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,