Source code for stocksimpy.utils.performance

# src/stocksimpy/utils/performance.py

from __future__ import annotations

from typing import TYPE_CHECKING

import numpy as np
import pandas as pd

if TYPE_CHECKING:
    from stocksimpy.core.backtester import Backtester


[docs] class Performance: """ Compute performance and risk metrics for a backtest execution. This class wraps a completed Backtester instance to calculate risk-adjusted returns, volatility, drawdown, and other common performance statistics. All metrics are computed from the portfolio's value history and trade log. Parameters ---------- backtester : Backtester A completed Backtester instance with an executed portfolio. The portfolio's value_history and trade_log will be used to compute metrics. risk_free_rate : float, optional Annual risk-free rate used for Sharpe ratio and other risk-adjusted calculations. Expressed as a decimal (e.g., 0.02 for 2%). Default is 0.02. Attributes ---------- backtester : Backtester Reference to the underlying Backtester instance. portfolio : Portfolio The portfolio object from the backtester. symbol : str Ticker symbol being analyzed. risk_free_rate : float Annual risk-free rate for calculations. Notes ----- - All return metrics are expressed as decimals (e.g., 0.15 for 15%). - Volatility and Sharpe ratio are annualized using the observed trading days per year in the portfolio's value history. - Drawdown is expressed as a negative decimal (e.g., -0.25 for -25%). - Metrics assume that the backtester has already been executed; results may be incorrect or zero if the backtest produced no trades or value history. Examples -------- >>> bt = Backtester('AAPL', stock_data, strategy) >>> bt.run_backtest_fixed() >>> perf = Performance(bt, risk_free_rate=0.02) >>> report = perf.generate_risk_report() >>> print(f"Sharpe Ratio: {report['Sharpe Ratio']:.2f}") """ def __init__(self, backtester: "Backtester", risk_free_rate: float = 0.02) -> None: self.backtester = backtester self.portfolio = backtester.portfolio self.symbol = backtester.symbol self.risk_free_rate = risk_free_rate
[docs] def calc_daily_returns(self) -> pd.Series: """ Calculate daily percentage returns from portfolio value history. Returns ------- pandas.Series Daily returns as decimals (e.g., 0.01 for 1% daily return). Index is aligned with the portfolio value history dates. Missing values are dropped. """ return self.portfolio.value_history.pct_change().dropna()
[docs] def calc_total_return(self) -> float: """ Calculate total return over the backtest period. Computes the percentage return from the initial capital to the final portfolio value, expressed as a decimal. Returns ------- float Total return as a decimal. For example, 0.15 represents a 15% return. Returns 0.0 if the portfolio value history is empty. Notes ----- Total return is calculated as: (final_value - initial_capital) / initial_capital """ value_history = self.portfolio.value_history initial_cap = self.portfolio.initial_cap if value_history.empty: return 0.0 final_val = value_history.iloc[-1] return (final_val - initial_cap) / initial_cap
[docs] def get_annualized_return(self) -> float: """ Calculate annualized return over the backtest period. Converts the total return to a compound annual growth rate (CAGR) using the date span of the portfolio (start to end). This gives a consistent annual rate for comparisons across different time horizons. Returns ------- float Annualized return as a decimal. For example, 0.12 represents a 12% annualized return. Returns 0.0 if date span is zero. Notes ----- - The calculation assumes a 365.25-day year to account for leap years. - The date span includes all calendar days (weekends, holidays, etc.), not just trading days. - Formula: ``(1 + total_return) ** (365.25 / days) - 1`` """ total_return = self.calc_total_return() # For some reason days include all the days from start to end, including weekend official holidays, etc. days = self.portfolio.date_length if days == 0: return 0.0 annualized_return = (1 + total_return) ** (365.25 / days) - 1 return annualized_return
def _get_annualized_trading_days(self) -> float: """ Compute the average trading days per year from the portfolio history. This internal method dynamically calculates the number of trading days per year observed in the simulation period, ensuring that annualized metrics (Sharpe ratio, volatility) are accurate for any time range (intraday, weeks, months, or years). Returns ------- float Average trading days per year. Returns 252 (standard for US equities) as a fallback if insufficient data is available. Notes ----- - If the portfolio has fewer than 2 entries or spans zero years, the method returns 252. - Formula: ``total_trading_days / total_years`` - This ensures volatility and Sharpe ratios are correctly annualized even for partial-year or multi-year backtests. """ value_history = self.portfolio.value_history if len(value_history) < 2: return 252 # Fallback to standard 252 trading days per year start_date = value_history.index.min() end_date = value_history.index.max() # Total time span in years total_years = (end_date - start_date).days / 365.25 # Total number of trading days total_trading_days = len(value_history) if total_years == 0 or total_trading_days < 2: return 252 # Avoid division by zero # Average trading days per year return total_trading_days / total_years
[docs] def calc_max_drawdown(self) -> float: """ Calculate the maximum drawdown during the backtest period. Maximum drawdown is the peak-to-trough decline from the highest portfolio value to the lowest subsequent value. It measures the worst cumulative loss experienced by the strategy. Returns ------- float Maximum drawdown as a negative decimal. For example, -0.25 represents a 25% drawdown from peak to trough. Returns 0.0 if the portfolio value history is empty. Notes ----- - Formula: ``min((value - cummax(value)) / cummax(value))`` - A drawdown of -0.2 means the portfolio fell 20% from its peak. - All drawdown values are negative or zero. """ value_history = self.portfolio.value_history if value_history.empty: return 0.0 rolling_max = value_history.cummax() drawdowns = (value_history - rolling_max) / rolling_max max_drawdown = drawdowns.min() return max_drawdown
[docs] def calc_sharpe_ratio(self) -> float: """ Calculate the annualized Sharpe ratio. The Sharpe ratio measures risk-adjusted return by comparing the excess daily return (above the risk-free rate) to the standard deviation of daily returns. Higher Sharpe ratios indicate better risk-adjusted performance. Returns ------- float Annualized Sharpe ratio. A typical range is 0 to 3, with values above 1.0 generally considered good and above 2.0 excellent. Returns 0.0 if daily returns are empty or have zero volatility. Notes ----- - The calculation uses the dynamic annualization factor from ``_get_annualized_trading_days()`` to adapt to the backtest period. - Formula: (mean_daily_excess_return / daily_volatility) * sqrt(annualization_factor) - The risk-free rate is converted to a daily rate before computation. - Sharpe ratios above 1.0 typically indicate strong risk-adjusted returns. Examples -------- >>> perf = Performance(bt) >>> sharpe = perf.calc_sharpe_ratio() >>> if sharpe > 1.0: ... print("Good risk-adjusted performance") """ daily_returns = self.calc_daily_returns() if daily_returns.empty or daily_returns.std() == 0: return 0.0 # 1. Get the dynamic annualization factor annualization_factor = self._get_annualized_trading_days() # 2. Adjust annual risk-free rate to daily rate # Daily risk-free rate = (1 + R)^(1/T) - 1 daily_risk_free_rate = (1 + self.risk_free_rate) ** ( 1 / annualization_factor ) - 1 # 3. Calculate the daily Sharpe Ratio sharpe_ratio = ( daily_returns.mean() - daily_risk_free_rate ) / daily_returns.std() # 4. Annualize the ratio using the square root of the annualization factor return sharpe_ratio * np.sqrt(annualization_factor)
# TODO: for future implementation, the current version is not correct """ def calc_sortino_ratio(self) -> float: daily_returns = self.calc_daily_returns() if daily_returns.empty: return 0.0 annualization_factor = self._get_annualized_trading_days() # Daily Minimum Acceptable Return (MAR) is set to the daily risk-free rate daily_mar = (1 + self.risk_free_rate)**(1/annualization_factor) - 1 # 1. Calculate Downside Deviation (Downside Risk) # Identify returns below the MAR (daily risk-free rate) downside_returns = daily_returns[daily_returns < daily_mar] # If there are no downside returns, the deviation is 0. if downside_returns.empty: # If no downside, the ratio is effectively infinite, but we safely return 0.0 or the annualized excess return. # Returning 0.0 ensures safety in case of division by zero later. return (daily_returns.mean() - daily_mar) * annualization_factor # Calculate the sum of squared differences only for returns below MAR. # The divisor must be the total number of periods (len(daily_returns)) for the population downside deviation. sum_of_squares = ((downside_returns - daily_mar)**2).sum() downside_deviation = np.sqrt(sum_of_squares / len(daily_returns)) if downside_deviation == 0: return 0.0 # 2. Calculate Daily Sortino Ratio daily_excess_return = daily_returns.mean() - daily_mar sortino_ratio = daily_excess_return / downside_deviation # 3. Annualize the ratio return sortino_ratio * np.sqrt(annualization_factor) """ # TODO: for future implementation """ def calculate_calmar_ratio(self) -> float: pass """
[docs] def generate_risk_report(self) -> pd.Series: """ Generate a comprehensive performance and risk report. Computes and aggregates the most important performance metrics into a single pandas Series for easy inspection and comparison. All metrics are computed lazily at the time of the call. Returns ------- pandas.Series A labeled one-dimensional array with keys: - ``'Total Return'`` (float): Decimal total return from initial capital to final portfolio value. - ``'Annualized Return'`` (float): Geometric annualized return over the backtest period. - ``'Max Drawdown'`` (float): Maximum peak-to-trough decline as a negative decimal (e.g., -0.25 for -25%). - ``'Sharpe Ratio'`` (float): Risk-adjusted return metric annualized using dynamic trading days. Generally, values > 1.0 are good. - ``'Volatility'`` (float): Annualized standard deviation of daily returns, expressed as a decimal (e.g., 0.15 for 15%). Notes ----- - All return metrics are expressed as decimals (0.15 = 15%). - All metrics return 0.0 if insufficient data is available. - Sortino Ratio is deliberately omitted pending implementation verification; uncomment the corresponding line in the code once ready. - This is the recommended entry point for quick portfolio evaluation. Examples -------- >>> report = perf.generate_risk_report() >>> print(report) Total Return 0.150000 Annualized Return 0.120000 Max Drawdown -0.250000 Sharpe Ratio 1.450000 Volatility 0.180000 dtype: float64 """ return pd.Series( { "Total Return": self.calc_total_return(), "Annualized Return": self.get_annualized_return(), "Max Drawdown": self.calc_max_drawdown(), "Sharpe Ratio": self.calc_sharpe_ratio(), # 'Sortino Ratio': self.calc_sortino_ratio(), # Uncomment when implemented "Volatility": self.calc_volatility(), } )
[docs] def calc_volatility(self) -> float: """ Calculate annualized portfolio volatility. Volatility is the standard deviation of daily returns, annualized using the observed trading days per year. Higher volatility indicates more erratic price swings; lower volatility indicates more stable returns. Returns ------- float Annualized volatility as a decimal. For example, 0.20 represents 20% annualized volatility. Returns 0.0 if daily returns are empty. Notes ----- - Formula: ``daily_returns.std() * sqrt(annualization_factor)`` - Annualization factor is dynamically computed from trading days observed in the portfolio history. - Volatility is a key input to the Sharpe ratio calculation. Examples -------- >>> vol = perf.calc_volatility() >>> print(f"Annualized volatility: {vol*100:.1f}%") Annualized volatility: 18.5% """ daily_returns = self.calc_daily_returns() if daily_returns.empty: return 0.0 annualization_factor = self._get_annualized_trading_days() return daily_returns.std() * np.sqrt(annualization_factor)