Portfolio Performance Measurement › Computing Performance Metrics in Python

Michael BrenndoerferDecember 16, 20257 min read
Quantitative FinanceAll chapters
Part 9 of 15Portfolio Theory and Investment Analysis
Chapter 49Portfolio Performance Measurement › Computing Performance Metrics in Python
Reading section 5 of 11 · Computing Performance Metrics in Python · 7 minSection 5 of 11 · 7 min

Book contents

Section 5 of 11 · 7 min read

Portfolio Performance Measurement › Computing Performance Metrics in PythonOne section · Sections in this chapter5 / 11 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let's implement these metrics and apply them to realistic portfolio data. We'll work with simulated returns that exhibit properties common in equity portfolios: modest positive drift, time-varying volatility, and occasional drawdowns.

In[6]:
Code
import numpy as np

np.random.seed(42)

First, we'll generate synthetic return series for a portfolio and its benchmark, simulating approximately five years of daily data.

In[7]:
Code
import numpy as np
import pandas as pd

# Simulation parameters
n_days = 1260  # Approximately 5 years of trading days
daily_rf = 0.04 / 252  # 4% annual risk-free rate

# Generate benchmark returns (market index)
benchmark_returns = np.random.normal(0.08 / 252, 0.16 / np.sqrt(252), n_days)

# Generate portfolio returns with slight alpha and tracking error
alpha_daily = 0.02 / 252  # 2% annual alpha
tracking_noise = np.random.normal(0, 0.05 / np.sqrt(252), n_days)
portfolio_returns = benchmark_returns + alpha_daily + tracking_noise

# Create a date index
dates = pd.date_range(start="2019-01-01", periods=n_days, freq="B")
In[8]:
Code
# Build a DataFrame for analysis
df = pd.DataFrame(
    {"portfolio": portfolio_returns, "benchmark": benchmark_returns},
    index=dates,
)

# Calculate cumulative returns (wealth paths)
df["portfolio_cumulative"] = (1 + df["portfolio"]).cumprod()
df["benchmark_cumulative"] = (1 + df["benchmark"]).cumprod()

Implementing the Sharpe Ratio

In[9]:
Code
def sharpe_ratio(returns, risk_free_rate=0.0, periods_per_year=252):
    """
    Calculate the annualized Sharpe ratio.

    Parameters:
    -----------
    returns : array-like
        Periodic returns (daily, monthly, etc.)
    risk_free_rate : float
        Periodic risk-free rate (same frequency as returns)
    periods_per_year : int
        Number of periods per year for annualization

    Returns:
    --------
    float : Annualized Sharpe ratio
    """
    excess_returns = returns - risk_free_rate
    if np.std(excess_returns) == 0:
        return np.nan
    return (
        np.mean(excess_returns)
        / np.std(excess_returns, ddof=1)
        * np.sqrt(periods_per_year)
    )
In[10]:
Code
# Calculate Sharpe ratios
portfolio_sharpe = sharpe_ratio(df["portfolio"], daily_rf)
benchmark_sharpe = sharpe_ratio(df["benchmark"], daily_rf)
Out[11]:
Console
Portfolio Sharpe Ratio: 1.080
Benchmark Sharpe Ratio: 0.864

The portfolio's higher Sharpe ratio reflects both the embedded alpha and the independent tracking noise. A Sharpe ratio near 0.6-0.7 is realistic for a well-managed equity portfolio over a multi-year period.

Implementing the Sortino Ratio

In[12]:
Code
def sortino_ratio(returns, risk_free_rate=0.0, mar=0.0, periods_per_year=252):
    """
    Calculate the annualized Sortino ratio.

    Parameters:
    -----------
    returns : array-like
        Periodic returns
    risk_free_rate : float
        Periodic risk-free rate
    mar : float
        Minimum acceptable return (same frequency as returns)
    periods_per_year : int
        Number of periods per year for annualization

    Returns:
    --------
    float : Annualized Sortino ratio
    """
    excess_returns = returns - risk_free_rate
    downside_returns = np.minimum(returns - mar, 0)
    downside_deviation = np.sqrt(np.mean(downside_returns**2))

    if downside_deviation == 0:
        return np.nan

    return (
        np.mean(excess_returns) / downside_deviation * np.sqrt(periods_per_year)
    )
In[13]:
Code
portfolio_sortino = sortino_ratio(df["portfolio"], daily_rf)
benchmark_sortino = sortino_ratio(df["benchmark"], daily_rf)
Out[14]:
Console
Portfolio Sortino Ratio: 1.670
Benchmark Sortino Ratio: 1.309

The Sortino ratio exceeds the Sharpe ratio because our simulated returns are approximately symmetric. In practice, the relationship between these ratios reveals information about the skewness of the return distribution.

Implementing the Information Ratio

In[15]:
Code
def information_ratio(
    portfolio_returns, benchmark_returns, periods_per_year=252
):
    """
    Calculate the annualized information ratio.

    Parameters:
    -----------
    portfolio_returns : array-like
        Portfolio periodic returns
    benchmark_returns : array-like
        Benchmark periodic returns
    periods_per_year : int
        Number of periods per year for annualization

    Returns:
    --------
    tuple : (information_ratio, annualized_alpha, annualized_tracking_error)
    """
    active_returns = portfolio_returns - benchmark_returns
    tracking_error = np.std(active_returns, ddof=1) * np.sqrt(periods_per_year)
    annualized_alpha = np.mean(active_returns) * periods_per_year

    if tracking_error == 0:
        return np.nan, annualized_alpha, tracking_error

    ir = annualized_alpha / tracking_error
    return ir, annualized_alpha, tracking_error
In[16]:
Code
ir, alpha, te = information_ratio(df["portfolio"], df["benchmark"])
Out[17]:
Console
Information Ratio: 0.858
Annualized Alpha: 4.20%
Annualized Tracking Error: 4.89%

The information ratio of approximately 0.4-0.5 is consistent with a skilled active manager. The alpha is close to our simulated 2%, and tracking error matches the noise we introduced.

Implementing Drawdown Analysis

In[18]:
Code
def calculate_drawdowns(cumulative_returns):
    """
    Calculate the drawdown series and maximum drawdown.

    Parameters:
    -----------
    cumulative_returns : array-like
        Cumulative return series (wealth path starting at 1)

    Returns:
    --------
    tuple : (drawdown_series, max_drawdown, max_drawdown_duration)
    """
    # Running maximum
    running_max = np.maximum.accumulate(cumulative_returns)

    # Drawdown at each point
    drawdowns = (running_max - cumulative_returns) / running_max

    # Maximum drawdown
    max_dd = np.max(drawdowns)

    # Calculate drawdown durations
    is_in_drawdown = drawdowns > 0
    drawdown_periods = []
    current_duration = 0

    for in_dd in is_in_drawdown:
        if in_dd:
            current_duration += 1
        else:
            if current_duration > 0:
                drawdown_periods.append(current_duration)
            current_duration = 0

    if current_duration > 0:
        drawdown_periods.append(current_duration)

    max_duration = max(drawdown_periods) if drawdown_periods else 0

    return drawdowns, max_dd, max_duration
In[19]:
Code
# Calculate drawdowns for portfolio and benchmark
portfolio_dd, portfolio_max_dd, portfolio_max_duration = calculate_drawdowns(
    df["portfolio_cumulative"].values
)
benchmark_dd, benchmark_max_dd, benchmark_max_duration = calculate_drawdowns(
    df["benchmark_cumulative"].values
)

# Add drawdowns to DataFrame
df["portfolio_drawdown"] = portfolio_dd
df["benchmark_drawdown"] = benchmark_dd
Out[20]:
Console
Portfolio Maximum Drawdown: 19.65%
Benchmark Maximum Drawdown: 21.30%
Portfolio Max Drawdown Duration: 439 days
Benchmark Max Drawdown Duration: 440 days

The maximum drawdown figures highlight the worst historical loss experienced by each strategy. The portfolio's maximum drawdown represents the deepest peak-to-trough decline, while the duration metric indicates the number of days required to recover from that loss. These metrics quantify the tail risk and psychological endurance required to stick with the strategy.

Implementing the Calmar Ratio

In[21]:
Code
def calmar_ratio(returns, periods_per_year=252):
    """
    Calculate the Calmar ratio.

    Parameters:
    -----------
    returns : array-like
        Periodic returns
    periods_per_year : int
        Number of periods per year for annualization

    Returns:
    --------
    float : Calmar ratio
    """
    # Calculate cumulative returns
    cumulative = (1 + np.array(returns)).cumprod()

    # Calculate maximum drawdown
    running_max = np.maximum.accumulate(cumulative)
    drawdowns = (running_max - cumulative) / running_max
    max_dd = np.max(drawdowns)

    # Annualized Return
    total_return = cumulative[-1] - 1
    n_years = len(returns) / periods_per_year
    annualized_return = (1 + total_return) ** (1 / n_years) - 1

    if max_dd == 0:
        return np.nan

    return annualized_return / max_dd
In[22]:
Code
portfolio_calmar = calmar_ratio(df["portfolio"])
benchmark_calmar = calmar_ratio(df["benchmark"])
Out[23]:
Console
Portfolio Calmar Ratio: 1.158
Benchmark Calmar Ratio: 0.838

A Calmar ratio above 0.5 indicates that annualized returns exceed half the maximum drawdown, suggesting reasonable compensation for the drawdown risk experienced.

Visualizing Performance

Out[24]:
Visualization
Line chart of cumulative wealth from 2019 to 2024 for portfolio and benchmark. The portfolio line ends above the benchmark, which reflects a 2% annual alpha advantage.
Cumulative returns for the portfolio and benchmark. The portfolio exhibits a slight performance advantage over the benchmark due to the added alpha, resulting in a higher terminal wealth.
Underwater chart below zero showing portfolio and benchmark drawdown percentages over time. Deeper troughs indicate larger peak-to-trough losses.
Portfolio and benchmark drawdowns (underwater chart). The visualization highlights the depth and duration of declines, with the shaded areas representing each series' percentage loss from its peak value.

The underwater chart (below) provides an intuitive visualization of drawdowns. The depth shows magnitude while the width indicates duration. This view helps investors compare the path their capital took with its endpoint.