Historical volatility measures how much an asset’s returns varied over a past observation window. To calculate it in Python, compute returns from a consistent closing-price series, take their rolling standard deviation, and state the convention used to annualize the result.
The formula is short. Choosing the wrong price column, treating missing observations as zero returns, or using the same annualization factor for every market can change what the number means.
This guide builds a close-to-close historical volatility calculation from a SiftingIO CSV export. It uses daily observations, a sample standard deviation and logarithmic returns. The worked numbers are illustrative, not observed market prices or a trading recommendation.
Historical volatility is a statistic of returns, not prices#
The standard deviation of a price series is measured in its quote currency. It does not give a comparable percentage measure for an asset priced at $10 and another priced at $1,000.
Returns put successive observations on a relative scale. For positive closing prices, the logarithmic return is:
r[t] = ln(close[t] / close[t-1])
For a window of N returns, calculate the sample standard deviation around their mean, using N − 1 in the denominator. This is the volatility per observation period. If the observations are daily, it is daily volatility.
Annualization then applies a stated scale:
annualized volatility = period standard deviation × sqrt(periods per year)
The result describes past variability under those choices. It does not predict the next price, specify a maximum loss or measure every kind of investment risk.
Choose the window and observation frequency separately#
A 20-observation rolling window and an annualized output answer different questions. The window determines which recent returns enter the estimate. Annualization determines the scale on which that estimate is reported.
| Input series | Example window | Illustrative annualization convention | Interpretation |
|---|---|---|---|
| Daily stock-session closes | 20 session-to-session returns | 252 periods per year | Recent daily variability, expressed on an annualized scale |
| Daily crypto closes, every calendar day | 20 daily returns | 365 periods per year | Recent calendar-day variability on the same reporting scale |
| Monthly closes | 12 monthly returns | 12 periods per year | A year of monthly observations, not a daily estimate |
These factors are conventions for the specified sampling scheme, not universal properties of an asset. A year does not always contain exactly 252 stock sessions. A 20-session stock window and a 20-day crypto window also cover different elapsed time.
Square-root scaling assumes an appropriate relationship between returns over time, including an absence of serial correlation for the usual variance-scaling argument. It is an approximation, not a guarantee that a short-window estimate will match volatility measured over a full year. The CFA Institute discussion of autocorrelation and annualized volatility explains why that distinction matters.
For a comparison across markets, decide whether you want equal numbers of observations or equal calendar spans, and document the choice. Do not convert minute-bar volatility to annual volatility by multiplying it by the square root of 252: that factor belongs to daily observations under the convention above.
Prepare daily closing prices from SiftingIO#
Open Market Data Export, select an instrument such as AAPL, choose daily bars and a period long enough for the window, then download CSV. Longer history and daily intervals require signing in and connecting your API key; the account-free preview covers recent intraday bars.
For a 20-return window, you need at least 21 valid consecutive observations. More history lets you calculate a rolling series rather than one final value. Check that the result is complete before using it.
The export includes Time (UTC), Timestamp (ms), Open, High, Low, Close and Volume. The code below reads the epoch-millisecond column explicitly and uses Close. Volume is not an input to this estimator, so missing volume does not need to become zero.
Keep the instrument, quote currency, actual interval and price basis with the file. SiftingIO’s export page identifies US stock prices as as-traded, without split or dividend adjustment. A split can therefore create a large raw-price return that is not an equivalent change in an investor’s wealth. For a corporate-action-sensitive study, establish an appropriate adjustment method and supporting data before interpreting the result. The adjusted versus unadjusted price guide covers that distinction.
Calculate rolling historical volatility in Python#
Install pandas and NumPy in your analysis environment:
python -m pip install pandas numpy
Save the CSV as prices.csv. The function expects one instrument and one completed close for each intended observation period. It sorts rows, rejects duplicate timestamps and invalid non-positive prices, and preserves missing closes.
from numbers import Integral
import numpy as np
import pandas as pd
def historical_volatility(frame, window=20, periods_per_year=252):
if isinstance(window, bool) or not isinstance(window, Integral) or window < 2:
raise ValueError("window must be an integer of at least 2")
if not np.isfinite(periods_per_year) or periods_per_year <= 0:
raise ValueError("periods_per_year must be positive and finite")
epoch = pd.to_numeric(frame["Timestamp (ms)"], errors="raise")
if epoch.isna().any() or not np.isfinite(epoch).all():
raise ValueError("Every row needs a finite timestamp in milliseconds")
time = pd.to_datetime(epoch, unit="ms", utc=True, errors="raise")
if time.duplicated().any():
raise ValueError("Duplicate timestamps: resolve the source rows first")
close = pd.to_numeric(frame["Close"], errors="raise")
invalid = close.notna() & (~np.isfinite(close) | (close <= 0))
if invalid.any():
raise ValueError("Log returns require finite, strictly positive closes")
out = pd.DataFrame({"time": time, "close": close})
out = out.sort_values("time").set_index("time")
out["log_return"] = np.log(out["close"]).diff()
out["period_vol"] = out["log_return"].rolling(
window=window, min_periods=window
).std(ddof=1)
out["annualized_vol"] = out["period_vol"] * np.sqrt(periods_per_year)
return out
bars = pd.read_csv("prices.csv")
result = historical_volatility(bars, window=20, periods_per_year=252)
if result.empty or pd.isna(result["annualized_vol"].iloc[-1]):
print("The latest observation has no complete 20-return window.")
else:
print(f"Window ending {result.index[-1].isoformat()}: "
f"{result['annualized_vol'].iloc[-1]:.2%} annualized")
Two details make the output easier to audit. std(ddof=1) explicitly selects sample standard deviation. min_periods=window requires the full number of valid returns in each window; an incomplete window remains missing. See the pandas windowing documentation and standard deviation reference for those parameters.
The display checks the final observation and prints its timestamp. If that row has no complete window, it reports the gap instead of silently displaying an older estimate as current.
The function does not infer a trading calendar or detect an entire missing source row. Before running it, compare the observations with the intended session schedule. A missing Tuesday can otherwise turn the next observed return into a multi-session change. The missing-candles guide explains how to distinguish a source gap from a legitimate market closure.
Check the result with a small numerical example#
Suppose three illustrative log returns are −0.01, 0 and +0.01. Their mean is zero. Their squared deviations sum to 0.0002, and division by N − 1 gives a sample variance of 0.0001. The sample standard deviation is therefore 0.01, or 1% per observation period.
| Quantity | Calculation | Result |
|---|---|---|
| Sample standard deviation | sqrt(0.0002 / 2) | 1.00% |
| Annualized under a 252-period convention | 1% × sqrt(252) | 15.87% |
| Annualized under a 365-period convention | 1% × sqrt(365) | 19.10% |
The final two rows use the same returns. The difference comes entirely from the annualization assumption. It is not evidence that the underlying observations changed.
The code keeps volatility as a decimal: 0.1587 is approximately 15.87%. Format it as a percentage for display; multiplying by 100 twice would turn a valid result into an implausible one.
Explain missing values before filling them#
The first observation has no preceding close, so its return is missing. A 20-return calculation consequently starts no earlier than the 21st price observation. Those initial missing values are expected.
A blank close has a different meaning. Keeping it missing prevents the calculation from pretending that a price was observed. Forward filling would manufacture a zero return at that point, while dropping the row could create a longer-interval return between the surrounding observations. Neither operation repairs the missing market observation.
Closed-session dates should not be inserted as repeated prices simply to make a stock series look like a seven-day calendar. Conversely, an absent daily crypto observation should not be treated as a normal weekend closure.
Historical volatility, ATR and drawdown answer different questions#
Historical volatility summarizes the dispersion of returns around their mean. It treats upward and downward variation alike. Average True Range uses high, low and previous-close information to measure price ranges. Maximum drawdown measures a decline from a prior peak.
A series can have a substantial drawdown despite modest day-to-day volatility, or high volatility without ending below its starting price. Keep these measures separate in a report rather than treating one as a substitute for the others.
When exporting a result, save the observation frequency, window length, return convention, ddof, annualization factor and price basis alongside the value. A label such as “20-session, close-to-close log-return volatility, annualized with 252” is more useful than “volatility: 16%”.
For another instrument, start with a fresh SiftingIO historical data export and check its interval and price conventions before reusing the calculation. The same Python function can process a compatible forex, commodity or crypto file, but the sampling assumptions still need to match that file.



