Risk, in one number
Volatility is the standard deviation of returns, quoted per year so that assets can be compared: about 15 to 20% for a stock index, 30 to 60% for a single stock, near 0 for cash. It sizes positions, prices options and sets risk limits. The simplest estimate is the realized volatility over a window: the sample standard deviation of the returns times sqrt(periods per year). It treats every day in the window equally, which is both its strength (stable) and its weakness (slow).
import math
import numpy as np
# Eight quiet days, one -3% shock, three more days
returns = np.array([0.004, -0.002, 0.001, 0.003, -0.004, 0.002, 0.006, -0.001, -0.030, 0.004, 0.002, -0.003])
print(round(returns[:8].std(ddof=1) * math.sqrt(252), 4)) # 0.0526 before the shock: 5.3% a year
print(round(returns.std(ddof=1) * math.sqrt(252), 4)) # 0.1505 with the shock: 15.1% a yearReacting faster: EWMA
Volatility clusters: calm periods and stormy periods each last a while, so yesterday's shock says more about tomorrow than a quiet day a month ago. The exponentially weighted moving average (EWMA, popularised as RiskMetrics) updates the variance each day: variance_t = lam * variance_(t-1) + (1 - lam) * return_t². With lam = 0.94 the newest return carries 6% of the weight and old ones fade geometrically. The estimate jumps at a shock and decays gradually, instead of staying high until the shock leaves a window, then dropping all at once.
lam = 0.94
variance = np.empty(len(returns))
variance[0] = returns[0] ** 2
for t in range(1, len(returns)):
variance[t] = lam * variance[t - 1] + (1 - lam) * returns[t] ** 2 # the recursion needs the previous value
ewma = np.sqrt(variance)
print(ewma.round(5).tolist())
# [0.004, 0.00391, 0.0038, 0.00375, 0.00377, 0.00369, 0.00387, 0.00376, 0.0082, 0.00801, 0.00778, 0.00758]
# ^ the shock doubles the estimate, then it fades
print(round(ewma[-1] * math.sqrt(252), 4)) # 0.1204 today's annualised estimateUsing the range, and targeting a volatility
Closing prices ignore what happened during the day. The daily high and low carry more information: Parkinson's estimator sqrt(mean(ln(high/low)²) / (4 ln 2)) is several times more efficient than the close-to-close estimate, meaning it reaches the same accuracy with far fewer days (it assumes no big gaps between days). A practical use of any volatility estimate is volatility targeting: if you want 10% risk and the realized volatility is 15%, hold 10 / 15 = 0.67 of the position. The leverage shrinks in storms and grows in calm.
high = np.array([101.5, 102.0, 103.2, 102.8, 104.0])
low = np.array([99.8, 100.9, 101.7, 101.1, 102.4])
parkinson = math.sqrt(np.mean(np.log(high / low) ** 2) / (4 * math.log(2)))
print(round(parkinson, 5)) # 0.00905 per-day volatility from the daily ranges
target = 0.10
realized = returns.std(ddof=1) * math.sqrt(252)
print(round(target / realized, 3)) # 0.664 hold two thirds of the position to run at 10% risk# Close-to-close versus range: the same five days
close = np.array([100.0, 101.2, 102.5, 101.9, 103.4])
print(round(float(np.diff(np.log(close)).std(ddof=1)), 5)) # 0.00955 from the closes only
print(round(parkinson, 5)) # 0.00905 from the high-low ranges of the same days- Realized volatility: sample std times
sqrt(periods per year); stable but slow to react. - EWMA:
variance_t = lam * variance_(t-1) + (1 - lam) * return_t²; recent shocks weigh more and fade geometrically. - Parkinson uses the daily high/low range; volatility targeting scales the position by
target / realized.
Next up: the Sharpe ratio, reward per unit of risk, and how far to trust it.
Lab · what's graded
- Measure the yearly volatility with
realized_volatility25 pts - Let recent shocks count more with
ewma_volatility25 pts - Use the daily range with
parkinson_volatility25 pts - Size a position to a risk budget with
volatility_target_leverage25 pts