Risk metricsQuantPerformance

Volatility, Sharpe ratio and maximum drawdown from a NAV series

Compute annualised volatility, Sharpe ratio and maximum drawdown from a fund's indexed NAV series in Python, and see why your figures differ from the factsheet.Published 5 September 2026 · 4 min read · by FundFacts API

Fund factsheets print a 3-year volatility, a Sharpe ratio and sometimes a maximum drawdown. When you compute the same numbers from the fund's own NAV series, you rarely land on exactly the same value. This post walks through the three calculations from first principles, shows the sampling and scaling choices that move the result, and explains why the fund house's figure and yours can legitimately disagree.

The input: an indexed NAV series

FundFacts API returns the fund's performance chart as data.indexedPerformance.points, an array of { date, value } where the value is the NAV rebased to a starting level (typically 100). Example values:

json
{
"indexedPerformance": {
"points": [
{ "date": "2021-09-30", "value": 100.0 },
{ "date": "2021-10-29", "value": 103.2 },
{ "date": "2021-11-30", "value": 101.7 }
]
}
}

Two things matter before you compute anything. First, the spacing: the points may be daily, weekly or monthly depending on what the fund house publishes, so detect the frequency rather than assume it. Second, the series is a total-return index in the share-class currency, with distributions reinvested, which is the right basis for risk metrics.

Step 1: returns

Everything starts with periodic returns. For consecutive values V(t-1) and V(t):

text
simple return r_t = V_t / V_(t-1) - 1
log return l_t = ln(V_t / V_(t-1))

Simple returns are what factsheets use and what the formulas below assume. Log returns are convenient because they add across periods; the difference is negligible for daily data and small for monthly data, but be consistent.

Step 2: annualised volatility

Volatility is the standard deviation of periodic returns, scaled to a yearly horizon:

text
sigma_annual = stdev(r) * sqrt(periods_per_year)

with periods_per_year = 252 for daily data (trading days), 52 for weekly and 12 for monthly. The square-root scaling assumes returns are independent from one period to the next. Real returns are not perfectly independent, which is one reason daily-based and monthly-based volatility for the same window do not agree.

Use the sample standard deviation (divide by n - 1). Most factsheets do, and with 36 monthly points the difference between n and n - 1 is about 1.4% of the figure: small, but visible at two decimals.

Step 3: Sharpe ratio

The Sharpe ratio is excess return per unit of volatility:

text
sharpe = (R_annual - Rf_annual) / sigma_annual

where R_annual is the annualised return over the window and Rf_annual the risk-free rate over the same window. A common alternative works at the period level:

text
sharpe = mean(r - rf_period) / stdev(r - rf_period) * sqrt(periods_per_year)

The two are not identical. The first uses the geometric (compound) annualised return, the second the arithmetic mean scaled up. For a fund with 15% volatility the geometric return is roughly 1.1 percentage points lower than the arithmetic one (about sigma squared over two), so the first formula gives a lower Sharpe. Neither is wrong; document which one you use.

Choosing the risk-free rate

  • Match the share-class currency: a EUR share class should use a euro short rate, a USD share class a Treasury bill or SOFR-based rate.
  • Match the window: use the average rate over the three years, not today's rate.
  • Some fund houses use zero. If a factsheet Sharpe equals annualised return divided by volatility exactly, that is what happened.

Step 4: maximum drawdown

Maximum drawdown is the largest peak-to-trough fall in the series:

text
peak_t = max(V_0, ..., V_t)
drawdown_t = V_t / peak_t - 1
max_dd = min(drawdown_t) (a negative number, e.g. -0.23)

Unlike volatility and Sharpe, drawdown is not scaled by frequency, but it is still sensitive to it: a monthly series only sees month-end values and will miss an intra-month trough, so monthly maximum drawdown is never deeper than the daily one for the same window.

Putting it together in Python

python
import os
import numpy as np
import pandas as pd
import requests
res = requests.get(
"https://fundfactsapi.com/api/v1/funds/IE00B4L5Y983",
headers={"Authorization": f"Bearer {os.environ['FUNDFACTS_API_KEY']}"},
)
res.raise_for_status()
points = res.json()["data"]["indexedPerformance"]["points"]
nav = (
pd.DataFrame(points)
.assign(date=lambda d: pd.to_datetime(d["date"]))
.set_index("date")["value"]
.sort_index()
)
# detect sampling frequency from the median gap between points
gap_days = nav.index.to_series().diff().dt.days.median()
periods_per_year = 252 if gap_days <= 2 else 52 if gap_days <= 8 else 12
# trailing 3-year window
nav3 = nav[nav.index >= nav.index[-1] - pd.DateOffset(years=3)]
rets = nav3.pct_change().dropna()
years = (nav3.index[-1] - nav3.index[0]).days / 365.25
ann_return = (nav3.iloc[-1] / nav3.iloc[0]) ** (1 / years) - 1
ann_vol = rets.std(ddof=1) * np.sqrt(periods_per_year)
rf_annual = 0.02 # illustrative; use the short rate of the share-class currency
sharpe = (ann_return - rf_annual) / ann_vol
drawdown = nav3 / nav3.cummax() - 1
max_dd = drawdown.min()
print(f"freq={periods_per_year} vol={ann_vol:.2%} sharpe={sharpe:.2f} maxDD={max_dd:.2%}")

The frequency detection is the part people skip. Feeding monthly points into a sqrt(252) scaling inflates volatility by a factor of about 4.6, which is the single most common cause of a wildly wrong number.

Why your numbers differ from headlineMetrics.volatility3y

The API also returns the fund house's own figures, headlineMetrics.volatility3y, headlineMetrics.sharpe3y and metrics.maxDrawdown, read from the factsheet. When yours disagree, one of these is usually the cause:

Source of differenceTypical effectHow to check
Window end dateYour series ends at the latest point; the factsheet figure ends at dataAsOf, often an earlier month-endCompare dataAsOf with the last date in points
Sampling frequencyDaily and monthly returns over the same window give different estimates; monthly also misses intra-month drawdownsResample to month-ends: nav.resample("ME").last() ("M" on older pandas)
CurrencyThe factsheet may quote risk in the fund's base currency while you hold, or converted to, another currencyCheck keyFacts.currency and the share-class name
Return typeSimple vs log returns, sample vs population standard deviationSecond-decimal differences
Risk-free rateZero vs cash rate, and which currency's rateSharpe only
Price basisETF factsheets sometimes use NAV, sometimes market priceETFs only

Window and frequency together explain most gaps. If a factsheet quotes 3-year volatility and you resample the indexed series to month-ends, take the 36 returns ending at dataAsOf and scale by sqrt(12), you will usually land within a few tenths of a percentage point of the published number. Anything beyond that, look at currency.

Practical rule
Treat the fund house's figure as the one that is consistent within that house, and your own as the one that is consistent across houses and auditable. Store both, along with the window and frequency you used.

When to compute and when to use the published figure

For a screener that ranks thousands of share classes, the published volatility3y is a reasonable first filter, with the caveat that methodologies differ between houses. For quant research, client reporting or anything where two funds sit side by side, compute the metrics yourself from indexedPerformance.points so that every fund is treated identically. The performance data guide covers the return fields in the same response; the regulatory risk indicator (SRRI/SRI) is a separate, coarser measure and is covered in its own guide.

Try it on your own ISINs

One request returns key facts, holdings, risk and performance as JSON. Free plan, no card.