←Back to the bookChapter 134 · Challenge solutions
▶ Open in Colab⬇ Download notebookView on GitHub
Chapter 134 · Solutions
Volatility & Advanced Models · Solutions
Five challenges, each verified in code.

Volatility & Advanced Models · Challenge Solutions¶

Worked solutions to the five challenges from Chapter 134, on the same daily market-and-stock return series.

In [1]:
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/"
In [2]:
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
CHALLENGE 1
Where is the signal, mean or variance?
Test returns and squared returns for autocorrelation with the Ljung-Box test.
In [3]:
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.

CHALLENGE 2
Levels vs returns
Build the price level (cumulative returns) and show it is non-stationary while the returns are.
In [4]:
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.

CHALLENGE 3
Forecast the volatility
Fit GARCH(1,1) and forecast the next five days' volatility.
In [5]:
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.

CHALLENGE 4
Granger, both directions
Test causality each way at two lags and state the caveat.
In [6]:
for a,b in [('market_return','stock_return'), ('stock_return','market_return')]:
    g = grangercausalitytests(df[[b,a]], maxlag=2)
    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.

CHALLENGE 5
ARDL vs Granger
Fit an ARDL and check it agrees with the Granger conclusion.
In [7]:
ard = ARDL(stock, 1, df[['market_return']], 2).fit()
coefs = pd.DataFrame({'coef': ard.params.filter(like='market_return'),
                      'p-value': ard.pvalues.filter(like='market_return')})
print(coefs.round(3).to_string())
print('\nthe same-day and one-day market terms are sizable and significant, and the two-day term is not.')
print('ARDL agrees with Granger and QUANTIFIES the effect (today + lagged) that Granger could only flag as present.')
                   coef  p-value
market_return.L0  0.329     0.00
market_return.L1  0.290     0.00
market_return.L2  0.029     0.59

the same-day and one-day market terms are sizable and significant, and the two-day term is not.
ARDL agrees with Granger and 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.


Statistics, Data Science and AI: A Visual Handbook · © 2026 John Fisher
←Back to the bookChapter 134 · Challenge solutions