drl-trading-bot-dev2 / src /features /risk_manager.py
DRL Trading Bot
Feature: HTF Agent integration — live trading, API endpoints, UI tab
fc115d5
Raw
History Blame Contribute Delete
14.1 kB
"""
Advanced Risk Management Module
Features:
- Adaptive SL/TP based on ATR volatility
- Kelly Criterion position sizing
- Trailing stops
- Dynamic risk per trade
"""
import numpy as np
import pandas as pd
from typing import Dict, Tuple, Optional
from dataclasses import dataclass
import logging
logger = logging.getLogger(__name__)
@dataclass
class RiskParameters:
"""Dynamic risk parameters."""
stop_loss_pct: float
take_profit_pct: float
position_size: float
trailing_stop_pct: Optional[float]
risk_reward_ratio: float
class AdaptiveRiskManager:
"""
Advanced risk management with dynamic SL/TP and position sizing.
Key features:
- ATR-based stop loss and take profit
- Kelly Criterion for optimal position sizing
- Trailing stops for maximizing winners
"""
def __init__(
self,
base_sl_pct: float = 0.05, # 5.0% base stop loss (increased from 2%)
base_tp_pct: float = 0.10, # 10.0% base take profit (increased from 5%)
base_position_size: float = 0.5,
min_sl_pct: float = 0.02, # Minimum 2% SL
max_sl_pct: float = 0.10, # Maximum 10% SL
min_tp_pct: float = 0.04, # Minimum 4% TP
max_tp_pct: float = 0.20, # Maximum 20% TP
min_position_size: float = 0.1,
max_position_size: float = 0.75,
atr_period: int = 14,
use_kelly: bool = True,
use_trailing: bool = True,
):
self.base_sl_pct = base_sl_pct
self.base_tp_pct = base_tp_pct
self.base_position_size = base_position_size
self.min_sl_pct = min_sl_pct
self.max_sl_pct = max_sl_pct
self.min_tp_pct = min_tp_pct
self.max_tp_pct = max_tp_pct
self.min_position_size = min_position_size
self.max_position_size = max_position_size
self.atr_period = atr_period
self.use_kelly = use_kelly
self.use_trailing = use_trailing
# Trade history for Kelly calculation
self.trade_history: list = []
logger.info(f"📊 AdaptiveRiskManager initialized (Kelly={use_kelly}, Trailing={use_trailing})")
def get_asset_specific_params(self, symbol: str) -> Tuple[float, float]:
"""
Get asset-specific SL/TP base parameters based on typical volatility.
BTC: Lower volatility → Tighter stops (1.5% SL, 3.5% TP)
ETH: Medium volatility → Standard stops (2.0% SL, 5.0% TP)
SOL: Higher volatility → Wider stops (2.5% SL, 6.0% TP)
XRP: Medium-high volatility → Medium-wide stops (2.0% SL, 5.5% TP)
"""
symbol_upper = symbol.upper().replace('USDT', '')
asset_params = {
'BTC': (0.015, 0.035), # 1.5% SL, 3.5% TP (lowest volatility)
'ETH': (0.020, 0.050), # 2.0% SL, 5.0% TP (medium volatility)
'SOL': (0.025, 0.060), # 2.5% SL, 6.0% TP (highest volatility)
'XRP': (0.020, 0.055), # 2.0% SL, 5.5% TP (medium-high volatility)
}
sl_pct, tp_pct = asset_params.get(symbol_upper, (self.base_sl_pct, self.base_tp_pct))
logger.info(f"📊 Asset-specific params for {symbol_upper}: SL={sl_pct:.2%}, TP={tp_pct:.2%}")
return sl_pct, tp_pct
def calculate_atr(self, df: pd.DataFrame, period: int = None) -> float:
"""Calculate Average True Range."""
period = period or self.atr_period
high = df['high'].values
low = df['low'].values
close = df['close'].values
tr1 = high - low
tr2 = np.abs(high - np.roll(close, 1))
tr3 = np.abs(low - np.roll(close, 1))
tr = np.maximum(np.maximum(tr1, tr2), tr3)
atr = np.mean(tr[-period:])
return atr
def get_adaptive_sl_tp(self, df: pd.DataFrame, trade_type: str = "long") -> Tuple[float, float]:
"""
Calculate adaptive SL/TP using direct ATR multipliers.
Provides robust stops that sit outside normal market noise.
SL: 2.5x ATR
TP: 4.0x ATR
"""
current_price = df['close'].iloc[-1]
try:
atr = self.calculate_atr(df)
atr_pct = atr / current_price
# Use direct ATR multipliers for robust crypto stops
sl_pct = atr_pct * 2.5
tp_pct = atr_pct * 4.0
# Clamp to min/max safety rails
sl_pct = np.clip(sl_pct, self.min_sl_pct, self.max_sl_pct)
tp_pct = np.clip(tp_pct, self.min_tp_pct, self.max_tp_pct)
logger.info(
f"📊 Adaptive SL/TP: ATR={atr:.2f} ({atr_pct:.2%}) "
f"→ SL={sl_pct:.2%}, TP={tp_pct:.2%}"
)
return sl_pct, tp_pct
except Exception as e:
logger.warning(f"Failed to calc ATR-based SL/TP: {e}. Using base defaults.")
return self.base_sl_pct, self.base_tp_pct
def get_structural_sl_tp(self, df: pd.DataFrame, trade_type: str = "long", symbol: str = "") -> Tuple[float, float]:
"""
Calculate Structural SL/TP using VWAP and recent swing highs/lows.
Places the stop loss just beyond the nearest structural support/resistance
to prevent being wicked out by noise.
Uses asset-specific base parameters for min/max clamping.
"""
if len(df) < 24:
return self.get_adaptive_sl_tp(df, trade_type)
# Get asset-specific base parameters for appropriate min/max clamping
asset_sl_base, asset_tp_base = self.get_asset_specific_params(symbol) if symbol else (self.base_sl_pct, self.base_tp_pct)
current_price = df['close'].iloc[-1]
try:
# 1. Calculate VWAP (approximate support/resistance)
# typical price = (H+L+C)/3
tp = (df['high'] + df['low'] + df['close']) / 3
vwap = (tp * df['volume']).sum() / df['volume'].sum() if df['volume'].sum() > 0 else current_price
# 2. Get local swings (last 24 periods)
recent_low = df['low'].tail(24).min()
recent_high = df['high'].tail(24).max()
# 3. Calculate ATR for a small buffer (0.5x ATR buffer past structure)
atr = self.calculate_atr(df)
buffer = atr * 0.5
if trade_type == "long":
# For LONG: SL should be slightly below the nearest structure (VWAP or Swing Low)
# Pick whichever is closer to the current price, but below it.
structures_below = [p for p in [vwap, recent_low] if p < current_price]
if structures_below:
nearest_support = max(structures_below) # highest support below us
sl_price = nearest_support - buffer
else:
sl_price = current_price - (atr * 2.5) # fallback
# Convert price to percentage Drop
sl_pct = (current_price - sl_price) / current_price
# TP: Target the recent high, or default 4x ATR
tp_price = max(recent_high, current_price + (atr * 4))
tp_pct = (tp_price - current_price) / current_price
else: # SHORT
# For SHORT: SL slightly above nearest structural resistance
structures_above = [p for p in [vwap, recent_high] if p > current_price]
if structures_above:
nearest_resistance = min(structures_above) # lowest resistance above us
sl_price = nearest_resistance + buffer
else:
sl_price = current_price + (atr * 2.5) # fallback
# Convert price to percentage Rise
sl_pct = (sl_price - current_price) / current_price
# TP: Target recent low, or default 4x ATR
tp_price = min(recent_low, current_price - (atr * 4))
tp_pct = (current_price - tp_price) / current_price
# Asset-specific soft clamping (allow some flexibility around base params)
# Use base params as anchor, but allow 50% flexibility
if symbol:
min_sl = asset_sl_base * 0.5 # e.g., BTC 1.5% → min 0.75%
max_sl = asset_sl_base * 2.0 # e.g., BTC 1.5% → max 3.0%
min_tp = asset_tp_base * 0.6 # e.g., BTC 3.5% → min 2.1%
max_tp = asset_tp_base * 2.0 # e.g., BTC 3.5% → max 7.0%
sl_pct = np.clip(sl_pct, min_sl, max_sl)
tp_pct = np.clip(tp_pct, min_tp, max_tp)
else:
# Fallback to global limits if no symbol provided
sl_pct = np.clip(sl_pct, self.min_sl_pct, self.max_sl_pct)
tp_pct = np.clip(tp_pct, self.min_tp_pct, self.max_tp_pct)
logger.info(
f"🏛️ Structural SL/TP [{symbol or 'UNKNOWN'}] (VWAP: ${vwap:.2f}): "
f"SL={sl_pct:.2%} (${sl_price:.2f}), TP={tp_pct:.2%} (${tp_price:.2f})"
)
return sl_pct, tp_pct
except Exception as e:
logger.warning(f"Failed to calc Structural SL/TP: {e}. Falling back to ATR.")
return self.get_adaptive_sl_tp(df, trade_type)
def calculate_kelly_fraction(self) -> float:
"""
Calculate optimal position size using Kelly Criterion.
Kelly = (Win% * Avg_Win / Avg_Loss) - (1 - Win%) / (Avg_Win / Avg_Loss)
Or simplified: Kelly = Win% - (Loss% / Win/Loss Ratio)
"""
if len(self.trade_history) < 10:
# Not enough history, use base position size
return self.base_position_size
# Calculate statistics
wins = [t for t in self.trade_history if t > 0]
losses = [t for t in self.trade_history if t < 0]
if not wins or not losses:
return self.base_position_size
win_rate = len(wins) / len(self.trade_history)
avg_win = np.mean(wins)
avg_loss = abs(np.mean(losses))
# Kelly formula
if avg_loss == 0:
return self.max_position_size
win_loss_ratio = avg_win / avg_loss
kelly = win_rate - ((1 - win_rate) / win_loss_ratio)
# Use half-Kelly for safety (more conservative)
half_kelly = kelly * 0.5
# Clamp to reasonable range
position_size = np.clip(half_kelly, self.min_position_size, self.max_position_size)
logger.info(
f"📊 Kelly Sizing: Win%={win_rate:.1%}, W/L Ratio={win_loss_ratio:.2f}, "
f"Kelly={kelly:.2%}, Half-Kelly={position_size:.2%}"
)
return position_size
def record_trade(self, pnl_pct: float):
"""Record trade result for Kelly calculation."""
self.trade_history.append(pnl_pct)
# Keep only last 50 trades for recency
if len(self.trade_history) > 50:
self.trade_history = self.trade_history[-50:]
def get_trailing_stop(
self,
entry_price: float,
current_price: float,
highest_price: float,
trade_type: str = "long",
base_trailing_pct: float = 0.015
) -> Tuple[float, bool]:
"""
Calculate trailing stop price and whether it's triggered.
Args:
entry_price: Position entry price
current_price: Current market price
highest_price: Highest price since entry (for longs)
trade_type: "long" or "short"
base_trailing_pct: Trailing stop percentage
Returns:
Tuple of (trailing_stop_price, is_triggered)
"""
if trade_type == "long":
# For longs, trail from the highest price
trailing_stop = highest_price * (1 - base_trailing_pct)
triggered = current_price <= trailing_stop
else:
# For shorts, trail from the lowest price
trailing_stop = highest_price * (1 + base_trailing_pct) # highest_price is actually lowest for shorts
triggered = current_price >= trailing_stop
return trailing_stop, triggered
def get_risk_parameters(self, df: pd.DataFrame, trade_type: str = "long") -> RiskParameters:
"""
Get all risk parameters for a trade.
Returns complete RiskParameters with adaptive values.
"""
# Adaptive SL/TP
sl_pct, tp_pct = self.get_adaptive_sl_tp(df, trade_type)
# Kelly position sizing
if self.use_kelly:
position_size = self.calculate_kelly_fraction()
else:
position_size = self.base_position_size
# Trailing stop
trailing_pct = sl_pct if self.use_trailing else None
return RiskParameters(
stop_loss_pct=sl_pct,
take_profit_pct=tp_pct,
position_size=position_size,
trailing_stop_pct=trailing_pct,
risk_reward_ratio=tp_pct / sl_pct
)
def get_summary(self) -> Dict:
"""Get summary of current risk parameters."""
if len(self.trade_history) >= 10:
wins = [t for t in self.trade_history if t > 0]
losses = [t for t in self.trade_history if t < 0]
win_rate = len(wins) / len(self.trade_history) if self.trade_history else 0
kelly = self.calculate_kelly_fraction()
else:
win_rate = 0
kelly = self.base_position_size
return {
'trades_recorded': len(self.trade_history),
'win_rate': win_rate,
'kelly_fraction': kelly,
'use_kelly': self.use_kelly,
'use_trailing': self.use_trailing,
}