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 APIFund 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):
textsimple return r_t = V_t / V_(t-1) - 1log 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:
textsigma_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:
textsharpe = (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:
textsharpe = 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:
textpeak_t = max(V_0, ..., V_t)drawdown_t = V_t / peak_t - 1max_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
pythonimport osimport numpy as npimport pandas as pdimport requestsres = 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 pointsgap_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 windownav3 = nav[nav.index >= nav.index[-1] - pd.DateOffset(years=3)]rets = nav3.pct_change().dropna()years = (nav3.index[-1] - nav3.index[0]).days / 365.25ann_return = (nav3.iloc[-1] / nav3.iloc[0]) ** (1 / years) - 1ann_vol = rets.std(ddof=1) * np.sqrt(periods_per_year)rf_annual = 0.02 # illustrative; use the short rate of the share-class currencysharpe = (ann_return - rf_annual) / ann_voldrawdown = nav3 / nav3.cummax() - 1max_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 difference | Typical effect | How to check |
|---|---|---|
| Window end date | Your series ends at the latest point; the factsheet figure ends at dataAsOf, often an earlier month-end | Compare dataAsOf with the last date in points |
| Sampling frequency | Daily and monthly returns over the same window give different estimates; monthly also misses intra-month drawdowns | Resample to month-ends: nav.resample("ME").last() ("M" on older pandas) |
| Currency | The factsheet may quote risk in the fund's base currency while you hold, or converted to, another currency | Check keyFacts.currency and the share-class name |
| Return type | Simple vs log returns, sample vs population standard deviation | Second-decimal differences |
| Risk-free rate | Zero vs cash rate, and which currency's rate | Sharpe only |
| Price basis | ETF factsheets sometimes use NAV, sometimes market price | ETFs 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.
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.