deploy: 2026-03-20 07:49
This commit is contained in:
0
risk/__init__.py
Normal file
0
risk/__init__.py
Normal file
67
risk/drawdown_monitor.py
Normal file
67
risk/drawdown_monitor.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Drawdown monitoring and equity curve tracking."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class DrawdownState:
|
||||
"""Current drawdown statistics."""
|
||||
|
||||
current_drawdown: float = 0.0
|
||||
max_drawdown: float = 0.0
|
||||
peak_equity: float = 0.0
|
||||
trough_equity: float = 0.0
|
||||
is_in_drawdown: bool = False
|
||||
|
||||
|
||||
class DrawdownMonitor:
|
||||
"""Track equity curve and compute drawdown metrics."""
|
||||
|
||||
def __init__(self, max_drawdown_limit: float = 0.15):
|
||||
self.limit = max_drawdown_limit
|
||||
self._equity_curve: List[float] = []
|
||||
self._peak: float = 0.0
|
||||
self._max_dd: float = 0.0
|
||||
|
||||
def update(self, equity: float) -> DrawdownState:
|
||||
"""Record new equity point and recalculate drawdown."""
|
||||
self._equity_curve.append(equity)
|
||||
|
||||
if equity > self._peak:
|
||||
self._peak = equity
|
||||
|
||||
current_dd = (self._peak - equity) / self._peak if self._peak > 0 else 0.0
|
||||
if current_dd > self._max_dd:
|
||||
self._max_dd = current_dd
|
||||
|
||||
state = DrawdownState(
|
||||
current_drawdown=current_dd,
|
||||
max_drawdown=self._max_dd,
|
||||
peak_equity=self._peak,
|
||||
trough_equity=min(self._equity_curve) if self._equity_curve else 0.0,
|
||||
is_in_drawdown=current_dd > 0,
|
||||
)
|
||||
|
||||
if current_dd >= self.limit:
|
||||
logger.warning(
|
||||
"DRAWDOWN ALERT: {:.2%} >= limit {:.2%}", current_dd, self.limit
|
||||
)
|
||||
|
||||
return state
|
||||
|
||||
@property
|
||||
def equity_curve(self) -> List[float]:
|
||||
return list(self._equity_curve)
|
||||
|
||||
@property
|
||||
def max_drawdown(self) -> float:
|
||||
return self._max_dd
|
||||
|
||||
def is_breached(self) -> bool:
|
||||
"""Return True if max drawdown limit has been exceeded."""
|
||||
return self._max_dd >= self.limit
|
||||
59
risk/position_sizing.py
Normal file
59
risk/position_sizing.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Position sizing utilities.
|
||||
|
||||
Provides various sizing methods beyond the basic risk-percentage approach.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class SizingMethod(str, Enum):
|
||||
FIXED_RISK = "fixed_risk"
|
||||
FIXED_AMOUNT = "fixed_amount"
|
||||
KELLY = "kelly"
|
||||
|
||||
|
||||
def fixed_risk_size(
|
||||
balance: float,
|
||||
entry_price: float,
|
||||
stop_loss: float,
|
||||
risk_pct: float = 0.02,
|
||||
) -> float:
|
||||
"""Position size = (balance * risk%) / |entry - SL|."""
|
||||
price_risk = abs(entry_price - stop_loss)
|
||||
if price_risk == 0:
|
||||
return 0.0
|
||||
return round((balance * risk_pct) / price_risk, 8)
|
||||
|
||||
|
||||
def fixed_amount_size(
|
||||
amount_usd: float,
|
||||
entry_price: float,
|
||||
) -> float:
|
||||
"""Fixed USD amount converted to asset quantity."""
|
||||
if entry_price <= 0:
|
||||
return 0.0
|
||||
return round(amount_usd / entry_price, 8)
|
||||
|
||||
|
||||
def kelly_size(
|
||||
win_rate: float,
|
||||
avg_win: float,
|
||||
avg_loss: float,
|
||||
balance: float,
|
||||
entry_price: float,
|
||||
fraction: float = 0.5,
|
||||
) -> float:
|
||||
"""Half-Kelly sizing for conservative edge exploitation.
|
||||
|
||||
Kelly% = W - (1-W)/R where R = avg_win/avg_loss
|
||||
We use fraction (default 0.5) of full Kelly for safety.
|
||||
"""
|
||||
if avg_loss == 0 or entry_price <= 0:
|
||||
return 0.0
|
||||
|
||||
r = avg_win / avg_loss
|
||||
kelly_pct = win_rate - (1 - win_rate) / r
|
||||
kelly_pct = max(0.0, min(kelly_pct * fraction, 0.1)) # cap at 10%
|
||||
return round((balance * kelly_pct) / entry_price, 8)
|
||||
224
risk/risk_manager.py
Normal file
224
risk/risk_manager.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""Risk management engine.
|
||||
|
||||
Controls position sizing, daily loss limits, drawdown monitoring,
|
||||
and trade approval to protect capital.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from config import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class RiskApproval:
|
||||
"""Result of a trade approval check."""
|
||||
|
||||
approved: bool
|
||||
reason: str = ""
|
||||
position_size: float = 0.0
|
||||
risk_amount: float = 0.0
|
||||
|
||||
|
||||
class RiskManager:
|
||||
"""Central risk management engine.
|
||||
|
||||
Enforces:
|
||||
- Per-trade risk limit (default 2% of balance)
|
||||
- Daily max loss (default 5%)
|
||||
- Max concurrent positions (default 3)
|
||||
- Max leverage (default 3x)
|
||||
- Max drawdown (default 15%) -- triggers emergency stop
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_risk_per_trade: float | None = None,
|
||||
max_daily_loss: float | None = None,
|
||||
max_concurrent_positions: int | None = None,
|
||||
max_leverage: int | None = None,
|
||||
max_drawdown: float | None = None,
|
||||
):
|
||||
self.max_risk_per_trade = max_risk_per_trade or settings.MAX_RISK_PER_TRADE
|
||||
self.max_daily_loss = max_daily_loss or settings.MAX_DAILY_LOSS
|
||||
self.max_concurrent_positions = (
|
||||
max_concurrent_positions or settings.MAX_CONCURRENT_POSITIONS
|
||||
)
|
||||
self.max_leverage = max_leverage or settings.MAX_LEVERAGE
|
||||
self.max_drawdown = max_drawdown or settings.MAX_DRAWDOWN
|
||||
|
||||
# Internal state
|
||||
self._daily_pnl: Dict[str, float] = {} # date_str -> cumulative pnl
|
||||
self._open_positions: int = 0
|
||||
self._peak_equity: float = 0.0
|
||||
self._is_stopped: bool = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Trade approval
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def approve_trade(
|
||||
self,
|
||||
entry_price: float,
|
||||
stop_loss: float,
|
||||
balance: float,
|
||||
current_open_positions: int | None = None,
|
||||
) -> RiskApproval:
|
||||
"""Decide whether a new trade is allowed.
|
||||
|
||||
Checks:
|
||||
1. Bot not in emergency-stop state
|
||||
2. Daily loss limit not exceeded
|
||||
3. Concurrent position limit not exceeded
|
||||
4. Drawdown limit not exceeded
|
||||
5. Position size within acceptable bounds
|
||||
"""
|
||||
if self._is_stopped:
|
||||
return RiskApproval(False, "Bot is in emergency stop mode")
|
||||
|
||||
# Daily loss check
|
||||
today = date.today().isoformat()
|
||||
daily = self._daily_pnl.get(today, 0.0)
|
||||
if daily < 0 and abs(daily) >= balance * self.max_daily_loss:
|
||||
return RiskApproval(False, f"Daily loss limit reached: {daily:.2f}")
|
||||
|
||||
# Concurrent positions
|
||||
open_pos = (
|
||||
current_open_positions
|
||||
if current_open_positions is not None
|
||||
else self._open_positions
|
||||
)
|
||||
if open_pos >= self.max_concurrent_positions:
|
||||
return RiskApproval(
|
||||
False,
|
||||
f"Max concurrent positions reached: {open_pos}/{self.max_concurrent_positions}",
|
||||
)
|
||||
|
||||
# Drawdown
|
||||
if self._peak_equity > 0:
|
||||
drawdown = (self._peak_equity - balance) / self._peak_equity
|
||||
if drawdown >= self.max_drawdown:
|
||||
return RiskApproval(
|
||||
False, f"Max drawdown reached: {drawdown:.2%} >= {self.max_drawdown:.2%}"
|
||||
)
|
||||
|
||||
# Position sizing
|
||||
risk_pct = min(self.max_risk_per_trade, 0.05) # hard cap at 5%
|
||||
position_size = self.calculate_position_size(
|
||||
balance, entry_price, stop_loss, risk_pct
|
||||
)
|
||||
|
||||
if position_size <= 0:
|
||||
return RiskApproval(False, "Calculated position size is zero or negative")
|
||||
|
||||
risk_amount = balance * risk_pct
|
||||
|
||||
logger.info(
|
||||
"Trade APPROVED: size={:.6f}, risk={:.2f} ({:.1%})",
|
||||
position_size, risk_amount, risk_pct,
|
||||
)
|
||||
return RiskApproval(
|
||||
approved=True,
|
||||
position_size=position_size,
|
||||
risk_amount=risk_amount,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Position sizing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def calculate_position_size(
|
||||
self,
|
||||
balance: float,
|
||||
entry_price: float,
|
||||
stop_loss: float,
|
||||
risk_pct: float | None = None,
|
||||
) -> float:
|
||||
"""Calculate position size based on risk percentage.
|
||||
|
||||
Formula: size = (balance * risk_pct) / |entry - stop_loss|
|
||||
"""
|
||||
risk = risk_pct or self.max_risk_per_trade
|
||||
price_risk = abs(entry_price - stop_loss)
|
||||
if price_risk == 0:
|
||||
return 0.0
|
||||
|
||||
risk_amount = balance * risk
|
||||
size = risk_amount / price_risk
|
||||
|
||||
# Cap so that total cost doesn't exceed available balance
|
||||
# Reserve room for concurrent positions
|
||||
max_per_position = balance / self.max_concurrent_positions
|
||||
max_size = max_per_position / entry_price
|
||||
if size * entry_price > max_per_position:
|
||||
size = max_size
|
||||
|
||||
return round(size, 8)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PnL tracking
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def update_daily_pnl(self, pnl: float) -> None:
|
||||
"""Record realised PnL for the current day."""
|
||||
today = date.today().isoformat()
|
||||
self._daily_pnl[today] = self._daily_pnl.get(today, 0.0) + pnl
|
||||
logger.debug("Daily PnL updated: {} = {:.2f}", today, self._daily_pnl[today])
|
||||
|
||||
def update_equity(self, equity: float) -> None:
|
||||
"""Track peak equity for drawdown calculation."""
|
||||
if equity > self._peak_equity:
|
||||
self._peak_equity = equity
|
||||
|
||||
def get_daily_pnl(self) -> float:
|
||||
"""Return today's cumulative PnL."""
|
||||
today = date.today().isoformat()
|
||||
return self._daily_pnl.get(today, 0.0)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Drawdown
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def check_drawdown(self, current_equity: float) -> bool:
|
||||
"""Return True if max drawdown has been breached."""
|
||||
if self._peak_equity <= 0:
|
||||
return False
|
||||
drawdown = (self._peak_equity - current_equity) / self._peak_equity
|
||||
return drawdown >= self.max_drawdown
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Emergency stop
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def emergency_stop(self) -> None:
|
||||
"""Activate emergency stop -- no new trades allowed."""
|
||||
self._is_stopped = True
|
||||
logger.critical("EMERGENCY STOP activated")
|
||||
|
||||
def reset_emergency(self) -> None:
|
||||
"""Clear emergency stop state."""
|
||||
self._is_stopped = False
|
||||
logger.warning("Emergency stop cleared")
|
||||
|
||||
@property
|
||||
def is_stopped(self) -> bool:
|
||||
return self._is_stopped
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Position tracking helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def on_position_opened(self) -> None:
|
||||
self._open_positions += 1
|
||||
|
||||
def on_position_closed(self) -> None:
|
||||
self._open_positions = max(0, self._open_positions - 1)
|
||||
|
||||
@property
|
||||
def open_position_count(self) -> int:
|
||||
return self._open_positions
|
||||
Reference in New Issue
Block a user