sifting/io
Quant Research & Backtesting
10 min readSiftingIO Team

Sharpe Ratio in Python: Formula, Returns and Annualisation

Calculate the Sharpe ratio in Python with a worked example, matching cash returns and annualisation checks. Prepare daily market data with SiftingIO.

Sharpe Ratio in Python: Formula, Returns and Annualisation

A strategy returns 12% in a year. Is that a good result? The answer changes if its daily returns were steady, if it repeatedly lost 20%, or if holding cash would have earned almost as much.

The Sharpe ratio puts excess return and return variability into one number. To calculate a Sharpe ratio in Python, subtract a matching cash return from each period's return, then divide the average difference by its sample standard deviation. Annualising that number requires a separate assumption about the return series.

This guide works through the arithmetic, provides a small implementation, and shows how to prepare daily Bitcoin prices from SiftingIO. All numbers in the worked tables are invented examples. The API example supplies inputs for research, not a claimed trading result.

What the Sharpe ratio measures#

The historical Sharpe ratio describes the average excess return earned per unit of variability in those excess returns. The calculation uses a sequence of returns, not the price level and not the percentage change between just the first and last observation.

For each period, define:

excess return = investment return minus cash return
period Sharpe = mean excess return ÷ sample standard deviation of excess returns

Use returns covering the same dates, currency and measurement interval. A daily investment return belongs beside a daily cash return. An annual interest rate cannot be subtracted directly from every daily observation.

William Sharpe's original explanation develops the measure using differential returns. That distinction matters when the benchmark changes over time: calculate the standard deviation of the differences, not just the investment returns.

The result is dimensionless. A negative value means the average excess return in the sample was negative. A positive value says nothing by itself about maximum loss, liquidity or whether the performance will continue.

A worked example with five returns#

Suppose a portfolio has the following monthly returns. Assume cash earns 0.2% in each month. These are synthetic numbers selected to make the calculation easy to inspect.

MonthPortfolio returnCash returnExcess return
12.0%0.2%1.8%
2−1.0%0.2%−1.2%
33.0%0.2%2.8%
40.0%0.2%−0.2%
51.0%0.2%0.8%

The mean excess return is 0.8%. Its deviations from the mean, in percentage points, are 1, −2, 2, −1 and 0. Their squared values sum to 10.

For sample standard deviation, divide by four, which is the number of observations minus one, then take the square root. The result is about 1.5811 percentage points. The monthly Sharpe ratio is therefore 0.5060.

Using population standard deviation would give a different answer, about 0.5657. Neither a rounding setting nor a different market feed explains that difference. It comes from the denominator convention.

If the square-root annualisation assumptions are appropriate, multiplying 0.5060 by the square root of 12 gives 1.7527. Five months remain a very short sample. Annualising a statistic does not create another seven months of evidence.

Calculate the Sharpe ratio in Python#

This implementation uses Python's standard library. Returns are decimal fractions, so 2% is 0.02. The two input sequences must already be aligned by period.

from math import isfinite, sqrt
from statistics import fmean, stdev


def sharpe_summary(returns, cash_returns, periods_per_year=None):
    returns = [float(value) for value in returns]
    cash_returns = [float(value) for value in cash_returns]
    if len(returns) != len(cash_returns) or len(returns) < 2:
        raise ValueError("Provide at least two aligned return pairs")
    if not all(isfinite(value) for value in returns + cash_returns):
        raise ValueError("Returns must be finite; investigate missing values")
    if periods_per_year is not None:
        if isinstance(periods_per_year, bool):
            raise ValueError("periods_per_year must be positive")
        periods_per_year = float(periods_per_year)
        if not isfinite(periods_per_year) or periods_per_year <= 0:
            raise ValueError("periods_per_year must be finite and positive")

    excess = [r - cash for r, cash in zip(returns, cash_returns)]
    mean_excess = fmean(excess)
    volatility = stdev(excess)
    ratio = None if volatility == 0 else mean_excess / volatility
    annualized = None
    if ratio is not None and periods_per_year is not None:
        annualized = ratio * sqrt(periods_per_year)

    return {
        "observations": len(excess),
        "mean_excess_return": mean_excess,
        "sample_volatility": volatility,
        "period_sharpe": ratio,
        "annualized_sharpe": annualized,
    }


monthly_returns = [0.02, -0.01, 0.03, 0.00, 0.01]
monthly_cash = [0.002] * len(monthly_returns)
result = sharpe_summary(monthly_returns, monthly_cash, 12)
print(round(result["period_sharpe"], 4))
print(round(result["annualized_sharpe"], 4))

The output is 0.506 followed by 1.7527. Python's statistics.stdev uses sample standard deviation. The explicit function name helps avoid mixing it with a library default that uses population standard deviation.

A zero standard deviation produces None. Display that as unavailable rather than an enormous score. With nearly constant returns, the denominator can also become very small, so inspect the observations before presenting the result as meaningful.

The function rejects missing values instead of silently removing them. It cannot check dates because it receives only numbers. Date alignment belongs in the data preparation step.

Annualising daily, monthly and crypto returns#

The commonly used conversion is:

annualised Sharpe = period Sharpe × square root of periods per year

It is a conventional scaling estimate, not an exact Sharpe ratio calculated from observed annual returns. Stable return behaviour and negligible serial correlation are important assumptions. Andrew Lo's research on Sharpe ratios explains why dependence between successive returns can invalidate the simple multiplier.

Choose the factor from the return observations, not the name of the asset.

Input seriesCommon factorCheck before using it
Monthly returns12Every observation covers one comparable month
Daily equity session returns252The trading-session convention is explicit
Daily crypto returns365Weekends are included and each bar covers one UTC day
Hourly returnsDepends on the sampling scheduleOvernight gaps and market closures are handled consistently

Do not use the number of rows downloaded as the annualisation factor. Sixty daily returns do not imply 60 periods per year. The sample length and the annual frequency are different quantities.

Likewise, adding weekend rows with unchanged stock prices changes the distribution. It does not turn a trading-session series into genuine observations of a continuously traded market.

Match the cash rate to the return interval#

For a simplified example with a constant effective annual cash return of 4%, the equivalent daily rate on a 365-day convention is:

annual_cash = 0.04  # Illustrative assumption, not a current market rate
daily_cash = (1 + annual_cash) ** (1 / 365) - 1
print(daily_cash)

That is approximately 0.00010746, or 0.010746% per day. The conversion assumes an effective annual rate and equal daily compounding. A quoted money-market yield may use another day-count or interest convention, so identify the rate definition before applying this formula.

For historical research, use a cash-return series aligned to the actual return intervals. A fixed 4% across the entire sample is an assumption, not a reconstruction of historical financing conditions. Keep it visible in the report.

Currency matters too. USD-denominated Bitcoin returns should be compared with a documented USD cash benchmark. A stablecoin lending yield carries different risks and should not silently replace that benchmark.

Get daily prices from the SiftingIO API#

The SiftingIO crypto data API provides historical bars and real-time reference prices. Here, the historical REST endpoint supplies the close series; the Sharpe calculation remains in your application.

This request downloads daily BTCUSD bars from September and October 2026. Both months are complete, so a forming daily candle cannot enter the calculation. Store the API key in an environment variable.

curl --fail --silent --show-error --compressed \
  --get 'https://api.sifting.io/v1/hist/crypto/BTCUSD/bars' \
  --header "X-API-Key: $SIFTINGIO_API_KEY" \
  --data-urlencode 'start=2026-09-01T00:00:00Z' \
  --data-urlencode 'end=2026-10-31T00:00:00Z' \
  --data-urlencode 'interval=1d' \
  --data-urlencode 'limit=2000' \
  --output btcusd.json

The crypto bars reference defines t as the UTC bar-opening timestamp in milliseconds and c as the close. The upper bound applies to the bar's opening time, so the request includes the bar beginning on 31 October. --compressed requests gzip and decodes the response.

The reader below expects the complete requested daily grid. A missing day stops the calculation rather than turning a two-day move into a one-day return.

import json
from datetime import datetime, timezone
from math import isfinite

with open("btcusd.json", encoding="utf-8") as handle:
    page = json.load(handle)

meta = page.get("meta", {})
if meta.get("next_cursor"):
    raise ValueError("Finish pagination before calculating returns")
if meta.get("symbol") != "BTCUSD" or meta.get("interval") != "1d":
    raise ValueError("Expected daily BTCUSD bars")

day_ms = 86_400_000
start = int(datetime(2026, 9, 1, tzinfo=timezone.utc).timestamp() * 1000)
end = int(datetime(2026, 10, 31, tzinfo=timezone.utc).timestamp() * 1000)
expected = list(range(start, end + day_ms, day_ms))
bars = page["data"]
times = [bar["t"] for bar in bars]
if any(isinstance(t, bool) or not isinstance(t, int) for t in times):
    raise ValueError("Expected integer UTC timestamps")
if times != expected:
    raise ValueError("Missing, duplicate, out-of-order or unexpected daily bars")

closes = [float(bar["c"]) for bar in bars]
if not all(isfinite(close) and close > 0 for close in closes):
    raise ValueError("BTCUSD closes must be finite and positive")

returns = [current / previous - 1 for previous, current in zip(closes, closes[1:])]
daily_cash = (1 + 0.04) ** (1 / 365) - 1
result = sharpe_summary(returns, [daily_cash] * len(returns), 365)
print(result)

Place this reader after the sharpe_summary function. Sixty-one completed closes produce 60 returns. The first close is the baseline, not a return observation. For a larger range, finish cursor pagination before running the calculation.

This measures close-to-close Bitcoin price returns relative to an assumed cash rate. It does not include execution costs, staking income, financing or the decisions of a trading strategy. No fixed API result is printed here because it depends on the downloaded data.

A price series is not a strategy's return series#

For a strategy report, calculate the portfolio's returns after its positions, execution costs and cash holdings have been accounted for. Do not describe the underlying asset's Sharpe ratio as the strategy's result.

For example, a strategy that holds Bitcoin only on selected days will have a different return series from a continuous Bitcoin position. Fees and slippage belong on the periods when they are incurred. External deposits need cash-flow-aware performance measurement; a deposit should not appear as an investment gain.

Stock research adds corporate actions. Raw prices around a split can create a false loss, while a price-only series excludes dividends. Specify the adjustment and income treatment before comparing results across assets. The guide to adjusted and unadjusted prices covers that distinction.

Keep live monitoring separate from completed returns#

A dashboard can use SiftingIO WebSocket prices to show a current portfolio value while keeping the reported daily Sharpe based on completed daily observations.

During the day, the current price is provisional. Appending a return for every tick would change the statistic from a daily measure into one based on irregular arrivals. Update the live value freely, but add one committed observation for each completed reporting period.

After a disconnection, recover the missing historical observations before refreshing the metric. Display the last completed period and the sample size next to the number so a reader knows what was actually measured.

What belongs beside a Sharpe ratio#

Report fieldExample labelWhy it matters
Return basisPortfolio returns after costsDistinguishes investable results from price changes
FrequencyCompleted UTC daily observationsMakes the measurement interval explicit
Cash benchmarkConstant 4% effective annual assumptionExposes a modelling choice
Sample size60 daily returnsShows how much evidence supports the estimate
ScalingSquare-root estimate, 365 periods per yearSeparates a convention from observed annual performance
Other risk measuresDrawdown and worst periodShows losses that one ratio cannot describe

A universal grading table such as "above 2 means excellent" leaves out too much context. Compare like with like: matching dates, costs, frequency and return definitions. Very short samples and results selected from many tested strategies deserve particular scrutiny.

Pair the ratio with maximum drawdown and CAGR. They answer different questions about loss and compounded growth. None replaces the underlying return series.

To reproduce the workflow, create a SiftingIO account, download one completed historical window and keep the raw response beside the calculation. A useful performance report makes its inputs and assumptions inspectable. That is more valuable than another decimal place on the score.

Keep reading

Related posts