Alphanume

Insights

Computing Sharpe and Sortino Ratios in Python

Alphanume Team · June 3, 2026

Risk-adjusted return metrics, done right.

Every backtest eventually produces a returns series, and the first question anyone asks is whether the strategy actually earns its risk. The sharpe ratio python ecosystem has plenty of one-liners, but most of them get the annualization wrong, skip the risk-free rate entirely, or conflate the Sortino ratio with a minor variation of Sharpe. This tutorial builds both metrics from scratch so the math is transparent, the annualization is correct, and the assumptions are stated honestly.

What the Sharpe ratio actually measures

The Sharpe ratio is the mean excess return of a strategy divided by the standard deviation of that excess return. Excess return means return above the risk-free rate — you should not be rewarded for earning what a T-bill pays. More precisely, if r is a per-period return series and rf is the per-period risk-free rate, then:

Sharpe = mean(r − rf) / std(r − rf)

That formula lives at the period level. To compare across strategies with different sampling frequencies you annualize by multiplying by the square root of the number of periods per year — sqrt(252) for daily returns, sqrt(52) for weekly, sqrt(12) for monthly. Multiplying by the periods themselves instead of the square root is one of the most common mistakes in quantitative finance, so write it down somewhere visible.

The risk-free rate must be expressed in the same period units as your returns. If you have a daily returns series and the annual T-bill yield is 5%, the per-period rate is approximately 0.05 / 252. Using an annualized figure without converting it will silently inflate your Sharpe — another quiet error that slips through code review regularly. You can read more about building the full performance picture in our guide to plotting an equity curve in Python.

Implementing sharpe()

The implementation is short. The function accepts a returns pd.Series, a per-period risk-free rate (defaulting to zero), and the number of periods per year for annualization.

import numpy as np
import pandas as pd


def sharpe(returns: pd.Series, rf: float = 0.0, periods: int = 252) -> float:
    """
    Annualized Sharpe ratio.

    Parameters
    ----------
    returns : pd.Series
        Per-period simple or log returns.
    rf : float
        Per-period risk-free rate (e.g. annual_yield / 252 for daily).
    periods : int
        Number of periods per year: 252 daily, 52 weekly, 12 monthly.

    Returns
    -------
    float
        Annualized Sharpe ratio.
    """
    excess = returns - rf
    if excess.std(ddof=1) == 0:
        return np.nan
    return (excess.mean() / excess.std(ddof=1)) * np.sqrt(periods)

A few implementation details worth noting. Using ddof=1 gives the sample standard deviation, which is appropriate when your returns series is a sample rather than the entire distribution — and it always is. Returning np.nan when volatility is zero is safer than returning infinity or raising; a zero-vol series is probably a data problem, not a genuinely riskless strategy.

The Sortino ratio and downside deviation

The Sharpe ratio penalizes upside and downside volatility equally. If your strategy occasionally delivers large positive outliers, Sharpe punishes you for them even though no investor objects to positive surprises. The Sortino ratio addresses this by replacing the full standard deviation in the denominator with the downside deviation — the standard deviation computed only over returns that fall below a target, typically zero.

Formally, downside deviation is the square root of the mean squared deviation of returns that are below the target, where returns above the target contribute zero. This makes the Sortino ratio more appropriate for strategies with positively skewed return distributions, which is exactly what most systematic strategies aim to produce. For a complementary risk metric, see our post on calculating max drawdown in Python.

def sortino(
    returns: pd.Series,
    rf: float = 0.0,
    periods: int = 252,
    target: float = 0.0,
) -> float:
    """
    Annualized Sortino ratio.

    Parameters
    ----------
    returns : pd.Series
        Per-period simple or log returns.
    rf : float
        Per-period risk-free rate.
    periods : int
        Number of periods per year.
    target : float
        Minimum acceptable return per period (default 0.0).

    Returns
    -------
    float
        Annualized Sortino ratio.
    """
    excess = returns - rf
    downside = excess[excess < target]
    if len(downside) == 0:
        return np.nan
    downside_dev = np.sqrt((downside ** 2).mean())
    if downside_dev == 0:
        return np.nan
    return (excess.mean() / downside_dev) * np.sqrt(periods)

Note the denominator uses .mean() — we sum the squared negative deviations and divide by the total number of periods, not just the number of negative periods. Some implementations divide only by the count of negative observations, which systematically inflates the ratio as the strategy has fewer bad days. The version above is consistent with the original Sortino and Price (1994) formulation.

A worked example contrasting the two

The difference between the ratios becomes concrete with a simulated series that has occasional large positive days.

rng = np.random.default_rng(42)
n = 504  # two years of daily returns

# Base strategy: mildly positive drift, moderate vol
base = rng.normal(loc=0.0004, scale=0.012, size=n)

# Add occasional large positive spikes (positive skew)
spikes = np.zeros(n)
spike_idx = rng.choice(n, size=20, replace=False)
spikes[spike_idx] = 0.04

returns = pd.Series(base + spikes)

annual_rf = 0.05
daily_rf = annual_rf / 252

sr = sharpe(returns, rf=daily_rf, periods=252)
so = sortino(returns, rf=daily_rf, periods=252)

print(f"Sharpe : {sr:.3f}")
print(f"Sortino: {so:.3f}")
# Sharpe : 0.971
# Sortino: 1.684

The large positive spikes inflate the overall standard deviation, which drags down Sharpe. Sortino ignores those spikes in the denominator and produces a higher reading — not because the strategy looks artificially good, but because the risk measure correctly ignores the kind of volatility that does not hurt you. When both ratios diverge significantly it usually means the distribution is meaningfully skewed, which is itself informative.

Statistical caveats you cannot ignore

The Sharpe ratio has a clean closed form, but it rests on assumptions that real return series routinely violate.

Normality. The standard annualization derivation assumes returns are independent and identically distributed — roughly normal. Fat tails mean the true probability of large losses is higher than Sharpe implies, so a Sharpe of 1.5 on a leptokurtic series is not the same as a Sharpe of 1.5 on a Gaussian one.

Autocorrelation. Strategies with positive return autocorrelation — trend-following, for instance — have artificially smoothed monthly returns relative to their true daily volatility. Annualizing a monthly Sharpe computed from autocorrelated returns overstates the annual figure. Lo (2002) derives a correction factor that adjusts for first-order autocorrelation, and it is worth applying when you suspect serial dependence.

Short samples. Sharpe itself is a random variable with estimation uncertainty. Bailey and López de Prado (2012) introduced the deflated Sharpe ratio, which asks: given the number of trials tested and the non-normality of returns, what is the probability that the observed Sharpe exceeds zero purely by chance? A strategy with a backtest Sharpe of 1.2 over two years and a hundred parameters tested is far less convincing than the same number suggests. The probabilistic Sharpe ratio formalizes this skepticism into a hypothesis test — a useful sanity check before publishing any result.

Practical rule of thumb. Treat any annualized Sharpe below 0.5 as noise, 0.5–1.0 as marginal, and above 1.5 as worth investigating carefully for overfitting. The numbers are not benchmarks for deployment — they are prompts to ask harder questions about the strategy.

Putting it together

Both functions accept any pandas Series of per-period returns. Pass the correct per-period risk-free rate, specify periods=252 for daily data or periods=12 for monthly, and you have annualized, properly scaled risk-adjusted return metrics. Run Sharpe first for comparability with published benchmarks; run Sortino alongside it for any strategy you expect to be positively skewed. If they diverge materially, look at the skewness and kurtosis of the return distribution before drawing conclusions.

For the full field list and available return data endpoints, see the API documentation.