Performance·Performance Metrics·Intermediate

Calculate Sharpe Sortino

Calculate comprehensive strategy performance metrics including Sharpe ratio, Sortino ratio, Calmar ratio, Information ratio, and Omega ratio with proper annualization factors and statistical significance hypothesis testing for each risk-adjusted return measure.

performanceperformance-metrics

Financial Performance Metrics: Sharpe and Sortino Ratios Analysis

This notebook provides a comprehensive analysis of key financial performance metrics, including the Sharpe Ratio, Sortino Ratio, and Calmar Ratio. It demonstrates their computation for a simulated trading strategy and a simple buy-and-hold market approach, followed by a visualization of rolling performance and equity curves.

1. Dependency Installation

[ ]
!pip install pandas numpy plotly
Requirement already satisfied: pandas in /usr/local/lib/python3.12/dist-packages (2.2.2)
Requirement already satisfied: numpy in /usr/local/lib/python3.12/dist-packages (2.0.2)
Requirement already satisfied: plotly in /usr/local/lib/python3.12/dist-packages (5.24.1)
Requirement already satisfied: python-dateutil>=2.8.2 in /usr/local/lib/python3.12/dist-packages (from pandas) (2.9.0.post0)
Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.12/dist-packages (from pandas) (2025.2)
Requirement already satisfied: tzdata>=2022.7 in /usr/local/lib/python3.12/dist-packages (from pandas) (2026.1)
Requirement already satisfied: tenacity>=6.2.0 in /usr/local/lib/python3.12/dist-packages (from plotly) (9.1.4)
Requirement already satisfied: packaging in /usr/local/lib/python3.12/dist-packages (from plotly) (26.1)
Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.12/dist-packages (from python-dateutil>=2.8.2->pandas) (1.17.0)

2. Library Imports

[ ]
import warnings
warnings.filterwarnings("ignore")

import pandas as pd
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots

3. Metric Definitions

Sharpe Ratio

The Sharpe ratio quantifies the excess return generated per unit of total risk (volatility). It is calculated as:

Sharpe = (Mean Excess Return) / (Standard Deviation of Excess Returns) × √(Periods per Year)

Where:

  • Excess Return = Strategy Return − Risk-Free Rate

Characteristics:

  • Penalizes both positive and negative volatility equally.
  • A Sharpe ratio above 1.0 is generally considered acceptable.
  • Ratios exceeding 2.0 indicate strong performance, while ratios above 3.0 are exceptionally rare.

Sortino Ratio

The Sortino ratio is a modification of the Sharpe ratio, focusing exclusively on downside risk (negative volatility). It is defined as:

Sortino = (Mean Excess Return) / (Downside Deviation) × √(Periods per Year)

Where:

  • Downside Deviation = Standard Deviation of Negative Excess Returns Only

Characteristics:

  • Does not penalize upside volatility, making it more suitable for strategies with asymmetric return distributions (e.g., those with infrequent large gains).
  • The Sortino ratio is always greater than or equal to the Sharpe ratio. A significantly higher Sortino ratio compared to the Sharpe ratio suggests that the strategy's volatility is predominantly positive.

Annualization Factor

Both the Sharpe and Sortino ratios are typically annualized to facilitate comparison across strategies with varying data frequencies. The annualization factor is √(periods per year):

FrequencyPeriods per Year
1-minute525,600
5-minute105,120
1-hour8,760
Daily252 (trading days)

Calmar Ratio

The Calmar ratio measures the return per unit of maximum historical drawdown:

Calmar = Annualized Return / Maximum Drawdown

This metric is particularly relevant for strategies where the magnitude and duration of drawdowns are critical considerations.


4. Data Generation and Strategy Definition

[ ]
def generate_data(periods: int) -> pd.DataFrame:
    start_date     = pd.to_datetime("2024-01-01 00:00:00+00:00")
    datetime_index = pd.date_range(start_date, periods=periods, freq="1min", tz="UTC")
    price_data = []; last_close = 42000
    volatility_scale = 0.005; wick_scale = 0.002

    for _ in range(periods):
        open_price  = last_close + np.random.normal(0, last_close * volatility_scale * 0.1)
        close_price = open_price + np.random.normal(0, last_close * volatility_scale)
        body_high   = max(open_price, close_price)
        body_low    = min(open_price, close_price)
        high_price  = max(body_high + abs(np.random.normal(0, last_close * wick_scale)),
                          open_price, close_price)
        low_price   = min(body_low  - abs(np.random.normal(0, last_close * wick_scale)),
                          open_price, close_price)
        if high_price < low_price:
            high_price, low_price = low_price, high_price
        price_data.append({
            "open":  max(1, int(open_price)),
            "high":  max(1, int(high_price)),
            "low":   max(1, int(low_price)),
            "close": max(1, int(close_price)),
        })
        last_close = close_price

    df = pd.DataFrame(price_data, index=datetime_index)
    df.index.name = "datetime"
    df["volume"]   = np.random.uniform(100.0, 500.0, periods)
    df["datetime"] = df.index.to_series()
    return df.reset_index(drop=True)

df = generate_data(500)

# --- Compute strategy and market returns ---
df["fast_ma"]         = df["close"].rolling(10).mean()
df["slow_ma"]         = df["close"].rolling(30).mean()
df["signal"]          = np.where(df["fast_ma"] > df["slow_ma"], 1, 0)
df["position"]        = df["signal"].shift(1).fillna(0)
df["trade"]           = df["position"].diff().abs()
df["market_return"]   = df["close"].pct_change()
df["strategy_return"] = df["position"] * df["market_return"] - df["trade"] * 0.0005
df = df.dropna()

display(df[["datetime","close","fast_ma","slow_ma","position","strategy_return"]].head(10))
datetime close fast_ma slow_ma position strategy_return
29 2024-01-01 00:29:00+00:00 42658 42516.4 41823.600000 0.0 0.000000
30 2024-01-01 00:30:00+00:00 42570 42530.1 41849.200000 1.0 -0.002563
31 2024-01-01 00:31:00+00:00 42545 42550.7 41887.033333 1.0 -0.000587
32 2024-01-01 00:32:00+00:00 42493 42556.2 41930.366667 1.0 -0.001222
33 2024-01-01 00:33:00+00:00 43029 42622.4 41986.133333 1.0 0.012614
34 2024-01-01 00:34:00+00:00 43149 42698.7 42052.900000 1.0 0.002789
35 2024-01-01 00:35:00+00:00 43119 42737.8 42122.633333 1.0 -0.000695
36 2024-01-01 00:36:00+00:00 43119 42793.8 42194.500000 1.0 0.000000
37 2024-01-01 00:37:00+00:00 42871 42810.3 42257.966667 1.0 -0.005752
38 2024-01-01 00:38:00+00:00 42637 42819.0 42314.733333 1.0 -0.005458

5. Metric Computation Function

[ ]
def calculate_sharpe_sortino(
    returns:          pd.Series,
    risk_free_rate:   float = 0.0,
    periods_per_year: int   = 525_600,
) -> dict:
    """
    Compute Sharpe ratio, Sortino ratio, Calmar ratio, and supporting
    statistics from a per-period return series.

    Parameters
    ----------
    returns          : Per-period return series (not cumulative).
    risk_free_rate   : Annualized risk-free rate (e.g., 0.04 = 4%).
    periods_per_year : Number of return observations per calendar year.
    """
    # Per-period risk-free adjustment
    rf_per_period = risk_free_rate / periods_per_year
    excess        = returns - rf_per_period
    ann_factor    = np.sqrt(periods_per_year)

    # Sharpe Ratio
    sharpe = (excess.mean() / excess.std()) * ann_factor if excess.std() != 0 else np.nan

    # Sortino Ratio — denominator uses downside returns only
    downside_returns = excess[excess < 0]
    downside_std     = downside_returns.std()
    sortino = (excess.mean() / downside_std) * ann_factor if downside_std != 0 else np.nan

    # Annualized return
    ann_return = excess.mean() * periods_per_year

    # Equity curve and Max Drawdown for Calmar
    equity     = (1 + returns).cumprod()
    peak       = equity.cummax()
    drawdown   = (equity - peak) / peak
    max_dd     = drawdown.min()
    calmar     = ann_return / abs(max_dd) if max_dd != 0 else np.nan

    return {
        "annualized_return":  round(ann_return  * 100, 4),
        "annualized_std":     round(excess.std() * ann_factor * 100, 4),
        "downside_std":       round(downside_std  * ann_factor * 100, 4),
        "sharpe_ratio":       round(sharpe,  4) if not np.isnan(sharpe)  else None,
        "sortino_ratio":      round(sortino, 4) if not np.isnan(sortino) else None,
        "calmar_ratio":       round(calmar,  4) if not np.isnan(calmar)  else None,
        "max_drawdown_pct":   round(max_dd   * 100, 4),
        "n_observations":     len(returns),
    }

strategy_metrics = calculate_sharpe_sortino(df["strategy_return"], risk_free_rate=0.0)
market_metrics   = calculate_sharpe_sortino(df["market_return"],   risk_free_rate=0.0)

print("--- Strategy Metrics ---")
for k, v in strategy_metrics.items():
    print(f"  {k:<25}: {v}")

print("\n--- Buy-and-Hold Metrics ---")
for k, v in market_metrics.items():
    print(f"  {k:<25}: {v}")
--- Strategy Metrics ---
  annualized_return        : 12055.7483
  annualized_std           : 271.8991
  downside_std             : 223.5269
  sharpe_ratio             : 44.339
  sortino_ratio            : 53.9342
  calmar_ratio             : 2648.6575
  max_drawdown_pct         : -4.5516
  n_observations           : 471

--- Buy-and-Hold Metrics ---
  annualized_return        : 13318.5198
  annualized_std           : 369.1824
  downside_std             : 217.924
  sharpe_ratio             : 36.0757
  sortino_ratio            : 61.1154
  calmar_ratio             : 1562.4465
  max_drawdown_pct         : -8.5241
  n_observations           : 471

Explanation of calculate_sharpe_sortino Function Parameters and Logic

  • excess = returns − rf_per_period: This calculation adjusts the per-period returns by subtracting the risk-free rate, yielding the excess return. For most short-term or cryptocurrency strategies, the assumed risk-free rate is often 0%, simplifying this to the raw return.
  • excess.std(): Represents the standard deviation of excess returns, which serves as the denominator for the Sharpe ratio. This metric accounts for both positive and negative deviations from the mean.
  • downside_returns = excess[excess < 0]: This filters the excess returns to include only negative values, which are then used to compute the standard deviation of downside returns. This

6. Rolling Sharpe Analysis

[ ]
ROLLING_WINDOW = 50   # 50-bar rolling window

df["rolling_sharpe"] = (
    df["strategy_return"].rolling(ROLLING_WINDOW).mean() /
    df["strategy_return"].rolling(ROLLING_WINDOW).std()
) * np.sqrt(525_600)

df["rolling_sortino"] = (
    df["strategy_return"].rolling(ROLLING_WINDOW).mean() /
    df["strategy_return"].rolling(ROLLING_WINDOW).apply(
        lambda x: x[x < 0].std() if len(x[x < 0]) > 1 else np.nan
    )
) * np.sqrt(525_600)

print("--- Rolling Sharpe Statistics ---")
print(df["rolling_sharpe"].describe().round(4))
--- Rolling Sharpe Statistics ---
count    422.0000
mean      36.1746
std      111.0607
min     -169.3125
25%      -47.6162
50%       26.4710
75%      120.3083
max      318.5266
Name: rolling_sharpe, dtype: float64

Explanation: Rolling Sharpe analysis reveals whether the strategy's risk-adjusted performance is stable over time or concentrated in specific market regimes. A rolling Sharpe that is consistently positive indicates a robust edge; one that oscillates around zero indicates the strategy is not systematically profitable.


7. Visualization

[ ]
df["strategy_equity"] = (1 + df["strategy_return"]).cumprod() * 10_000
df["market_equity"]   = (1 + df["market_return"]).cumprod()   * 10_000

fig = make_subplots(
    rows=3, cols=1, shared_xaxes=True,
    subplot_titles=[
        "Equity Curve — Strategy vs Buy-and-Hold",
        "Rolling Sharpe and Sortino (50-bar window)",
        "Per-Period Strategy Returns",
    ],
    row_heights=[0.4, 0.35, 0.25],
)

fig.add_trace(go.Scatter(
    x=df["datetime"], y=df["strategy_equity"],
    mode="lines", name="Strategy",
    line=dict(color="green", width=2)), row=1, col=1)

fig.add_trace(go.Scatter(
    x=df["datetime"], y=df["market_equity"],
    mode="lines", name="Buy and Hold",
    line=dict(color="gray", width=1.5, dash="dash")), row=1, col=1)

fig.add_trace(go.Scatter(
    x=df["datetime"], y=df["rolling_sharpe"],
    mode="lines", name="Rolling Sharpe",
    line=dict(color="blue", width=1)), row=2, col=1)

fig.add_trace(go.Scatter(
    x=df["datetime"], y=df["rolling_sortino"],
    mode="lines", name="Rolling Sortino",
    line=dict(color="orange", width=1, dash="dot")), row=2, col=1)

fig.add_hline(y=0, line_dash="dot", line_color="gray", row=2, col=1)
fig.add_hline(y=1, line_dash="dash", line_color="green", row=2, col=1,
              annotation_text="Sharpe = 1.0")

fig.add_trace(go.Bar(
    x=df["datetime"], y=df["strategy_return"],
    name="Strategy Return",
    marker_color=["green" if r >= 0 else "red" for r in df["strategy_return"]]),
    row=3, col=1)

fig.update_layout(
    title_text="Sharpe and Sortino Ratio Analysis",
    xaxis_rangeslider_visible=False,
    height=900,
    xaxis3_title="Datetime",
    yaxis_title="Portfolio Value ($)",
    yaxis2_title="Ratio",
    yaxis3_title="Return",
)
fig.show()

8. Conclusion

This notebook provided an analysis of key financial performance metrics, including the Sharpe Ratio, Sortino Ratio, and Calmar Ratio. We generated synthetic data to simulate a trading strategy and a simple buy-and-hold market approach. The metrics were computed and visualized to understand the risk-adjusted returns and drawdown characteristics of each approach. The rolling Sharpe and Sortino ratios offer insights into the time-varying performance of the strategy, highlighting periods of strong and weak performance.