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.
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 plotlyRequirement 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_subplots3. 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):
| Frequency | Periods per Year |
|---|---|
| 1-minute | 525,600 |
| 5-minute | 105,120 |
| 1-hour | 8,760 |
| Daily | 252 (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.