""" 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), }