612 lines
20 KiB
Python
612 lines
20 KiB
Python
"""
|
|
Performance analytics for the portfolio system.
|
|
|
|
This module provides comprehensive performance analytics including
|
|
returns calculation, risk metrics, trade statistics, and equity curve generation.
|
|
"""
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timedelta
|
|
from decimal import Decimal
|
|
from typing import List, Dict, Any, Optional, Tuple
|
|
import logging
|
|
import math
|
|
|
|
from .exceptions import CalculationError, ValidationError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class TradeRecord:
|
|
"""
|
|
Record of a completed trade.
|
|
|
|
Attributes:
|
|
ticker: Security ticker symbol
|
|
entry_date: Date position was opened
|
|
exit_date: Date position was closed
|
|
entry_price: Entry price
|
|
exit_price: Exit price
|
|
quantity: Quantity traded
|
|
pnl: Profit/loss from the trade
|
|
pnl_percent: Profit/loss as percentage
|
|
commission: Total commission paid
|
|
holding_period: Number of days held
|
|
is_win: Whether the trade was profitable
|
|
"""
|
|
|
|
ticker: str
|
|
entry_date: datetime
|
|
exit_date: datetime
|
|
entry_price: Decimal
|
|
exit_price: Decimal
|
|
quantity: Decimal
|
|
pnl: Decimal
|
|
pnl_percent: Decimal
|
|
commission: Decimal
|
|
holding_period: int
|
|
is_win: bool
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""Convert trade record to dictionary."""
|
|
return {
|
|
'ticker': self.ticker,
|
|
'entry_date': self.entry_date.isoformat(),
|
|
'exit_date': self.exit_date.isoformat(),
|
|
'entry_price': str(self.entry_price),
|
|
'exit_price': str(self.exit_price),
|
|
'quantity': str(self.quantity),
|
|
'pnl': str(self.pnl),
|
|
'pnl_percent': str(self.pnl_percent),
|
|
'commission': str(self.commission),
|
|
'holding_period': self.holding_period,
|
|
'is_win': self.is_win,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class PerformanceMetrics:
|
|
"""
|
|
Comprehensive performance metrics for a portfolio.
|
|
|
|
Attributes:
|
|
total_return: Total return (as fraction)
|
|
annualized_return: Annualized return
|
|
total_trades: Total number of trades
|
|
winning_trades: Number of winning trades
|
|
losing_trades: Number of losing trades
|
|
win_rate: Percentage of winning trades
|
|
profit_factor: Ratio of gross profits to gross losses
|
|
average_win: Average profit from winning trades
|
|
average_loss: Average loss from losing trades
|
|
largest_win: Largest single winning trade
|
|
largest_loss: Largest single losing trade
|
|
sharpe_ratio: Risk-adjusted return metric
|
|
sortino_ratio: Downside risk-adjusted return metric
|
|
max_drawdown: Maximum peak-to-trough decline
|
|
max_drawdown_duration: Duration of max drawdown in days
|
|
calmar_ratio: Return / Max Drawdown
|
|
volatility: Annualized volatility
|
|
total_commission: Total commission paid
|
|
"""
|
|
|
|
total_return: Decimal
|
|
annualized_return: Decimal
|
|
total_trades: int
|
|
winning_trades: int
|
|
losing_trades: int
|
|
win_rate: Decimal
|
|
profit_factor: Decimal
|
|
average_win: Decimal
|
|
average_loss: Decimal
|
|
largest_win: Decimal
|
|
largest_loss: Decimal
|
|
sharpe_ratio: Decimal
|
|
sortino_ratio: Decimal
|
|
max_drawdown: Decimal
|
|
max_drawdown_duration: int
|
|
calmar_ratio: Decimal
|
|
volatility: Decimal
|
|
total_commission: Decimal
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""Convert metrics to dictionary."""
|
|
return {
|
|
'total_return': str(self.total_return),
|
|
'annualized_return': str(self.annualized_return),
|
|
'total_trades': self.total_trades,
|
|
'winning_trades': self.winning_trades,
|
|
'losing_trades': self.losing_trades,
|
|
'win_rate': str(self.win_rate),
|
|
'profit_factor': str(self.profit_factor),
|
|
'average_win': str(self.average_win),
|
|
'average_loss': str(self.average_loss),
|
|
'largest_win': str(self.largest_win),
|
|
'largest_loss': str(self.largest_loss),
|
|
'sharpe_ratio': str(self.sharpe_ratio),
|
|
'sortino_ratio': str(self.sortino_ratio),
|
|
'max_drawdown': str(self.max_drawdown),
|
|
'max_drawdown_duration': self.max_drawdown_duration,
|
|
'calmar_ratio': str(self.calmar_ratio),
|
|
'volatility': str(self.volatility),
|
|
'total_commission': str(self.total_commission),
|
|
}
|
|
|
|
|
|
class PerformanceAnalytics:
|
|
"""
|
|
Analyzes portfolio performance and generates metrics.
|
|
|
|
This class provides methods to calculate various performance metrics,
|
|
generate equity curves, and analyze trade statistics.
|
|
"""
|
|
|
|
def __init__(self):
|
|
"""Initialize the performance analytics engine."""
|
|
self.equity_curve: List[Tuple[datetime, Decimal]] = []
|
|
self.returns: List[Decimal] = []
|
|
logger.info("Initialized PerformanceAnalytics")
|
|
|
|
def calculate_returns(
|
|
self,
|
|
equity_curve: List[Tuple[datetime, Decimal]]
|
|
) -> List[Decimal]:
|
|
"""
|
|
Calculate periodic returns from an equity curve.
|
|
|
|
Args:
|
|
equity_curve: List of (datetime, value) tuples
|
|
|
|
Returns:
|
|
List of periodic returns
|
|
|
|
Raises:
|
|
ValidationError: If equity curve is invalid
|
|
"""
|
|
if len(equity_curve) < 2:
|
|
return []
|
|
|
|
try:
|
|
returns = []
|
|
for i in range(1, len(equity_curve)):
|
|
prev_value = equity_curve[i - 1][1]
|
|
curr_value = equity_curve[i][1]
|
|
|
|
if prev_value == 0:
|
|
continue
|
|
|
|
ret = (curr_value - prev_value) / prev_value
|
|
returns.append(ret)
|
|
|
|
return returns
|
|
|
|
except (IndexError, ZeroDivisionError, TypeError) as e:
|
|
raise CalculationError(f"Returns calculation failed: {e}")
|
|
|
|
def calculate_total_return(
|
|
self,
|
|
initial_value: Decimal,
|
|
final_value: Decimal
|
|
) -> Decimal:
|
|
"""
|
|
Calculate total return.
|
|
|
|
Args:
|
|
initial_value: Initial portfolio value
|
|
final_value: Final portfolio value
|
|
|
|
Returns:
|
|
Total return as a fraction
|
|
|
|
Raises:
|
|
ValidationError: If values are invalid
|
|
"""
|
|
if initial_value <= 0:
|
|
raise ValidationError("Initial value must be positive")
|
|
|
|
return (final_value - initial_value) / initial_value
|
|
|
|
def calculate_annualized_return(
|
|
self,
|
|
total_return: Decimal,
|
|
days: int
|
|
) -> Decimal:
|
|
"""
|
|
Calculate annualized return from total return.
|
|
|
|
Args:
|
|
total_return: Total return as a fraction
|
|
days: Number of days in the period
|
|
|
|
Returns:
|
|
Annualized return
|
|
|
|
Raises:
|
|
ValidationError: If inputs are invalid
|
|
"""
|
|
if days <= 0:
|
|
raise ValidationError("Days must be positive")
|
|
|
|
years = Decimal(days) / Decimal('365.25')
|
|
|
|
if years == 0:
|
|
return Decimal('0')
|
|
|
|
# Annualized return = (1 + total_return) ^ (1/years) - 1
|
|
try:
|
|
annualized = Decimal(
|
|
math.pow(float(1 + total_return), float(1 / years))
|
|
) - 1
|
|
return annualized
|
|
except (ValueError, OverflowError) as e:
|
|
raise CalculationError(f"Annualized return calculation failed: {e}")
|
|
|
|
def calculate_volatility(
|
|
self,
|
|
returns: List[Decimal]
|
|
) -> Decimal:
|
|
"""
|
|
Calculate annualized volatility.
|
|
|
|
Args:
|
|
returns: List of periodic returns
|
|
|
|
Returns:
|
|
Annualized volatility (standard deviation)
|
|
|
|
Raises:
|
|
ValidationError: If returns is empty
|
|
"""
|
|
if not returns:
|
|
raise ValidationError("Returns list cannot be empty")
|
|
|
|
try:
|
|
# Calculate mean
|
|
mean = sum(returns) / len(returns)
|
|
|
|
# Calculate variance
|
|
variance = sum((r - mean) ** 2 for r in returns) / len(returns)
|
|
|
|
# Calculate standard deviation
|
|
std_dev = Decimal(math.sqrt(float(variance)))
|
|
|
|
# Annualize (assuming daily returns)
|
|
annualized_vol = std_dev * Decimal(math.sqrt(252))
|
|
|
|
return annualized_vol
|
|
|
|
except (ValueError, TypeError) as e:
|
|
raise CalculationError(f"Volatility calculation failed: {e}")
|
|
|
|
def calculate_trade_statistics(
|
|
self,
|
|
trades: List[TradeRecord]
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Calculate comprehensive trade statistics.
|
|
|
|
Args:
|
|
trades: List of trade records
|
|
|
|
Returns:
|
|
Dictionary of trade statistics
|
|
|
|
Raises:
|
|
ValidationError: If trades list is invalid
|
|
"""
|
|
if not trades:
|
|
return {
|
|
'total_trades': 0,
|
|
'winning_trades': 0,
|
|
'losing_trades': 0,
|
|
'win_rate': Decimal('0'),
|
|
'profit_factor': Decimal('0'),
|
|
'average_win': Decimal('0'),
|
|
'average_loss': Decimal('0'),
|
|
'largest_win': Decimal('0'),
|
|
'largest_loss': Decimal('0'),
|
|
'average_holding_period': 0,
|
|
'total_commission': Decimal('0'),
|
|
}
|
|
|
|
try:
|
|
winning_trades = [t for t in trades if t.is_win]
|
|
losing_trades = [t for t in trades if not t.is_win]
|
|
|
|
total_trades = len(trades)
|
|
num_wins = len(winning_trades)
|
|
num_losses = len(losing_trades)
|
|
|
|
# Win rate
|
|
win_rate = Decimal(num_wins) / Decimal(total_trades) if total_trades > 0 else Decimal('0')
|
|
|
|
# Profit factor
|
|
gross_profit = sum(t.pnl for t in winning_trades)
|
|
gross_loss = abs(sum(t.pnl for t in losing_trades))
|
|
profit_factor = gross_profit / gross_loss if gross_loss > 0 else Decimal('0')
|
|
|
|
# Average win/loss
|
|
average_win = gross_profit / num_wins if num_wins > 0 else Decimal('0')
|
|
average_loss = gross_loss / num_losses if num_losses > 0 else Decimal('0')
|
|
|
|
# Largest win/loss
|
|
largest_win = max((t.pnl for t in winning_trades), default=Decimal('0'))
|
|
largest_loss = abs(min((t.pnl for t in losing_trades), default=Decimal('0')))
|
|
|
|
# Average holding period
|
|
avg_holding = sum(t.holding_period for t in trades) / total_trades
|
|
|
|
# Total commission
|
|
total_commission = sum(t.commission for t in trades)
|
|
|
|
return {
|
|
'total_trades': total_trades,
|
|
'winning_trades': num_wins,
|
|
'losing_trades': num_losses,
|
|
'win_rate': win_rate,
|
|
'profit_factor': profit_factor,
|
|
'average_win': average_win,
|
|
'average_loss': average_loss,
|
|
'largest_win': largest_win,
|
|
'largest_loss': largest_loss,
|
|
'average_holding_period': int(avg_holding),
|
|
'total_commission': total_commission,
|
|
}
|
|
|
|
except (ValueError, TypeError, ZeroDivisionError) as e:
|
|
raise CalculationError(f"Trade statistics calculation failed: {e}")
|
|
|
|
def generate_performance_metrics(
|
|
self,
|
|
equity_curve: List[Tuple[datetime, Decimal]],
|
|
trades: List[TradeRecord],
|
|
initial_capital: Decimal,
|
|
risk_free_rate: Decimal = Decimal('0.02')
|
|
) -> PerformanceMetrics:
|
|
"""
|
|
Generate comprehensive performance metrics.
|
|
|
|
Args:
|
|
equity_curve: List of (datetime, value) tuples
|
|
trades: List of completed trades
|
|
initial_capital: Initial portfolio capital
|
|
risk_free_rate: Annual risk-free rate (default 2%)
|
|
|
|
Returns:
|
|
PerformanceMetrics object
|
|
|
|
Raises:
|
|
ValidationError: If inputs are invalid
|
|
CalculationError: If calculation fails
|
|
"""
|
|
if not equity_curve:
|
|
raise ValidationError("Equity curve cannot be empty")
|
|
|
|
if initial_capital <= 0:
|
|
raise ValidationError("Initial capital must be positive")
|
|
|
|
try:
|
|
# Calculate returns
|
|
returns = self.calculate_returns(equity_curve)
|
|
|
|
# Total return
|
|
final_value = equity_curve[-1][1]
|
|
total_return = self.calculate_total_return(initial_capital, final_value)
|
|
|
|
# Annualized return
|
|
start_date = equity_curve[0][0]
|
|
end_date = equity_curve[-1][0]
|
|
days = (end_date - start_date).days
|
|
annualized_return = self.calculate_annualized_return(total_return, max(days, 1))
|
|
|
|
# Volatility
|
|
volatility = self.calculate_volatility(returns) if returns else Decimal('0')
|
|
|
|
# Sharpe ratio
|
|
from .risk import RiskManager
|
|
risk_manager = RiskManager()
|
|
sharpe = risk_manager.calculate_sharpe_ratio(returns, risk_free_rate) if returns else Decimal('0')
|
|
sortino = risk_manager.calculate_sortino_ratio(returns, risk_free_rate) if returns else Decimal('0')
|
|
|
|
# Max drawdown
|
|
equity_values = [value for _, value in equity_curve]
|
|
max_dd, _, _ = risk_manager.calculate_max_drawdown(equity_values)
|
|
|
|
# Max drawdown duration
|
|
max_dd_duration = self._calculate_max_drawdown_duration(equity_curve)
|
|
|
|
# Calmar ratio
|
|
calmar = abs(annualized_return / max_dd) if max_dd > 0 else Decimal('0')
|
|
|
|
# Trade statistics
|
|
trade_stats = self.calculate_trade_statistics(trades)
|
|
|
|
return PerformanceMetrics(
|
|
total_return=total_return,
|
|
annualized_return=annualized_return,
|
|
total_trades=trade_stats['total_trades'],
|
|
winning_trades=trade_stats['winning_trades'],
|
|
losing_trades=trade_stats['losing_trades'],
|
|
win_rate=trade_stats['win_rate'],
|
|
profit_factor=trade_stats['profit_factor'],
|
|
average_win=trade_stats['average_win'],
|
|
average_loss=trade_stats['average_loss'],
|
|
largest_win=trade_stats['largest_win'],
|
|
largest_loss=trade_stats['largest_loss'],
|
|
sharpe_ratio=sharpe,
|
|
sortino_ratio=sortino,
|
|
max_drawdown=max_dd,
|
|
max_drawdown_duration=max_dd_duration,
|
|
calmar_ratio=calmar,
|
|
volatility=volatility,
|
|
total_commission=trade_stats['total_commission'],
|
|
)
|
|
|
|
except Exception as e:
|
|
raise CalculationError(f"Performance metrics generation failed: {e}")
|
|
|
|
def _calculate_max_drawdown_duration(
|
|
self,
|
|
equity_curve: List[Tuple[datetime, Decimal]]
|
|
) -> int:
|
|
"""
|
|
Calculate the maximum drawdown duration in days.
|
|
|
|
Args:
|
|
equity_curve: List of (datetime, value) tuples
|
|
|
|
Returns:
|
|
Maximum drawdown duration in days
|
|
"""
|
|
if len(equity_curve) < 2:
|
|
return 0
|
|
|
|
max_duration = 0
|
|
peak_value = equity_curve[0][1]
|
|
peak_date = equity_curve[0][0]
|
|
current_duration = 0
|
|
|
|
for date, value in equity_curve:
|
|
if value > peak_value:
|
|
peak_value = value
|
|
peak_date = date
|
|
current_duration = 0
|
|
else:
|
|
current_duration = (date - peak_date).days
|
|
max_duration = max(max_duration, current_duration)
|
|
|
|
return max_duration
|
|
|
|
def calculate_monthly_returns(
|
|
self,
|
|
equity_curve: List[Tuple[datetime, Decimal]]
|
|
) -> Dict[str, Decimal]:
|
|
"""
|
|
Calculate monthly returns from equity curve.
|
|
|
|
Args:
|
|
equity_curve: List of (datetime, value) tuples
|
|
|
|
Returns:
|
|
Dictionary mapping month (YYYY-MM) to return
|
|
|
|
Raises:
|
|
ValidationError: If equity curve is invalid
|
|
"""
|
|
if not equity_curve:
|
|
raise ValidationError("Equity curve cannot be empty")
|
|
|
|
try:
|
|
monthly_returns = {}
|
|
monthly_values = {}
|
|
|
|
# Group values by month
|
|
for date, value in equity_curve:
|
|
month_key = date.strftime('%Y-%m')
|
|
if month_key not in monthly_values:
|
|
monthly_values[month_key] = []
|
|
monthly_values[month_key].append((date, value))
|
|
|
|
# Calculate return for each month
|
|
sorted_months = sorted(monthly_values.keys())
|
|
for i, month in enumerate(sorted_months):
|
|
month_data = monthly_values[month]
|
|
start_value = month_data[0][1]
|
|
end_value = month_data[-1][1]
|
|
|
|
if start_value > 0:
|
|
monthly_return = (end_value - start_value) / start_value
|
|
monthly_returns[month] = monthly_return
|
|
|
|
return monthly_returns
|
|
|
|
except (ValueError, TypeError, ZeroDivisionError) as e:
|
|
raise CalculationError(f"Monthly returns calculation failed: {e}")
|
|
|
|
def calculate_rolling_sharpe(
|
|
self,
|
|
equity_curve: List[Tuple[datetime, Decimal]],
|
|
window_days: int = 252,
|
|
risk_free_rate: Decimal = Decimal('0.02')
|
|
) -> List[Tuple[datetime, Decimal]]:
|
|
"""
|
|
Calculate rolling Sharpe ratio.
|
|
|
|
Args:
|
|
equity_curve: List of (datetime, value) tuples
|
|
window_days: Rolling window size in days
|
|
risk_free_rate: Annual risk-free rate
|
|
|
|
Returns:
|
|
List of (date, sharpe_ratio) tuples
|
|
|
|
Raises:
|
|
ValidationError: If inputs are invalid
|
|
"""
|
|
if not equity_curve:
|
|
raise ValidationError("Equity curve cannot be empty")
|
|
|
|
if window_days < 2:
|
|
raise ValidationError("Window days must be at least 2")
|
|
|
|
try:
|
|
returns = self.calculate_returns(equity_curve)
|
|
rolling_sharpe = []
|
|
|
|
from .risk import RiskManager
|
|
risk_manager = RiskManager()
|
|
|
|
for i in range(window_days - 1, len(returns)):
|
|
window_returns = returns[i - window_days + 1:i + 1]
|
|
sharpe = risk_manager.calculate_sharpe_ratio(window_returns, risk_free_rate)
|
|
rolling_sharpe.append((equity_curve[i + 1][0], sharpe))
|
|
|
|
return rolling_sharpe
|
|
|
|
except Exception as e:
|
|
raise CalculationError(f"Rolling Sharpe calculation failed: {e}")
|
|
|
|
def generate_equity_curve_summary(
|
|
self,
|
|
equity_curve: List[Tuple[datetime, Decimal]]
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Generate a summary of the equity curve.
|
|
|
|
Args:
|
|
equity_curve: List of (datetime, value) tuples
|
|
|
|
Returns:
|
|
Dictionary with equity curve summary statistics
|
|
"""
|
|
if not equity_curve:
|
|
return {
|
|
'start_date': None,
|
|
'end_date': None,
|
|
'start_value': Decimal('0'),
|
|
'end_value': Decimal('0'),
|
|
'peak_value': Decimal('0'),
|
|
'trough_value': Decimal('0'),
|
|
'data_points': 0,
|
|
}
|
|
|
|
start_date = equity_curve[0][0]
|
|
end_date = equity_curve[-1][0]
|
|
start_value = equity_curve[0][1]
|
|
end_value = equity_curve[-1][1]
|
|
|
|
values = [v for _, v in equity_curve]
|
|
peak_value = max(values)
|
|
trough_value = min(values)
|
|
|
|
return {
|
|
'start_date': start_date.isoformat(),
|
|
'end_date': end_date.isoformat(),
|
|
'start_value': str(start_value),
|
|
'end_value': str(end_value),
|
|
'peak_value': str(peak_value),
|
|
'trough_value': str(trough_value),
|
|
'data_points': len(equity_curve),
|
|
}
|