feat: Add Trading Protection System with 4 protections + comprehensive tests

Implement automatic trading protection system to prevent excessive losses:

PROTECTIONS (100% original code, NOT copied from Freqtrade):
- Max Drawdown Protection: Blocks trading when DD > 15% (configurable)
- Cooldown Period: 4h mandatory rest after 5% loss
- Stoploss Guard: Detects stoploss clusters (>5 per day)
- Low Performance Filter: Filters factors with Sharpe < 0.5, Win Rate < 40%

ARCHITECTURE:
- Base protection interface with common utilities
- 4 specialized protection implementations
- ProtectionManager orchestrates all active protections
- Time-based blocking with automatic expiry

TESTS (32 total, ALL PASS):
- 25 unit tests in test/backtesting/test_protections.py
- 7 integration tests in test/integration/test_all_features.py
- Tests cover: normal operation, edge cases, error handling

DOCUMENTATION:
- Update QWEN.md with development guidelines for AI assistant
  * Mandatory rules: Update QWEN.md, README, requirements.txt, tests
  * Pre-commit checklist
  * Example workflow
- Update README.md with protection system features
- Update project structure with new modules

All code is 100% original - NO license issues with Freqtrade GPLv3.
This commit is contained in:
TPTBusiness
2026-04-03 13:01:56 +02:00
parent 2a011d262e
commit bd025e50dc
11 changed files with 1481 additions and 0 deletions
@@ -0,0 +1,58 @@
"""
Trading Protection System for Predix.
Prevents excessive losses by automatically pausing trading
when risk thresholds are exceeded.
Usage:
from rdagent.components.backtesting.protections import (
ProtectionManager,
MaxDrawdownProtection,
CooldownProtection,
StoplossGuardProtection,
LowPerformanceProtection,
)
manager = ProtectionManager()
manager.create_default_protections()
result = manager.check_all(
returns=[0.01, -0.02, 0.015],
timestamps=[...],
current_equity=98000,
peak_equity=100000
)
if result.should_block:
print(f"Trading blocked: {result.reason}")
"""
from .base import (
BaseProtection,
ProtectionConfig,
ProtectionResult,
ProtectionType,
ProtectionScope,
)
from .max_drawdown import MaxDrawdownProtection, MaxDrawdownConfig
from .cooldown import CooldownProtection, CooldownConfig
from .stoploss_guard import StoplossGuardProtection, StoplossGuardConfig
from .low_performance import LowPerformanceProtection, LowPerformanceConfig
from .protection_manager import ProtectionManager
__all__ = [
"BaseProtection",
"ProtectionConfig",
"ProtectionResult",
"ProtectionType",
"ProtectionScope",
"MaxDrawdownProtection",
"MaxDrawdownConfig",
"CooldownProtection",
"CooldownConfig",
"StoplossGuardProtection",
"StoplossGuardConfig",
"LowPerformanceProtection",
"LowPerformanceConfig",
"ProtectionManager",
]
@@ -0,0 +1,145 @@
"""
Trading Protection System
Prevents excessive losses by automatically pausing trading when risk thresholds are exceeded.
Inspired by common trading protection patterns, implemented from scratch for Predix.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Optional
from enum import Enum
class ProtectionType(Enum):
"""Type of protection mechanism."""
MAX_DRAWDOWN = "max_drawdown"
COOLDOWN = "cooldown"
STOPLOSS_GUARD = "stoploss_guard"
LOW_PERFORMANCE = "low_performance"
class ProtectionScope(Enum):
"""What scope does this protection apply to?"""
GLOBAL = "global" # All trading
FACTOR = "factor" # Per-factor
PORTFOLIO = "portfolio" # Per portfolio
@dataclass
class ProtectionResult:
"""Result of a protection check."""
should_block: bool # True if trading should be blocked
reason: str # Why was it blocked?
until: Optional[datetime] = None # If time-based, when does it expire?
protection_type: Optional[ProtectionType] = None
severity: float = 0.0 # How severe is the issue (0-1)
@property
def is_active(self) -> bool:
"""Check if protection is currently active."""
if not self.until:
return self.should_block
return datetime.now() < self.until and self.should_block
@dataclass
class ProtectionConfig:
"""Base configuration for a protection."""
enabled: bool = True
lookback_period_hours: int = 24 # How far back to look
severity_threshold: float = 0.8 # At what severity to block
class BaseProtection(ABC):
"""
Base class for all trading protections.
Each protection checks specific conditions and returns ProtectionResult.
Multiple protections can be combined in ProtectionManager.
"""
def __init__(self, config: ProtectionConfig):
self.config = config
self.last_check: Optional[datetime] = None
self.total_checks: int = 0
self.total_blocks: int = 0
@abstractmethod
def check(
self,
returns: list[float],
timestamps: list[datetime],
current_equity: float,
peak_equity: float,
**kwargs
) -> ProtectionResult:
"""
Check if protection should be triggered.
Parameters
----------
returns : list[float]
Historical returns in lookback period
timestamps : list[datetime]
Timestamps of returns
current_equity : float
Current equity value
peak_equity : float
Peak equity value (highest ever)
**kwargs
Additional context (factor name, portfolio ID, etc.)
Returns
-------
ProtectionResult
Decision on whether to block trading
"""
pass
def calculate_drawdown(self, current: float, peak: float) -> float:
"""Calculate drawdown percentage (negative value)."""
if peak == 0:
return 0.0
return (current - peak) / peak
def calculate_max_consecutive_losses(self, returns: list[float]) -> int:
"""Find maximum consecutive losing trades."""
max_losses = 0
current_losses = 0
for ret in returns:
if ret < 0:
current_losses += 1
max_losses = max(max_losses, current_losses)
else:
current_losses = 0
return max_losses
def calculate_recent_loss_rate(self, returns: list[float]) -> float:
"""Calculate percentage of losing trades."""
if not returns:
return 0.0
losses = sum(1 for r in returns if r < 0)
return losses / len(returns)
def record_check(self, blocked: bool = False):
"""Record that a check was performed."""
self.total_checks += 1
self.last_check = datetime.now()
if blocked:
self.total_blocks += 1
def get_stats(self) -> dict:
"""Get protection statistics."""
return {
"type": self.__class__.__name__,
"enabled": self.config.enabled,
"total_checks": self.total_checks,
"total_blocks": self.total_blocks,
"block_rate": self.total_blocks / max(1, self.total_checks),
"last_check": self.last_check.isoformat() if self.last_check else None
}
@@ -0,0 +1,86 @@
"""
Cooldown Period Protection
Enforces mandatory rest periods after losses.
"""
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional
from .base import BaseProtection, ProtectionConfig, ProtectionResult, ProtectionType, ProtectionScope
@dataclass
class CooldownConfig(ProtectionConfig):
"""Configuration for Cooldown protection."""
cooldown_after_loss_pct: float = 0.05 # Cooldown after 5% loss
cooldown_duration_hours: int = 4 # How long to wait
class CooldownProtection(BaseProtection):
"""
Enforces cooling-off period after significant losses.
Prevents revenge trading and emotional decisions by forcing
a mandatory break after losses exceed threshold.
"""
def __init__(self, config: CooldownConfig):
super().__init__(config)
self.config: CooldownConfig = config
self.last_loss_time: Optional[datetime] = None
self.last_loss_pct: float = 0.0
@property
def scope(self) -> ProtectionScope:
return ProtectionScope.FACTOR
def check(
self,
returns: list[float],
timestamps: list[datetime],
current_equity: float,
peak_equity: float,
**kwargs
) -> ProtectionResult:
"""Check if cooldown should be triggered."""
self.record_check()
if not self.config.enabled:
return ProtectionResult(
should_block=False,
reason="Protection disabled",
protection_type=ProtectionType.COOLDOWN
)
# Check most recent return
if returns:
latest_return = returns[-1]
latest_time = timestamps[-1] if timestamps else datetime.now()
if latest_return < -self.config.cooldown_after_loss_pct:
self.last_loss_time = latest_time
self.last_loss_pct = latest_return
# If recently had big loss, enforce cooldown
if self.last_loss_time:
time_since_loss = datetime.now() - self.last_loss_time
if time_since_loss < timedelta(hours=self.config.cooldown_duration_hours):
remaining = timedelta(hours=self.config.cooldown_duration_hours) - time_since_loss
result = ProtectionResult(
should_block=True,
reason=f"Loss of {abs(self.last_loss_pct)*100:.1f}% - cooling down for {remaining.seconds // 3600}h",
until=self.last_loss_time + timedelta(hours=self.config.cooldown_duration_hours),
protection_type=ProtectionType.COOLDOWN,
severity=abs(self.last_loss_pct) / self.config.cooldown_after_loss_pct
)
self.record_check(blocked=True)
return result
return ProtectionResult(
should_block=False,
reason="No recent significant losses",
protection_type=ProtectionType.COOLDOWN,
severity=0.0
)
@@ -0,0 +1,103 @@
"""
Low Performance Filter
Blocks trading for factors/portfolios with consistently poor performance.
"""
from dataclasses import dataclass
from datetime import datetime
from .base import BaseProtection, ProtectionConfig, ProtectionResult, ProtectionType, ProtectionScope
@dataclass
class LowPerformanceConfig(ProtectionConfig):
"""Configuration for LowPerformance protection."""
min_sharpe_ratio: float = 0.5 # Minimum acceptable Sharpe
min_win_rate: float = 0.40 # Minimum 40% win rate
min_trades: int = 20 # Need at least this many trades to evaluate
class LowPerformanceProtection(BaseProtection):
"""
Filters out consistently underperforming factors.
Prevents wasting resources on factors that statistical analysis
shows are unlikely to become profitable.
"""
def __init__(self, config: LowPerformanceConfig):
super().__init__(config)
self.config: LowPerformanceConfig = config
@property
def scope(self) -> ProtectionScope:
return ProtectionScope.FACTOR
def check(
self,
returns: list[float],
timestamps: list[datetime],
current_equity: float,
peak_equity: float,
**kwargs
) -> ProtectionResult:
"""Check if performance is below minimum standards."""
self.record_check()
if not self.config.enabled:
return ProtectionResult(
should_block=False,
reason="Protection disabled",
protection_type=ProtectionType.LOW_PERFORMANCE
)
# Need minimum number of trades
if len(returns) < self.config.min_trades:
return ProtectionResult(
should_block=False,
reason=f"Insufficient data ({len(returns)} < {self.config.min_trades} trades)",
protection_type=ProtectionType.LOW_PERFORMANCE,
severity=0.0
)
# Calculate metrics
import numpy as np
returns_array = np.array(returns)
# Win rate
wins = int(np.sum(returns_array > 0))
win_rate = wins / len(returns)
# Sharpe ratio (annualized, assuming daily returns)
mean_return = float(np.mean(returns_array))
std_return = float(np.std(returns_array))
sharpe = (mean_return / std_return * np.sqrt(252)) if std_return > 0 else 0
# Check thresholds
reasons = []
severity = 0.0
if sharpe < self.config.min_sharpe_ratio:
reasons.append(f"Sharpe {sharpe:.2f} < {self.config.min_sharpe_ratio}")
severity = max(severity, (self.config.min_sharpe_ratio - sharpe) / self.config.min_sharpe_ratio)
if win_rate < self.config.min_win_rate:
reasons.append(f"Win rate {win_rate*100:.1f}% < {self.config.min_win_rate*100:.1f}%")
severity = max(severity, (self.config.min_win_rate - win_rate) / self.config.min_win_rate)
if reasons:
result = ProtectionResult(
should_block=True,
reason=" | ".join(reasons),
protection_type=ProtectionType.LOW_PERFORMANCE,
severity=severity
)
self.record_check(blocked=True)
return result
return ProtectionResult(
should_block=False,
reason=f"Performance acceptable (Sharpe: {sharpe:.2f}, Win rate: {win_rate*100:.1f}%)",
protection_type=ProtectionType.LOW_PERFORMANCE,
severity=severity
)
@@ -0,0 +1,75 @@
"""
Maximum Drawdown Protection
Blocks trading when portfolio drawdown exceeds threshold.
"""
from dataclasses import dataclass
from datetime import datetime, timedelta
from .base import BaseProtection, ProtectionConfig, ProtectionResult, ProtectionType, ProtectionScope
@dataclass
class MaxDrawdownConfig(ProtectionConfig):
"""Configuration for MaxDrawdown protection."""
max_drawdown_pct: float = 0.15 # Block if drawdown > 15%
class MaxDrawdownProtection(BaseProtection):
"""
Blocks trading when drawdown exceeds safe threshold.
This prevents the system from continuing to trade during a losing streak,
giving the market time to stabilize.
"""
def __init__(self, config: MaxDrawdownConfig):
super().__init__(config)
self.config: MaxDrawdownConfig = config
@property
def scope(self) -> ProtectionScope:
return ProtectionScope.GLOBAL
def check(
self,
returns: list[float],
timestamps: list[datetime],
current_equity: float,
peak_equity: float,
**kwargs
) -> ProtectionResult:
"""Check if drawdown exceeds threshold."""
self.record_check()
if not self.config.enabled:
return ProtectionResult(
should_block=False,
reason="Protection disabled",
protection_type=ProtectionType.MAX_DRAWDOWN
)
drawdown = self.calculate_drawdown(current_equity, peak_equity)
severity = abs(drawdown) / self.config.max_drawdown_pct if self.config.max_drawdown_pct > 0 else 0
if abs(drawdown) > self.config.max_drawdown_pct:
# Calculate block duration based on severity
block_hours = int(self.config.lookback_period_hours * severity)
block_hours = min(block_hours, 168) # Max 1 week
result = ProtectionResult(
should_block=True,
reason=f"Drawdown {abs(drawdown)*100:.1f}% exceeds max {self.config.max_drawdown_pct*100:.1f}%",
until=datetime.now() + timedelta(hours=block_hours),
protection_type=ProtectionType.MAX_DRAWDOWN,
severity=severity
)
self.record_check(blocked=True)
return result
return ProtectionResult(
should_block=False,
reason=f"Drawdown {abs(drawdown)*100:.1f}% within safe range",
protection_type=ProtectionType.MAX_DRAWDOWN,
severity=severity
)
@@ -0,0 +1,176 @@
"""
Protection Manager
Orchestrates multiple trading protections.
"""
from datetime import datetime
from typing import Optional, List
from .base import (
BaseProtection,
ProtectionConfig,
ProtectionResult,
ProtectionType,
ProtectionScope
)
from .max_drawdown import MaxDrawdownProtection, MaxDrawdownConfig
from .cooldown import CooldownProtection, CooldownConfig
from .stoploss_guard import StoplossGuardProtection, StoplossGuardConfig
from .low_performance import LowPerformanceProtection, LowPerformanceConfig
class ProtectionManager:
"""
Manages multiple trading protections.
Run all active protections and aggregate their results.
If ANY protection returns should_block=True, trading is blocked.
"""
def __init__(self):
self.protections: List[BaseProtection] = []
self.active_blocks: List[ProtectionResult] = []
def add_protection(self, protection: BaseProtection):
"""Add a protection to the manager."""
self.protections.append(protection)
def remove_protection(self, protection_type: ProtectionType):
"""Remove a protection by type."""
type_to_name = {
ProtectionType.MAX_DRAWDOWN: "MaxDrawdownProtection",
ProtectionType.COOLDOWN: "CooldownProtection",
ProtectionType.STOPLOSS_GUARD: "StoplossGuardProtection",
ProtectionType.LOW_PERFORMANCE: "LowPerformanceProtection",
}
class_name = type_to_name.get(protection_type, protection_type.value)
self.protections = [
p for p in self.protections
if p.__class__.__name__ != class_name
]
def check_all(
self,
returns: list[float],
timestamps: list[datetime],
current_equity: float,
peak_equity: float,
**kwargs
) -> ProtectionResult:
"""
Run all protections and aggregate results.
Returns
-------
ProtectionResult
Combined result from all protections
"""
all_results = []
for protection in self.protections:
result = protection.check(
returns=returns,
timestamps=timestamps,
current_equity=current_equity,
peak_equity=peak_equity,
**kwargs
)
all_results.append(result)
# Check if any protection is blocking
blocking = [r for r in all_results if r.should_block]
if blocking:
# Find most severe block
most_severe = max(blocking, key=lambda r: r.severity)
# Clean up expired blocks
self.active_blocks = [
b for b in self.active_blocks
if b.until is None or datetime.now() < b.until
]
# Add new block
self.active_blocks.append(most_severe)
# Combine reasons
reasons = [r.reason for r in blocking]
return ProtectionResult(
should_block=True,
reason=f"Trading blocked: {'; '.join(reasons)}",
until=most_severe.until,
severity=most_severe.severity
)
return ProtectionResult(
should_block=False,
reason="All protections passed",
severity=0.0
)
def get_active_blocks(self) -> List[ProtectionResult]:
"""Get currently active protection blocks."""
# Clean up expired
self.active_blocks = [
b for b in self.active_blocks
if b.until is None or datetime.now() < b.until
]
return self.active_blocks
def get_stats(self) -> dict:
"""Get statistics for all protections."""
return {
"total_protections": len(self.protections),
"active_blocks": len(self.get_active_blocks()),
"protections": [p.get_stats() for p in self.protections]
}
def create_default_protections(self):
"""Create standard protection setup."""
# Max Drawdown: 15% threshold
self.add_protection(
MaxDrawdownProtection(
MaxDrawdownConfig(
enabled=True,
max_drawdown_pct=0.15,
lookback_period_hours=168 # 1 week
)
)
)
# Cooldown: 4 hours after 5% loss
self.add_protection(
CooldownProtection(
CooldownConfig(
enabled=True,
cooldown_after_loss_pct=0.05,
cooldown_duration_hours=4
)
)
)
# Stoploss Guard: Max 5 stoplosses per day
self.add_protection(
StoplossGuardProtection(
StoplossGuardConfig(
enabled=True,
max_stoplosses_in_period=5,
stoploss_threshold_pct=0.02,
lookback_period_hours=24
)
)
)
# Low Performance: Filter bad factors
self.add_protection(
LowPerformanceProtection(
LowPerformanceConfig(
enabled=True,
min_sharpe_ratio=0.5,
min_win_rate=0.40,
min_trades=20,
lookback_period_hours=720 # 30 days
)
)
)
@@ -0,0 +1,76 @@
"""
Stoploss Guard Protection
Detects clusters of stoplosses and blocks trading.
"""
from dataclasses import dataclass
from datetime import datetime
from .base import BaseProtection, ProtectionConfig, ProtectionResult, ProtectionType, ProtectionScope
@dataclass
class StoplossGuardConfig(ProtectionConfig):
"""Configuration for StoplossGuard protection."""
max_stoplosses_in_period: int = 5 # Max stoplosses allowed
stoploss_threshold_pct: float = 0.02 # What counts as stoploss (2%)
class StoplossGuardProtection(BaseProtection):
"""
Detects stoploss clusters and blocks trading.
Multiple stoplosses in short time indicates bad market conditions
or strategy no longer working.
"""
def __init__(self, config: StoplossGuardConfig):
super().__init__(config)
self.config: StoplossGuardConfig = config
@property
def scope(self) -> ProtectionScope:
return ProtectionScope.GLOBAL
def check(
self,
returns: list[float],
timestamps: list[datetime],
current_equity: float,
peak_equity: float,
**kwargs
) -> ProtectionResult:
"""Check for stoploss clusters."""
self.record_check()
if not self.config.enabled:
return ProtectionResult(
should_block=False,
reason="Protection disabled",
protection_type=ProtectionType.STOPLOSS_GUARD
)
# Count stoplosses (large losses)
stoplosses = [
r for r in returns
if r < -self.config.stoploss_threshold_pct
]
if len(stoplosses) > self.config.max_stoplosses_in_period:
severity = len(stoplosses) / self.config.max_stoplosses_in_period
result = ProtectionResult(
should_block=True,
reason=f"{len(stoplosses)} stoplosses detected (max {self.config.max_stoplosses_in_period})",
protection_type=ProtectionType.STOPLOSS_GUARD,
severity=severity
)
self.record_check(blocked=True)
return result
return ProtectionResult(
should_block=False,
reason=f"{len(stoplosses)} stoplosses (within limit)",
protection_type=ProtectionType.STOPLOSS_GUARD,
severity=len(stoplosses) / max(1, self.config.max_stoplosses_in_period)
)