Learn Without Walls

Module 15: Time Series Models for Finance

ARIMA for prices, GARCH for volatility, regime-switching models, and cointegration for pairs trading

Part 4 of 5 Module 15 of 22

Introduction: From Statistics Lab to Trading Floor

If you have studied time series analysis in a statistics program, you already know most of what quantitative finance uses daily. The difference is context: in finance, the data-generating process is non-stationary, high-frequency, fat-tailed, and contaminated with regime changes. This module maps the classical time series toolkit onto its financial applications, starting with ARIMA, escalating through GARCH and its asymmetric extensions, and finishing with cointegration-based trading strategies.

Stats Bridge

In a statistics course, you model time series to understand the data-generating process. In finance, the same models serve a dual purpose: forecasting future values and quantifying uncertainty (volatility). The forecast is the signal; the volatility is the noise envelope around it. Both matter for trading decisions.

1. ARIMA for Price Forecasting

1.1 Why Prices Are Non-Stationary

Raw stock prices are almost always non-stationary. They wander without a fixed mean, exhibiting what statisticians call a unit root. Formally, if Pt = Pt-1 + εt, the series is a random walk. The Augmented Dickey-Fuller (ADF) test virtually never rejects the unit root null for price levels.

Finance Term

Unit Root — A characteristic root of the autoregressive polynomial equal to 1. In finance, this means prices have no tendency to revert to a mean. First differencing (computing returns) typically removes the unit root.

1.2 The Differencing Operator: d = 1

In an ARIMA(p, d, q) model, the parameter d controls how many times we difference the series. For financial prices, d = 1 is the standard choice because:

rt = ln(Pt) − ln(Pt−1) ≈ (Pt − Pt−1) / Pt−1

1.3 Choosing p and q: ACF and PACF

After differencing, you inspect the autocorrelation function (ACF) and partial autocorrelation function (PACF) of the return series:

Pattern ACF Behavior PACF Behavior Suggested Model
Exponential decay Tails off Cuts off at lag p AR(p)
Cuts off at lag q Cuts off at lag q Tails off MA(q)
Both tail off Tails off Tails off ARMA(p,q)
Key Insight

In practice, stock returns show very weak autocorrelation. The ACF and PACF of daily returns are typically not significantly different from zero at conventional lags. This means ARIMA models for returns have near-zero predictive power for returns themselves. Where time series models shine in finance is in modeling volatility, not returns.

1.4 Information Criteria for Model Selection

When ACF/PACF are ambiguous (common with financial data), use AIC or BIC to select among candidate ARIMA(p, 1, q) models:

AIC = −2 ln(L) + 2k      BIC = −2 ln(L) + k ln(n)

where L is the maximized likelihood, k is the number of parameters, and n is the sample size. BIC penalizes complexity more heavily and tends to select more parsimonious models, which often generalize better out-of-sample.

1.5 Python: ARIMA on Stock Prices

Pythonimport numpy as np
import pandas as pd
from statsmodels.tsa.arima.model import ARIMA
from statsmodels.tsa.stattools import adfuller
import yfinance as yf

# Download price data
data = yf.download("SPY", start="2018-01-01", end="2023-12-31")
prices = data["Adj Close"]
log_prices = np.log(prices)
returns = log_prices.diff().dropna()

# Test for unit root in prices vs returns
adf_prices = adfuller(log_prices.dropna())
adf_returns = adfuller(returns)

print(f"ADF on log prices:  stat={adf_prices[0]:.4f}, p={adf_prices[1]:.4f}")
print(f"ADF on returns:     stat={adf_returns[0]:.4f}, p={adf_returns[1]:.4f}")
# Prices: p >> 0.05 (unit root). Returns: p << 0.01 (stationary).

# Fit ARIMA(1,1,1) to log prices
model = ARIMA(log_prices, order=(1, 1, 1))
result = model.fit()
print(result.summary())

# Compare models using AIC/BIC
results = {}
for p in range(0, 4):
    for q in range(0, 4):
        try:
            m = ARIMA(log_prices, order=(p, 1, q)).fit()
            results[(p, q)] = {"AIC": m.aic, "BIC": m.bic}
        except:
            pass

comparison = pd.DataFrame(results).T
print("Best by AIC:", comparison["AIC"].idxmin())
print("Best by BIC:", comparison["BIC"].idxmin())

2. GARCH for Volatility Modeling

2.1 The Volatility Clustering Phenomenon

Even though returns themselves show little autocorrelation, the squared returns (a proxy for variance) are highly autocorrelated. Large returns tend to be followed by large returns (of either sign), and calm periods tend to persist. This is volatility clustering, and it is one of the most robust stylized facts in finance.

Stats Bridge

In statistics terms, the return series is uncorrelated but not independent. The conditional variance is time-varying, even though the conditional mean may be approximately constant. GARCH models capture exactly this: they are models for the second moment of the conditional distribution.

2.2 The GARCH(1,1) Model

The GARCH(1,1) model, introduced by Bollerslev (1986), specifies:

rt = μ + εt,    εt = σt zt,    zt ~ N(0, 1)

σt2 = ω + α εt−12 + β σt−12

The parameters have intuitive interpretations:

Parameter Interpretation Typical Range
ω Long-run variance floor (intercept) Small positive
α Shock impact — how much yesterday’s surprise moves today’s volatility 0.03 – 0.15
β Persistence — how much yesterday’s variance carries over 0.80 – 0.97
α + β Total persistence; must be < 1 for stationarity 0.90 – 0.99
Key Insight

The unconditional (long-run) variance is ω / (1 − α − β). When α + β is close to 1, the long-run variance is large and volatility shocks are extremely persistent. This is the norm for equity markets: α + β typically exceeds 0.95.

2.3 Python: Fitting GARCH(1,1)

Pythonfrom arch import arch_model

# Scale returns to percentage (arch library convention)
returns_pct = returns * 100

# Fit GARCH(1,1)
garch = arch_model(returns_pct, vol="Garch", p=1, q=1, dist="t")
garch_result = garch.fit(disp="off")
print(garch_result.summary())

# Extract conditional volatility
cond_vol = garch_result.conditional_volatility
print(f"Mean daily vol: {cond_vol.mean():.4f}%")
print(f"Annualized vol: {cond_vol.mean() * np.sqrt(252):.2f}%")

# Persistence check
params = garch_result.params
alpha = params["alpha[1]"]
beta = params["beta[1]"]
print(f"alpha + beta = {alpha + beta:.4f}")

3. Asymmetric Volatility: EGARCH and GJR-GARCH

3.1 The Leverage Effect

Standard GARCH treats positive and negative shocks symmetrically: a +3% return and a −3% return increase tomorrow’s volatility by the same amount. But empirically, negative returns increase volatility more than positive returns of the same magnitude. This is the leverage effect, named because a stock price decline raises the firm’s debt-to-equity ratio (leverage), making the stock riskier.

Stats Bridge

The leverage effect means the distribution of returns is negatively skewed at short horizons. From a statistical perspective, the conditional variance is an asymmetric function of past innovations. This requires modifying the GARCH variance equation.

3.2 EGARCH (Nelson, 1991)

The Exponential GARCH models the log of the conditional variance, which ensures positivity without parameter constraints:

ln(σt2) = ω + α(|zt−1| − E|zt−1|) + γ zt−1 + β ln(σt−12)

The γ parameter captures asymmetry. If γ < 0, negative shocks (z < 0) increase volatility more than positive shocks of the same magnitude.

3.3 GJR-GARCH (Glosten, Jagannathan, Runkle, 1993)

σt2 = ω + (α + γ It−1) εt−12 + β σt−12

where It−1 = 1 if εt−1 < 0, else 0

The indicator function It-1 gives negative shocks an extra γ contribution to variance. For equities, γ > 0 is the standard finding: bad news has a larger impact on volatility than good news.

3.4 Comparing Models

Model Asymmetry Positivity Constraint Interpretation
GARCH(1,1) None ω, α, β ≥ 0 Symmetric shock impact
EGARCH(1,1) γ parameter Automatic (log form) Asymmetric, no constraints needed
GJR-GARCH(1,1) γ indicator term ω ≥ 0, α, β ≥ 0 Asymmetric, intuitive indicator
Python# Compare GARCH, EGARCH, and GJR-GARCH
models = {
    "GARCH": arch_model(returns_pct, vol="Garch", p=1, q=1),
    "EGARCH": arch_model(returns_pct, vol="EGARCH", p=1, q=1),
    "GJR-GARCH": arch_model(returns_pct, vol="Garch", p=1, o=1, q=1),
}

for name, model in models.items():
    res = model.fit(disp="off")
    print(f"{name:12s}  AIC={res.aic:10.2f}  BIC={res.bic:10.2f}")

4. Regime-Switching Models

4.1 The Intuition: Markets Have Moods

Financial markets alternate between qualitatively different regimes: calm bull markets with low volatility and crisis periods with high volatility and negative expected returns. A single set of ARIMA/GARCH parameters cannot capture both regimes simultaneously. Hamilton’s (1989) Markov-switching model addresses this by allowing parameters to shift between discrete states.

Stats Bridge

A regime-switching model is a hidden Markov model (HMM) applied to financial time series. The hidden state (regime) follows a Markov chain with transition matrix P, and the observed returns have regime-dependent parameters. If you have studied mixture models, think of this as a time-varying mixture where the mixing weights evolve as a Markov chain.

4.2 Two-Regime Model Specification

rt | St = j ∼ N(μj, σj2),    j ∈ {1, 2}

P(St = j | St−1 = i) = pij

The transition matrix controls regime persistence:

To Calm (j=1) To Crisis (j=2)
From Calm (i=1) p11 ≈ 0.97 p12 ≈ 0.03
From Crisis (i=2) p21 ≈ 0.10 p22 ≈ 0.90
Key Insight

The expected regime durations are 1/(1 − pii). With p11 = 0.97, the expected calm period is ~33 days; with p22 = 0.90, crisis periods last ~10 days. Calm regimes are more persistent than crisis regimes, matching the empirical observation that markets rise slowly and crash quickly.

Pythonimport statsmodels.api as sm

# Fit a 2-regime Markov-switching model
ms_model = sm.tsa.MarkovAutoregression(
    returns.dropna() * 100,
    k_regimes=2,
    order=1,
    switching_ar=False,
    switching_variance=True,
)
ms_result = ms_model.fit()
print(ms_result.summary())

# Extract smoothed regime probabilities
regime_probs = ms_result.smoothed_marginal_probabilities
print("Regime 0 (calm) mean:", ms_result.params["const"])

# Plot regime probabilities
import matplotlib.pyplot as plt
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8), sharex=True)
ax1.plot(returns.index[1:], returns.values[1:] * 100, linewidth=0.5)
ax1.set_ylabel("Returns (%)")
ax1.set_title("S&P 500 Returns with Regime Probabilities")

ax2.fill_between(regime_probs.index, regime_probs.iloc[:, 1],
                 alpha=0.5, color="red", label="P(Crisis regime)")
ax2.set_ylabel("Probability")
ax2.legend()
plt.tight_layout()
plt.show()

5. Cointegration: When Non-Stationary Series Walk Together

5.1 The Core Idea

Two price series may each be I(1) (integrated of order 1, i.e., they contain unit roots), but a linear combination of them may be I(0) (stationary). When this happens, the series are said to be cointegrated. They share a common stochastic trend, and deviations from their equilibrium relationship are temporary.

Stats Bridge

Cointegration is a long-run equilibrium relationship. Statistically, if Xt ~ I(1) and Yt ~ I(1) but Yt − βXt ~ I(0), then X and Y are cointegrated with cointegrating vector (1, −β). Think of it as two random walks tied together by a rubber band: they can diverge temporarily, but the band pulls them back.

5.2 Financial Examples of Cointegration

5.3 The Engle-Granger Test

The Engle-Granger two-step procedure is the simplest test for cointegration:

  1. Regress Yt on Xt using OLS to estimate β.
  2. Test the residuals εt = Yt − βXt for a unit root using the ADF test.
  3. If you reject the unit root null for the residuals, the series are cointegrated.
Common Pitfall

The critical values for the ADF test on the Engle-Granger residuals are NOT the standard ADF critical values. Because the residuals are estimated (not observed), the test statistic has a non-standard distribution. Use the Engle-Granger specific critical values (available in statsmodels).

5.4 The Johansen Test

For more than two series, the Johansen test extends cointegration analysis to the multivariate case. It estimates the number of cointegrating relationships (the cointegrating rank) in a system of variables using a Vector Error Correction Model (VECM) framework.

ΔYt = Π Yt−1 + ∑i=1k−1 Γi ΔYt−i + εt

The rank of Π gives the number of cointegrating relationships.
rank(Π) Interpretation
0 No cointegration — all series are I(1) independently
1 to n−1 Cointegration — there are r cointegrating relationships
n All series are stationary (no unit roots)

6. Pairs Trading: Cointegration in Action

6.1 The Strategy

Pairs trading is the most direct application of cointegration in finance:

  1. Identify a pair of cointegrated stocks.
  2. Compute the spread: St = Yt − βXt.
  3. Trade mean reversion: When the spread widens beyond a threshold (e.g., 2 standard deviations), go long the underperformer and short the outperformer.
  4. Close when the spread reverts to its mean.
Finance Term

Spread — In pairs trading, the spread is the residual from the cointegrating regression. It represents the deviation from the long-run equilibrium. A widening spread means one stock is temporarily rich relative to the other.

6.2 Python: Full Pairs Trading Pipeline

Pythonimport numpy as np
import pandas as pd
from statsmodels.tsa.stattools import coint, adfuller
import yfinance as yf

# Step 1: Download two potentially cointegrated stocks
tickers = ["KO", "PEP"]
data = yf.download(tickers, start="2018-01-01", end="2023-12-31")
prices = data["Adj Close"]

# Step 2: Engle-Granger cointegration test
score, pvalue, _ = coint(prices["KO"], prices["PEP"])
print(f"Cointegration test: stat={score:.4f}, p-value={pvalue:.4f}")

# Step 3: Estimate the hedge ratio via OLS
from sklearn.linear_model import LinearRegression
X = prices["KO"].values.reshape(-1, 1)
y = prices["PEP"].values
reg = LinearRegression().fit(X, y)
hedge_ratio = reg.coef_[0]
print(f"Hedge ratio (beta): {hedge_ratio:.4f}")

# Step 4: Compute the spread
spread = prices["PEP"] - hedge_ratio * prices["KO"]
spread_mean = spread.mean()
spread_std = spread.std()

# Step 5: Generate trading signals
z_score = (spread - spread_mean) / spread_std

signals = pd.DataFrame(index=spread.index)
signals["z_score"] = z_score
signals["long_entry"] = z_score < -2.0   # Spread too low: long PEP, short KO
signals["short_entry"] = z_score > 2.0   # Spread too high: short PEP, long KO
signals["exit"] = abs(z_score) < 0.5   # Close position near mean

print(f"Long signals:  {signals['long_entry'].sum()}")
print(f"Short signals: {signals['short_entry'].sum()}")

# Step 6: Verify spread is stationary
adf_spread = adfuller(spread.dropna())
print(f"ADF on spread: stat={adf_spread[0]:.4f}, p={adf_spread[1]:.4f}")
Common Pitfall

Cointegration can break down. Two stocks that were cointegrated in the past may diverge permanently due to structural changes (mergers, regulation, new competitors). Always use a rolling-window test and be prepared to exit if the cointegration relationship weakens. A pairs trade on a broken cointegration is just a bet that keeps bleeding.

7. VAR Models for Multivariate Financial Time Series

7.1 From Univariate to Multivariate

A Vector Autoregression (VAR) models the joint dynamics of multiple time series. Each variable is regressed on its own lags and the lags of all other variables in the system. In finance, VAR models are used to study:

Stats Bridge

The VAR(p) model is a multivariate generalization of the AR(p) model. For an n-variable system, each equation has np + 1 parameters (p lags of n variables plus an intercept). The number of parameters grows quadratically with the number of variables, making high-dimensional VAR models prone to overfitting. This is where regularization (shrinkage, LASSO-VAR, Bayesian VAR) becomes essential.

7.2 VAR(p) Specification

Yt = c + A1Yt−1 + A2Yt−2 + … + ApYt−p + εt

where Yt is an n × 1 vector, Ai are n × n coefficient matrices, εt ~ N(0, Σ)

7.3 Granger Causality

Within a VAR framework, you can test whether the lags of variable X improve the prediction of variable Y beyond Y’s own lags. This is Granger causality — not true causality, but predictive precedence. The test uses an F-statistic comparing the restricted model (without X lags) to the unrestricted model (with X lags).

7.4 Impulse Response Functions (IRFs)

IRFs trace the effect of a one-standard-deviation shock to one variable on all variables in the system over time. In finance, this answers questions like: “If the Fed unexpectedly raises rates by 25 basis points, how do stock prices, bond yields, and the dollar respond over the next 20 days?”

Pythonfrom statsmodels.tsa.api import VAR

# Build a 3-variable system: stocks, bonds, gold
tickers_var = ["SPY", "TLT", "GLD"]
data_var = yf.download(tickers_var, start="2018-01-01", end="2023-12-31")
returns_var = data_var["Adj Close"].pct_change().dropna() * 100

# Select lag order
model_var = VAR(returns_var)
lag_selection = model_var.select_order(maxlags=10)
print(lag_selection.summary())

# Fit VAR with selected order
optimal_lag = lag_selection.aic
var_result = model_var.fit(optimal_lag)
print(var_result.summary())

# Granger causality tests
for caused in tickers_var:
    for causing in tickers_var:
        if caused != causing:
            test = var_result.test_causality(caused, causing, kind="f")
            p = test.pvalue
            sig = "***" if p < 0.01 else "**" if p < 0.05 else ""
            print(f"{causing} -> {caused}: p={p:.4f} {sig}")

# Impulse response functions
irf = var_result.irf(20)
irf.plot(orth=True)
plt.tight_layout()
plt.show()

8. Comprehensive Example: Full Financial Time Series Analysis

8.1 Workflow Summary

Putting it all together, a complete financial time series analysis follows these steps:

Step Action Tool
1 Test for unit roots in price levels ADF / KPSS test
2 Compute returns (difference once) Log-differencing
3 Examine ACF/PACF of returns Correlogram
4 Fit ARIMA for the conditional mean ARIMA + AIC/BIC
5 Test for ARCH effects in residuals Ljung-Box on squared residuals
6 Fit GARCH/EGARCH for conditional variance arch library
7 Test for cointegration with related assets Engle-Granger / Johansen
8 Build VAR or VECM if cointegrated statsmodels VAR
9 Check for regime changes Markov-switching model
10 Validate out-of-sample Walk-forward evaluation

8.2 Python: Integrated Pipeline

Pythonimport numpy as np
import pandas as pd
from statsmodels.tsa.stattools import adfuller, acf, pacf
from statsmodels.stats.diagnostic import acorr_ljungbox
from arch import arch_model
import yfinance as yf

def full_ts_analysis(ticker, start="2018-01-01", end="2023-12-31"):
    """Complete time series analysis pipeline for a single asset."""
    # Data
    data = yf.download(ticker, start=start, end=end)
    prices = data["Adj Close"]
    log_prices = np.log(prices)
    returns = log_prices.diff().dropna() * 100

    results = {}

    # Step 1: Unit root tests
    adf_price = adfuller(log_prices.dropna())
    adf_ret = adfuller(returns)
    results["unit_root_prices_pval"] = adf_price[1]
    results["unit_root_returns_pval"] = adf_ret[1]

    # Step 2: ARCH effects test (Ljung-Box on squared returns)
    lb_test = acorr_ljungbox(returns**2, lags=[10], return_df=True)
    results["arch_effect_pval"] = lb_test["lb_pvalue"].values[0]

    # Step 3: Fit GARCH variants
    best_aic = np.inf
    best_model_name = None
    for vol_type, kwargs in [
        ("GARCH", {"vol": "Garch", "p": 1, "q": 1}),
        ("EGARCH", {"vol": "EGARCH", "p": 1, "q": 1}),
        ("GJR", {"vol": "Garch", "p": 1, "o": 1, "q": 1}),
    ]:
        m = arch_model(returns, **kwargs, dist="t")
        res = m.fit(disp="off")
        results[f"{vol_type}_AIC"] = res.aic
        if res.aic < best_aic:
            best_aic = res.aic
            best_model_name = vol_type

    results["best_vol_model"] = best_model_name

    # Step 4: Summary statistics
    results["mean_return"] = returns.mean()
    results["vol_return"] = returns.std()
    results["skewness"] = returns.skew()
    results["kurtosis"] = returns.kurtosis()

    return pd.Series(results)

# Run on multiple assets
tickers = ["SPY", "QQQ", "IWM", "EEM"]
analysis = pd.DataFrame({t: full_ts_analysis(t) for t in tickers})
print(analysis.T)

9. Chapter Summary

This module connected the time series methods from your statistics training to their financial applications. Here are the key mappings:

Statistics Concept Financial Application Key Nuance
Unit root / I(1) process Price levels are non-stationary Always model returns, not prices
ARMA model Return forecasting Very low signal; R² near zero
Conditional heteroscedasticity Volatility clustering The main signal is in variance, not mean
GARCH(1,1) Volatility forecasting Use t-distribution innovations
Asymmetric response Leverage effect EGARCH or GJR-GARCH preferred
Hidden Markov model Regime detection Calm vs crisis regimes
Cointegration Pairs trading Relationship can break down
VAR / Granger causality Cross-asset dynamics Beware of overfitting in high dimensions
Key Insight

The single most important lesson for statisticians entering finance: the signal is in the second moment, not the first. Predicting the direction of returns is extraordinarily difficult (markets are nearly efficient). But predicting the magnitude of returns (volatility) is feasible and has enormous practical value for risk management, option pricing, and portfolio construction.