Volatility & Advanced Models · Challenge Solutions¶
Worked solutions to the five challenges from Chapter 129, on the same daily market-and-stock return series.
import numpy as np, pandas as pd, matplotlib.pyplot as plt
import seaborn as sns # seaborn = high-level statistical plots (heatmaps, pairplots, count/bar plots)
from matplotlib.colors import ListedColormap
EM="#0284c7"; DEEP="#075985"; LIGHT="#bae6fd"; INK="#1a2138"; GRID="#e6e9f2"; RED="#ef4444"; AMBER="#d97706"; GREEN="#059669"; BLUE="#2563eb"; PUR="#9333ea"; GREY="#94a3b8"; SLATE="#475569"; ORG="#0284c7"; CYAN="#0891b2"
plt.rcParams.update({"figure.facecolor":"white","axes.facecolor":"white","figure.dpi":110,"font.size":11,
"axes.edgecolor":GRID,"axes.grid":True,"grid.color":GRID,"axes.axisbelow":True,"axes.spines.top":False,
"axes.spines.right":False,"axes.titlesize":12,"axes.titleweight":"bold","legend.frameon":False})
sns.set_style("whitegrid")
BASE="https://raw.githubusercontent.com/johnfisher-ai/Statistics-Data-Science-AI-Visual-Book/main/data/"
from statsmodels.tsa.stattools import adfuller, kpss, grangercausalitytests, acf
from statsmodels.stats.diagnostic import acorr_ljungbox
from statsmodels.tsa.ardl import ARDL
import warnings; warnings.filterwarnings('ignore')
try:
from arch import arch_model; from arch.unitroot import PhillipsPerron
except ImportError:
import subprocess, sys; subprocess.run([sys.executable,'-m','pip','install','-q','arch']); from arch import arch_model; from arch.unitroot import PhillipsPerron
try: raw = pd.read_excel('../../data/volatility-and-advanced-models--daily_returns.xlsx', sheet_name='Data')
except FileNotFoundError: raw = pd.read_excel(BASE + 'volatility-and-advanced-models--daily_returns.xlsx', sheet_name='Data')
raw['date']=pd.to_datetime(raw['date']); df = raw.set_index('date'); mkt, stock = df['market_return'], df['stock_return']
print('loaded', len(df), 'daily returns')
loaded 756 daily returns
lb_ret = acorr_ljungbox(stock, lags=[10])['lb_pvalue'].iloc[0]
lb_sq = acorr_ljungbox((stock-stock.mean())**2, lags=[10])['lb_pvalue'].iloc[0]
print('Ljung-Box p, returns : %.3f -> %s' % (lb_ret, 'white noise (no mean memory)' if lb_ret>0.05 else 'has memory'))
print('Ljung-Box p, squared ret. : %.3f -> %s' % (lb_sq, 'has memory (volatility clustering)' if lb_sq<0.05 else 'white'))
Ljung-Box p, returns : 0.377 -> white noise (no mean memory) Ljung-Box p, squared ret. : 0.000 -> has memory (volatility clustering)
The Ljung-Box test confirms formally what the ACF hinted: the returns show no autocorrelation (unpredictable mean), but the squared returns do. The predictable structure lives in the variance, which is why we reach for GARCH.
price = 100 + stock.cumsum() # a price path from the returns
for name, x in [('PRICE level', price), ('RETURNS', stock)]:
print('%-12s ADF p = %.3f | KPSS p = %.3f' % (name, adfuller(x)[1], kpss(x, regression='c', nlags='auto')[1]))
print('\nprice: ADF says non-stationary, KPSS says non-stationary -> difference it.')
print('returns (= the difference of price): both tests say stationary.')
PRICE level ADF p = 0.157 | KPSS p = 0.010 RETURNS ADF p = 0.000 | KPSS p = 0.100 price: ADF says non-stationary, KPSS says non-stationary -> difference it. returns (= the difference of price): both tests say stationary.
A price level wanders (a unit root): ADF fails to reject non-stationarity and KPSS rejects stationarity, both flag it. Its first difference, the returns, is stationary. This is the everyday workflow: model returns, not prices, and it is why the “I” in ARIMA exists.
g = arch_model(stock, mean='Constant', vol='GARCH', p=1, q=1).fit(disp='off')
fc = g.forecast(horizon=5); vol = np.sqrt(fc.variance.iloc[-1].values)
for h,v in enumerate(vol, 1): print('day +%d expected volatility: %.2f%%' % (h, v))
print('\ncurrent conditional volatility: %.2f%% -> forecasts drift toward the long-run level' % g.conditional_volatility.iloc[-1])
day +1 expected volatility: 2.41% day +2 expected volatility: 2.38% day +3 expected volatility: 2.34% day +4 expected volatility: 2.31% day +5 expected volatility: 2.28% current conditional volatility: 2.15% -> forecasts drift toward the long-run level
GARCH forecasts the variance ahead. From a given day the volatility forecast mean-reverts toward the long-run level at a rate set by the persistence (alpha + beta). This multi-day volatility path is exactly what risk desks feed into Value-at-Risk and option pricing.
for a,b in [('market_return','stock_return'), ('stock_return','market_return')]:
g = grangercausalitytests(df[[b,a]], maxlag=2, verbose=False)
print('%-14s -> %-14s p(lag1)=%.3f p(lag2)=%.3f' % (a, b, g[1][0]['ssr_ftest'][1], g[2][0]['ssr_ftest'][1]))
print('\nmarket -> stock is significant at both lags; stock -> market is not.')
market_return -> stock_return p(lag1)=0.000 p(lag2)=0.000 stock_return -> market_return p(lag1)=0.560 p(lag2)=0.839 market -> stock is significant at both lags; stock -> market is not.
Granger runs one way here: the market helps predict the stock, not the reverse. But remember the caveat, this is predictive precedence, not proof of cause. A faster-reacting third factor could produce the same pattern, so pair Granger with domain knowledge.
ard = ARDL(stock, 1, df[['market_return']], 2).fit()
coefs = ard.params.filter(like='market_return')
print(coefs.round(3).to_string())
print('\nthe market terms are sizable and significant -> ARDL confirms the market drives the stock,')
print('and it QUANTIFIES the effect (today + lagged) that Granger could only flag as present.')
market_return.L0 0.329 market_return.L1 0.290 market_return.L2 0.029 the market terms are sizable and significant -> ARDL confirms the market drives the stock, and it QUANTIFIES the effect (today + lagged) that Granger could only flag as present.
ARDL and Granger answer related questions from opposite angles: Granger asks whether the market's past improves the forecast; ARDL estimates by how much, today and at each lag. They agree here, and together they give both the test and the effect size.