145 lines
4.5 KiB
Python
145 lines
4.5 KiB
Python
|
|
"""Position sizing rules."""
|
||
|
|
|
||
|
|
from decimal import Decimal
|
||
|
|
from typing import Optional
|
||
|
|
from src.core.config import get_config
|
||
|
|
from src.core.logger import get_logger
|
||
|
|
from src.exchanges.base import BaseExchangeAdapter
|
||
|
|
|
||
|
|
logger = get_logger(__name__)
|
||
|
|
|
||
|
|
|
||
|
|
class PositionSizingManager:
|
||
|
|
"""Manages position sizing calculations."""
|
||
|
|
|
||
|
|
def __init__(self):
|
||
|
|
"""Initialize position sizing manager."""
|
||
|
|
self.config = get_config()
|
||
|
|
self.logger = get_logger(__name__)
|
||
|
|
|
||
|
|
def calculate_size(
|
||
|
|
self,
|
||
|
|
symbol: str,
|
||
|
|
price: Decimal,
|
||
|
|
balance: Decimal,
|
||
|
|
risk_percent: Optional[Decimal] = None,
|
||
|
|
exchange_adapter: Optional[BaseExchangeAdapter] = None
|
||
|
|
) -> Decimal:
|
||
|
|
"""Calculate position size, accounting for fees.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
symbol: Trading symbol
|
||
|
|
price: Entry price
|
||
|
|
balance: Available balance
|
||
|
|
risk_percent: Risk percentage (uses config default if None)
|
||
|
|
exchange_adapter: Exchange adapter for fee calculation (optional)
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Calculated position size
|
||
|
|
"""
|
||
|
|
if risk_percent is None:
|
||
|
|
risk_percent = Decimal(str(
|
||
|
|
self.config.get("risk.position_size_percent", 2.0)
|
||
|
|
)) / 100
|
||
|
|
|
||
|
|
position_value = balance * risk_percent
|
||
|
|
|
||
|
|
# Account for fees by reserving fee amount
|
||
|
|
from src.trading.fee_calculator import get_fee_calculator
|
||
|
|
fee_calculator = get_fee_calculator()
|
||
|
|
|
||
|
|
# Reserve ~0.4% for round-trip fees
|
||
|
|
fee_reserve = fee_calculator.calculate_fee_reserve(
|
||
|
|
position_value=position_value,
|
||
|
|
exchange_adapter=exchange_adapter,
|
||
|
|
reserve_percent=0.004 # 0.4% for round-trip
|
||
|
|
)
|
||
|
|
|
||
|
|
# Adjust position value to account for fees
|
||
|
|
adjusted_position_value = position_value - fee_reserve
|
||
|
|
|
||
|
|
if price > 0:
|
||
|
|
quantity = adjusted_position_value / price
|
||
|
|
return max(Decimal(0), quantity) # Ensure non-negative
|
||
|
|
|
||
|
|
return Decimal(0)
|
||
|
|
|
||
|
|
def calculate_kelly_criterion(
|
||
|
|
self,
|
||
|
|
win_rate: float,
|
||
|
|
avg_win: float,
|
||
|
|
avg_loss: float
|
||
|
|
) -> Decimal:
|
||
|
|
"""Calculate position size using Kelly Criterion.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
win_rate: Win rate (0.0 to 1.0)
|
||
|
|
avg_win: Average win amount
|
||
|
|
avg_loss: Average loss amount
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Kelly percentage
|
||
|
|
"""
|
||
|
|
if avg_loss == 0:
|
||
|
|
return Decimal(0)
|
||
|
|
|
||
|
|
kelly = (win_rate * avg_win - (1 - win_rate) * avg_loss) / avg_win
|
||
|
|
# Use fractional Kelly (half) for safety
|
||
|
|
return Decimal(str(kelly / 2))
|
||
|
|
|
||
|
|
def validate_position_size(
|
||
|
|
self,
|
||
|
|
symbol: str,
|
||
|
|
quantity: Decimal,
|
||
|
|
price: Decimal,
|
||
|
|
balance: Decimal,
|
||
|
|
exchange_adapter: Optional[BaseExchangeAdapter] = None
|
||
|
|
) -> bool:
|
||
|
|
"""Validate position size against limits, accounting for fees.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
symbol: Trading symbol
|
||
|
|
quantity: Position quantity
|
||
|
|
price: Entry price
|
||
|
|
balance: Available balance
|
||
|
|
exchange_adapter: Exchange adapter for fee calculation (optional)
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
True if position size is valid
|
||
|
|
"""
|
||
|
|
position_value = quantity * price
|
||
|
|
|
||
|
|
# Calculate estimated fee for this trade
|
||
|
|
from src.trading.fee_calculator import get_fee_calculator
|
||
|
|
from src.core.database import OrderType
|
||
|
|
|
||
|
|
fee_calculator = get_fee_calculator()
|
||
|
|
estimated_fee = fee_calculator.calculate_fee(
|
||
|
|
quantity=quantity,
|
||
|
|
price=price,
|
||
|
|
order_type=OrderType.MARKET, # Use market as worst case
|
||
|
|
exchange_adapter=exchange_adapter
|
||
|
|
)
|
||
|
|
|
||
|
|
total_cost = position_value + estimated_fee
|
||
|
|
|
||
|
|
# Check if exceeds available balance (including fees)
|
||
|
|
if total_cost > balance:
|
||
|
|
self.logger.warning(
|
||
|
|
f"Position cost {total_cost} (value: {position_value}, fee: {estimated_fee}) "
|
||
|
|
f"exceeds balance {balance}"
|
||
|
|
)
|
||
|
|
return False
|
||
|
|
|
||
|
|
# Check against risk limits
|
||
|
|
max_position_percent = Decimal(str(
|
||
|
|
self.config.get("risk.position_size_percent", 2.0)
|
||
|
|
)) / 100
|
||
|
|
|
||
|
|
if position_value > balance * max_position_percent:
|
||
|
|
self.logger.warning(f"Position size exceeds risk limit")
|
||
|
|
return False
|
||
|
|
|
||
|
|
return True
|
||
|
|
|