Files were edited but not staged in earlier commits: - adaptive_rhythm_manager.py: mt→mt5, pytz→timezone, get_volatility_level, shutdown() - check_market_regime.py: ADX_THRESHOLD, Wilder EWM, try/finally, UTC timestamp, sys import - check_system_status.py: remove duplicate cursor.execute - drawdown_protection.py: float(inf), persist pause state, DB save_setting, Markdown fix - performance_analysis.py: KeyError export fix, profit factor, drawdown positive, SQL filter - performance_analysis_simple.py: fromisoformat, numeric bin sort, profit factor - trading_dashboard.py: st.rerun(), session_state auto-refresh, pathlib DB path, errors=coerce Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
432 lines
14 KiB
Python
432 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
🛡️ Drawdown Protection System
|
||
Schützt vor übermäßigen Verlusten durch automatische Handels-Pausen
|
||
"""
|
||
|
||
from datetime import datetime, timedelta, timezone
|
||
from trading_database import TradingDatabase
|
||
from telegram_notifier import TelegramNotifier
|
||
import logging
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
class DrawdownProtection:
|
||
"""
|
||
Überwacht Verluste und pausiert Trading bei Überschreitung von Limits
|
||
"""
|
||
|
||
def __init__(self,
|
||
database: TradingDatabase,
|
||
telegram: TelegramNotifier = None,
|
||
max_daily_loss: float = 100.0,
|
||
max_weekly_loss: float = 300.0,
|
||
max_monthly_loss: float = 800.0,
|
||
max_consecutive_losses: int = 5,
|
||
cooldown_hours: int = 24):
|
||
"""
|
||
Args:
|
||
database: TradingDatabase instance
|
||
telegram: TelegramNotifier instance
|
||
max_daily_loss: Maximaler Tagesverlust in $
|
||
max_weekly_loss: Maximaler Wochenverlust in $
|
||
max_monthly_loss: Maximaler Monatsverlust in $
|
||
max_consecutive_losses: Maximale Anzahl aufeinanderfolgender Verluste
|
||
cooldown_hours: Pause in Stunden nach Limit-Überschreitung
|
||
"""
|
||
self.db = database
|
||
self.telegram = telegram
|
||
|
||
# Limits
|
||
self.max_daily_loss = max_daily_loss
|
||
self.max_weekly_loss = max_weekly_loss
|
||
self.max_monthly_loss = max_monthly_loss
|
||
self.max_consecutive_losses = max_consecutive_losses
|
||
self.cooldown_hours = cooldown_hours
|
||
|
||
# State (loaded from DB so it survives restarts)
|
||
self.trading_paused = False
|
||
self.pause_until = None
|
||
self.pause_reason = None
|
||
self._load_state()
|
||
|
||
def can_trade(self) -> tuple[bool, str]:
|
||
"""
|
||
Prüft ob Trading erlaubt ist
|
||
|
||
Returns:
|
||
(can_trade: bool, reason: str)
|
||
"""
|
||
# Prüfe ob Pause noch aktiv
|
||
if self.trading_paused and self.pause_until:
|
||
if datetime.now() < self.pause_until:
|
||
remaining = (self.pause_until - datetime.now()).total_seconds() / 3600
|
||
return False, f"Trading paused for {remaining:.1f} more hours. Reason: {self.pause_reason}"
|
||
else:
|
||
# Pause abgelaufen
|
||
self._resume_trading()
|
||
|
||
# Prüfe täglichen Verlust
|
||
daily_loss = self._get_loss_today()
|
||
if daily_loss >= self.max_daily_loss:
|
||
self._pause_trading(
|
||
f"Daily loss limit reached: ${daily_loss:.2f} / ${self.max_daily_loss:.2f}",
|
||
hours=self.cooldown_hours
|
||
)
|
||
return False, f"Daily loss limit: ${daily_loss:.2f}"
|
||
|
||
# Prüfe wöchentlichen Verlust
|
||
weekly_loss = self._get_loss_this_week()
|
||
if weekly_loss >= self.max_weekly_loss:
|
||
self._pause_trading(
|
||
f"Weekly loss limit reached: ${weekly_loss:.2f} / ${self.max_weekly_loss:.2f}",
|
||
hours=self.cooldown_hours * 2
|
||
)
|
||
return False, f"Weekly loss limit: ${weekly_loss:.2f}"
|
||
|
||
# Prüfe monatlichen Verlust
|
||
monthly_loss = self._get_loss_this_month()
|
||
if monthly_loss >= self.max_monthly_loss:
|
||
self._pause_trading(
|
||
f"Monthly loss limit reached: ${monthly_loss:.2f} / ${self.max_monthly_loss:.2f}",
|
||
hours=self.cooldown_hours * 7
|
||
)
|
||
return False, f"Monthly loss limit: ${monthly_loss:.2f}"
|
||
|
||
# Prüfe aufeinanderfolgende Verluste
|
||
consecutive = self._get_consecutive_losses()
|
||
if consecutive >= self.max_consecutive_losses:
|
||
self._pause_trading(
|
||
f"Too many consecutive losses: {consecutive} in a row",
|
||
hours=self.cooldown_hours
|
||
)
|
||
return False, f"Consecutive losses: {consecutive}"
|
||
|
||
return True, "OK"
|
||
|
||
def _load_state(self):
|
||
"""Load persisted pause state from DB on startup"""
|
||
try:
|
||
pause_until_str = self.db.load_setting('drawdown_pause_until')
|
||
pause_reason = self.db.load_setting('drawdown_pause_reason')
|
||
if pause_until_str:
|
||
pause_until = datetime.fromisoformat(pause_until_str)
|
||
if pause_until > datetime.now():
|
||
self.trading_paused = True
|
||
self.pause_until = pause_until
|
||
self.pause_reason = pause_reason
|
||
logger.info(f"Loaded active pause from DB: {pause_reason} until {pause_until}")
|
||
else:
|
||
self._clear_persisted_state()
|
||
except Exception as e:
|
||
logger.error(f"Error loading drawdown state from DB: {e}")
|
||
|
||
def _clear_persisted_state(self):
|
||
"""Clear pause state from DB"""
|
||
try:
|
||
self.db.save_setting('drawdown_pause_until', '')
|
||
self.db.save_setting('drawdown_pause_reason', '')
|
||
except Exception as e:
|
||
logger.error(f"Error clearing drawdown state: {e}")
|
||
|
||
def _get_loss_today(self) -> float:
|
||
"""Berechnet Verlust heute"""
|
||
try:
|
||
today = datetime.now().strftime('%Y-%m-%d')
|
||
|
||
conn = self.db.conn
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute("""
|
||
SELECT COALESCE(SUM(net_profit), 0)
|
||
FROM trades
|
||
WHERE DATE(exit_time) = ?
|
||
AND status = 'closed'
|
||
AND net_profit < 0
|
||
""", (today,))
|
||
|
||
loss = cursor.fetchone()[0]
|
||
return abs(loss) if loss else 0.0
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error calculating daily loss: {e}")
|
||
return float('inf') # safe: block trading when DB unreachable
|
||
|
||
def _get_loss_this_week(self) -> float:
|
||
"""Berechnet Verlust diese Woche"""
|
||
try:
|
||
week_start = (datetime.now() - timedelta(days=7)).strftime('%Y-%m-%d')
|
||
|
||
conn = self.db.conn
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute("""
|
||
SELECT COALESCE(SUM(net_profit), 0)
|
||
FROM trades
|
||
WHERE DATE(exit_time) >= ?
|
||
AND status = 'closed'
|
||
AND net_profit < 0
|
||
""", (week_start,))
|
||
|
||
loss = cursor.fetchone()[0]
|
||
return abs(loss) if loss else 0.0
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error calculating weekly loss: {e}")
|
||
return float('inf')
|
||
|
||
def _get_loss_this_month(self) -> float:
|
||
"""Berechnet Verlust diesen Monat"""
|
||
try:
|
||
month_start = datetime.now().replace(day=1).strftime('%Y-%m-%d')
|
||
|
||
conn = self.db.conn
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute("""
|
||
SELECT COALESCE(SUM(net_profit), 0)
|
||
FROM trades
|
||
WHERE DATE(exit_time) >= ?
|
||
AND status = 'closed'
|
||
AND net_profit < 0
|
||
""", (month_start,))
|
||
|
||
loss = cursor.fetchone()[0]
|
||
return abs(loss) if loss else 0.0
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error calculating monthly loss: {e}")
|
||
return float('inf')
|
||
|
||
def _get_consecutive_losses(self) -> int:
|
||
"""Zählt aufeinanderfolgende Verluste"""
|
||
try:
|
||
conn = self.db.conn
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute("""
|
||
SELECT net_profit
|
||
FROM trades
|
||
WHERE status = 'closed'
|
||
ORDER BY exit_time DESC
|
||
LIMIT 20
|
||
""")
|
||
|
||
trades = cursor.fetchall()
|
||
|
||
consecutive = 0
|
||
for trade in trades:
|
||
if trade[0] < 0:
|
||
consecutive += 1
|
||
else:
|
||
break
|
||
|
||
return consecutive
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error counting consecutive losses: {e}")
|
||
return 0
|
||
|
||
def _pause_trading(self, reason: str, hours: int):
|
||
"""Pausiert Trading"""
|
||
self.trading_paused = True
|
||
self.pause_until = datetime.now() + timedelta(hours=hours)
|
||
self.pause_reason = reason
|
||
|
||
logger.warning(f"🛑 Trading paused: {reason}")
|
||
logger.warning(f" Resuming at: {self.pause_until}")
|
||
|
||
# Persist so pause survives a restart
|
||
try:
|
||
self.db.save_setting('drawdown_pause_until', self.pause_until.isoformat())
|
||
self.db.save_setting('drawdown_pause_reason', reason)
|
||
except Exception as e:
|
||
logger.error(f"Error persisting drawdown pause state: {e}")
|
||
|
||
if self.telegram:
|
||
self.telegram.send_message(
|
||
f"🛑 *TRADING PAUSED*\n\n"
|
||
f"Reason: {reason}\n"
|
||
f"Duration: {hours} hours\n"
|
||
f"Resume at: {self.pause_until.strftime('%Y-%m-%d %H:%M')}\n\n"
|
||
f"ℹ️ Review your strategy and market conditions."
|
||
)
|
||
|
||
def _resume_trading(self):
|
||
"""Setzt Trading fort"""
|
||
self.trading_paused = False
|
||
self.pause_until = None
|
||
previous_reason = self.pause_reason
|
||
self.pause_reason = None
|
||
|
||
self._clear_persisted_state()
|
||
logger.info(f"✅ Trading resumed after: {previous_reason}")
|
||
|
||
if self.telegram:
|
||
self.telegram.send_message(
|
||
f"✅ *TRADING RESUMED*\n\n"
|
||
f"Previous pause reason: {previous_reason}\n"
|
||
f"Time: {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
||
)
|
||
|
||
def get_status(self) -> dict:
|
||
"""Gibt aktuellen Status zurück"""
|
||
return {
|
||
'trading_allowed': not self.trading_paused,
|
||
'paused': self.trading_paused,
|
||
'pause_reason': self.pause_reason,
|
||
'pause_until': self.pause_until.isoformat() if self.pause_until else None,
|
||
'daily_loss': self._get_loss_today(),
|
||
'daily_limit': self.max_daily_loss,
|
||
'weekly_loss': self._get_loss_this_week(),
|
||
'weekly_limit': self.max_weekly_loss,
|
||
'monthly_loss': self._get_loss_this_month(),
|
||
'monthly_limit': self.max_monthly_loss,
|
||
'consecutive_losses': self._get_consecutive_losses(),
|
||
'consecutive_limit': self.max_consecutive_losses
|
||
}
|
||
|
||
def force_pause(self, reason: str, hours: int = 24):
|
||
"""Manuelles Pausieren"""
|
||
self._pause_trading(reason, hours)
|
||
|
||
def force_resume(self):
|
||
"""Manuelles Fortsetzen"""
|
||
self._resume_trading()
|
||
|
||
|
||
# ==========================================
|
||
# INTEGRATION WRAPPER
|
||
# ==========================================
|
||
|
||
def create_protected_trading_check(infra, original_check_function):
|
||
"""
|
||
Wrapper der Drawdown Protection um Trading Check legt
|
||
|
||
Usage:
|
||
# Im Notebook:
|
||
from drawdown_protection import DrawdownProtection, create_protected_trading_check
|
||
|
||
# Erstelle Protection
|
||
drawdown_protection = DrawdownProtection(
|
||
database=infra.db,
|
||
telegram=infra.telegram,
|
||
max_daily_loss=100,
|
||
max_weekly_loss=300,
|
||
max_consecutive_losses=5
|
||
)
|
||
|
||
# Wrapping
|
||
original_trading_check = trading_check
|
||
trading_check = create_protected_trading_check(
|
||
infra,
|
||
original_trading_check
|
||
)
|
||
"""
|
||
|
||
# Create drawdown protection instance
|
||
protection = DrawdownProtection(
|
||
database=infra.db,
|
||
telegram=infra.telegram,
|
||
max_daily_loss=100, # $100 per day
|
||
max_weekly_loss=300, # $300 per week
|
||
max_monthly_loss=800, # $800 per month
|
||
max_consecutive_losses=5,
|
||
cooldown_hours=24
|
||
)
|
||
|
||
def protected_check():
|
||
"""Geschützter Trading Check"""
|
||
# Prüfe Drawdown Protection
|
||
can_trade, reason = protection.can_trade()
|
||
|
||
if not can_trade:
|
||
logger.warning(f"Trading blocked by drawdown protection: {reason}")
|
||
return
|
||
|
||
# Wenn OK, führe normalen Check aus
|
||
return original_check_function()
|
||
|
||
# Attach protection instance for access
|
||
protected_check.protection = protection
|
||
|
||
return protected_check
|
||
|
||
|
||
# ==========================================
|
||
# MONITORING HELPER
|
||
# ==========================================
|
||
|
||
def print_protection_status(protection: DrawdownProtection):
|
||
"""Gibt Protection Status formatiert aus"""
|
||
status = protection.get_status()
|
||
|
||
print("=" * 70)
|
||
print("🛡️ DRAWDOWN PROTECTION STATUS")
|
||
print("=" * 70)
|
||
|
||
if status['trading_allowed']:
|
||
print("\n✅ Trading ALLOWED")
|
||
else:
|
||
print("\n🛑 Trading PAUSED")
|
||
print(f" Reason: {status['pause_reason']}")
|
||
print(f" Until: {status['pause_until']}")
|
||
|
||
print("\n📊 Current Losses:")
|
||
print(f" Daily: ${status['daily_loss']:.2f} / ${status['daily_limit']:.2f}")
|
||
print(f" Weekly: ${status['weekly_loss']:.2f} / ${status['weekly_limit']:.2f}")
|
||
print(f" Monthly: ${status['monthly_loss']:.2f} / ${status['monthly_limit']:.2f}")
|
||
|
||
print(f"\n📉 Consecutive Losses: {status['consecutive_losses']} / {status['consecutive_limit']}")
|
||
|
||
print("=" * 70)
|
||
|
||
|
||
# ==========================================
|
||
# USAGE EXAMPLE
|
||
# ==========================================
|
||
|
||
"""
|
||
INTEGRATION IN NOTEBOOK:
|
||
|
||
# Cell: Drawdown Protection Setup (nach Infrastructure)
|
||
|
||
from drawdown_protection import DrawdownProtection, create_protected_trading_check, print_protection_status
|
||
|
||
# Erstelle Drawdown Protection
|
||
drawdown_protection = DrawdownProtection(
|
||
database=infra.db,
|
||
telegram=infra.telegram,
|
||
max_daily_loss=100, # $100/Tag
|
||
max_weekly_loss=300, # $300/Woche
|
||
max_monthly_loss=800, # $800/Monat
|
||
max_consecutive_losses=5,
|
||
cooldown_hours=24
|
||
)
|
||
|
||
print("✅ Drawdown Protection aktiviert")
|
||
print(f" Max Daily Loss: ${drawdown_protection.max_daily_loss}")
|
||
print(f" Max Weekly Loss: ${drawdown_protection.max_weekly_loss}")
|
||
print(f" Max Consecutive Losses: {drawdown_protection.max_consecutive_losses}")
|
||
|
||
# Wrapper um trading_check
|
||
original_trading_check = trading_check
|
||
trading_check = create_protected_trading_check(infra, original_trading_check)
|
||
|
||
print("✅ Trading Check ist jetzt geschützt!")
|
||
|
||
|
||
# Cell: Status prüfen (optional, jederzeit ausführbar)
|
||
|
||
print_protection_status(drawdown_protection)
|
||
|
||
|
||
# Cell: Manuell pausieren/fortsetzen (optional)
|
||
|
||
# Manuell pausieren:
|
||
# drawdown_protection.force_pause("Manual review needed", hours=12)
|
||
|
||
# Manuell fortsetzen:
|
||
# drawdown_protection.force_resume()
|
||
"""
|