Lesson 2.1.3 · 65 min

ARMA and ARIMA models: autocorrelation (ACF) and partial autocorrelation (PACF)

An ARMA model describes how much a series depends on its own past values and errors. In a spread that reverts, that dependence is strong and can be exploited; in the returns of an index, it is so weak that an ARMA does no better than predicting nothing. This lesson fits both cases with synthetic data with a known answer and measures the difference out of sample.

Fig. — ACF and PACF for the first 12 lags: true spread of pair A, a planted AR(1) (top), and IDA daily returns (bottom). Dashed lines: ±1.96/√n band
Level 2 lessons
  1. 2.1 · Statistics and econometrics for trading

    1. 2.1.1 Hypothesis tests, p-values, bootstrap and regression with robust standard errors
    2. 2.1.2 Stationarity: ADF, KPSS, differencing and fractional differencing
    3. 2.1.3 ARMA and ARIMA models: autocorrelation (ACF) and partial autocorrelation (PACF)
    4. 2.1.4 Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library
    5. 2.1.5 Cointegration: Engle-Granger, Johansen and the half-life of mean reversion
    6. 2.1.6 Kalman filter for dynamic hedge ratios
    7. 2.1.7 Regime detection: hidden Markov models and structural breaks
    8. 2.1.8 The multiple testing problem: Bonferroni, FDR and the “factor zoo”
  2. 2.2 · Anatomy of a trading system

    1. 2.2.1 Edge hypotheses: risk, behavior, structure and information
    2. 2.2.2 Universe, signal, filter, entry, exit, sizing and risk management
    3. 2.2.3 Frequencies: intraday, swing and position trading, and the capacity of a strategy
    4. 2.2.4 Research journal and experiment tracking against data snooping
  3. 2.3 · Strategy families

    1. 2.3.1 Trend following and momentum
    2. 2.3.2 Mean reversion: RSI and Bollinger, intraday reversal and gaps
    3. 2.3.3 Pairs trading and statistical arbitrage: distance, cointegration and Kalman
    4. 2.3.4 Carry in currencies, futures and bonds; seasonality and calendar effects
    5. 2.3.5 Factors (value, quality, low volatility, size) and event strategies
    6. 2.3.6 Introduction to market making: inventory, spread and adverse selection (Avellaneda-Stoikov)
    7. 2.3.7 Crypto-specific strategies: momentum, funding arbitrage and basis
  4. 2.4 · Backtesting: engines and paradigms

    1. 2.4.1 Vectorized backtest from scratch: matrices, lag, costs and limits
    2. 2.4.2 Event-driven backtest from scratch: loop, portfolio, orders and fills
    3. 2.4.3 vectorbt: massive parameter sweeps and portfolio simulation
    4. 2.4.4 backtesting.py and backtrader: two teaching engines and their status
    5. 2.4.5 NautilusTrader: architecture, data, strategies and the same code in backtest and live
    6. 2.4.6 LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation
    7. 2.4.7 Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing
    8. 2.4.8 Fill modeling: limit orders, queue, partial fills, gaps and stops
  5. 2.5 · Biases and validation

    1. 2.5.1 Backtest biases: look-ahead, survivorship, selection and narrative
    2. 2.5.2 In-sample and out-of-sample: anchored and rolling walk-forward
    3. 2.5.3 Combinatorial purged cross-validation for strategies
    4. 2.5.4 Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)
    5. 2.5.5 Monte Carlo: trade resampling, block bootstrap and signal permutations
    6. 2.5.6 Parameter sensitivity: plateaus versus peaks, robustness across assets and periods
    7. 2.5.7 Synthetic data for stress testing: GBM, GARCH and bootstrapping
    8. 2.5.8 “Honest backtest” checklist before moving to paper trading
  6. 2.6 · Performance metrics

    1. 2.6.1 CAGR, Sharpe, Sortino, Calmar, Omega, drawdown and Ulcer Index
    2. 2.6.2 Win rate, payoff, expectancy, alpha, beta, information ratio, turnover and capacity
    3. 2.6.3 Reports with QuantStats or pyfolio-reloaded and custom tearsheets

The memory of a series

Lesson 1.6.2 measured the autocorrelation of returns and concluded that direction has almost no memory. Lesson 2.1.2 separated stationary series from those that are not. This lesson takes the next step: describing with a model how much memory a stationary series has and using it to forecast.

ARMA models combine two ingredients:

  • AR(p), autoregressive: today's value depends on the pp previous values, yt=c+ϕ1yt−1+⋯+ϕpyt−p+εty_t = c + \phi_1 y_{t-1} + \dots + \phi_p y_{t-p} + \varepsilon_t.
  • MA(q), moving average (of the errors, not a moving average of prices): today's value depends on the qq previous shocks, yt=μ+εt+θ1εt−1+⋯+θqεt−qy_t = \mu + \varepsilon_t + \theta_1 \varepsilon_{t-1} + \dots + \theta_q \varepsilon_{t-q}.

An ARMA(p, q) has both. An ARIMA(p, d, q) is an ARMA on the series differenced dd times: an ARIMA(1,1,1) of the log price is an ARMA(1,1) of its returns. The classic Box-Jenkins procedure has four steps that are repeated until the diagnosis is clean:

clean residualsmemory leftIdentifyEstimateDiagnoseForecast
Mermaid source
flowchart TB
  I[Identify] --> E[Estimate]
  E --> D[Diagnose]
  D -- clean residuals --> P[Forecast]
  D -- memory left --> I

Two synthetic level 2 series with a known answer are used (data README):

  • the true spread of pair A, u=ln⁡y+2.1−1.5 ln⁡xu = \ln y + 2.1 - 1.5\,\ln x (in the code, multiplied by 100 to read it in %), a discrete Ornstein-Uhlenbeck: an AR(1) with ϕ=0.51/5=0.871\phi = 0.5^{1/5} = 0.871 (half-life of 5 sessions);
  • the daily returns of the IDA future, a fictional equity index with no autocorrelation of its own except the one induced by the generator's piecewise drift and a small time-series momentum.
IN [01]python
import warnings
import numpy as np
import pandas as pd
from statsmodels.tsa.stattools import acf, pacf

cont = pd.read_parquet("futuros2_continuos.parquet")
ida = np.log(cont[cont["mercado"] == "IDA"]
             .set_index("fecha")["cierre_ajustado"])
r = 100 * ida.diff().dropna()               # return in %

pares = pd.read_parquet("pares2.parquet").pivot(
    index="fecha", columns="activo", values="precio")
# true spread in logs (ln y = -2.1 + 1.5·ln x + u), in %
sA = 100 * (np.log(pares["PZAY"]) + 2.1
            - 1.5 * np.log(pares["PZAX"]))

print(f"IDA: {len(r)} sessions; spread A: {len(sA)} sessions")
print(f"true φ of spread A: {0.5 ** (1 / 5):.4f}")
OUT [01]Two series: a spread that reverts and some returns
IDA: 6538 sessions; spread A: 3018 sessions
true φ of spread A: 0.8706

ACF and PACF: the fingerprint of each model

The ACF (autocorrelation function) is the correlation of the series with itself kk periods earlier. The PACF (partial autocorrelation) is the correlation at lag kk net of the intermediate lags: the last coefficient of a regression of yty_t on yt−1,…,yt−ky_{t-1}, \dots, y_{t-k}. Each model leaves a different fingerprint on the two:

ModelACFPACF
White noiseEverything inside the bandEverything inside the band
AR(p)Decays (geometric or oscillating)Cuts off after lag pp
MA(q)Cuts off after lag qqDecays
ARMA(p, q)Decays from qq onDecays from pp on

The noise band is ±1.96/n\pm 1.96/\sqrt{n}: if the series had no memory, 95% of the autocorrelations would fall inside it. Read as a decision tree, the table looks like this:

yesnoyesnoyesnoACF inside band?No memoryPACF cuts off?AR of order pACF cuts off?MA of order qARMA p, q
Mermaid source
flowchart TB
  A{ACF inside band?} -- yes --> W[No memory]
  A -- no --> B{PACF cuts off?}
  B -- yes --> AR[AR of order p]
  B -- no --> C{ACF cuts off?}
  C -- yes --> MA[MA of order q]
  C -- no --> AM[ARMA p, q]

In practice the fingerprints mix with sampling noise and the grid of information criteria (further below) decides among the candidates.

IN [02]python
def huella(x, k=5):
    x = pd.Series(x).dropna()
    return pd.DataFrame({"acf": acf(x, nlags=k)[1:],
                         "pacf": pacf(x, nlags=k)[1:]},
                        index=range(1, k + 1))

banda = {"spread A": 1.96 / np.sqrt(len(sA)),
         "IDA": 1.96 / np.sqrt(len(r))}
print({k: round(v, 3) for k, v in banda.items()})
pd.concat({"spread A": huella(sA), "IDA ret.": huella(r)},
          axis=1).round(3)
OUT [02]ACF and PACF of the two cases
{'spread A': np.float64(0.036), 'IDA': np.float64(0.024)}
  spread A        IDA ret.
       acf   pacf      acf   pacf
1    0.863  0.864    0.020  0.020
2    0.742 -0.013   -0.015 -0.016
3    0.640  0.007    0.017  0.018
4    0.549 -0.007    0.060  0.059
5    0.469 -0.011    0.025  0.024

The figure at the top draws the first 12 lags. Spread A has the textbook fingerprint of an AR(1): the ACF falls like a power of 0.86 (0.74 is almost 0.86²; 0.64, almost 0.86³) and the PACF is 0.86 at lag 1 and nothing afterwards. The IDA returns have tiny autocorrelations, from −0.015 to 0.06; those at lag 4 (0.060) and lag 5 (0.025) fall outside the band (±0.024), but with 6,538 observations the band is narrow and any minute dependence exceeds it.

An MA shows up in a familiar place: the overlapping returns of lesson 2.1.1. The sum of 5 daily returns computed every day is an MA(4): two sums kk days apart share 5−k5-k days, so their theoretical ACF is (5−k)/5(5-k)/5 up to lag 4 and zero afterwards.

IN [03]python
r5 = r.rolling(5).sum().dropna()
ma = huella(r5, k=7)
ma["acf_teorica"] = [max(5 - k, 0) / 5 for k in ma.index]
ma.round(3)
OUT [03]A home-made MA(4): overlapping 5-day sums
     acf   pacf  acf_teorica
1  0.814  0.815          0.8
2  0.628 -0.105          0.6
3  0.454 -0.079          0.4
4  0.278 -0.125          0.2
5  0.084 -0.193          0.0
6  0.072  0.402          0.0
7  0.078 -0.021          0.0
Fig. — ACF of the sum of 5 daily IDA returns computed every day (bars) against the theoretical MA(4) value, (5 − k)/5 (squares)

The ACF follows the theory (0.81 versus 0.80; 0.63 versus 0.60…) and falls almost to zero after lag 4; what is left at 5, 6 and 7 (from 0.07 to 0.08) is IDA's own small autocorrelation, above all the one at lag 4, which enters several sums at once. The PACF, on the other hand, does not cut off: it changes sign and rises again at lag 6, the fingerprint of an MA. This is the technical reason why an overlapping target inflates the t's: it is an MA series by construction.

Estimating: the AR(1) of the spread

statsmodels.tsa.arima.model.ARIMA estimates by maximum likelihood (with a state space model). The order is given as (p, d, q) and trend="c" adds the constant. NumPy arrays are passed: with a date index without a regular frequency (NYSE sessions do not have one), statsmodels warns that it cannot forecast future dates.

IN [04]python
from statsmodels.tsa.arima.model import ARIMA

res_a = ARIMA(sA.to_numpy(), order=(1, 0, 0), trend="c").fit()
tabla = pd.DataFrame({"estimado": res_a.params, "error": res_a.bse},
                     index=["media", "phi", "sigma2"])
tabla["verdad"] = [0, 0.5 ** (1 / 5), np.nan]
tabla.round(4)
OUT [04]AR(1) of spread A
        estimado   error  verdad
media     0.2329  0.1297  0.0000
phi       0.8655  0.0090  0.8706
sigma2    0.9475  0.0245     NaN

The estimated ϕ\phi, 0.865 with a standard error of 0.009, is less than one standard error away from the true one (0.871). The mean of the spread (0.23% with an error of 0.13) cannot be told apart from zero at 5%, as it should be with the true α\alpha and β\beta. σ2\sigma^2 (0.95, in %²) is the variance of the daily shock; that of the spread is larger, σ2/(1−ϕ2)\sigma^2/(1-\phi^2), because the shocks accumulate.

Choosing the order: AIC and BIC

More parameters always fit better in sample. Information criteria penalize each parameter:

AIC=−2ln⁡L+2k,BIC=−2ln⁡L+kln⁡n\text{AIC} = -2\ln L + 2k, \qquad \text{BIC} = -2\ln L + k\ln n

with LL the likelihood, kk the number of parameters and nn the observations. The smallest is chosen. With large nn, the BIC penalty (ln⁡n≈8\ln n \approx 8 with 3,000 data points) is four times that of the AIC, and that is why it chooses simpler models.

IN [05]python
from statsmodels.tools.sm_exceptions import (ConvergenceWarning,
                                             EstimationWarning)

def rejilla(x, pmax=2, qmax=2):
    filas = []
    for p in range(pmax + 1):
        for q in range(qmax + 1):
            with warnings.catch_warnings():
                # some MA fits do not fully converge or start from
                # invalid values; it does not change the chosen order
                warnings.simplefilter("ignore", ConvergenceWarning)
                warnings.simplefilter("ignore", EstimationWarning)
                f = ARIMA(x, order=(p, 0, q), trend="c").fit()
            filas.append({"p": p, "q": q, "aic": f.aic, "bic": f.bic})
    t = pd.DataFrame(filas)
    return t.assign(d_aic=t.aic - t.aic.min(),
                    d_bic=t.bic - t.bic.min())

ra = rejilla(sA.to_numpy())
rr = rejilla(r.to_numpy())
cols = ["p", "q", "d_aic", "d_bic"]
print("AIC minimum for the spread (p, q):",
      ra.loc[ra.aic.idxmin(), ["p", "q"]].tolist())
print("spread A, by BIC:")
print(ra.sort_values("bic")[cols].head(3).round(1).to_string(
    index=False))
print("IDA returns, by BIC (and the ARMA(1,1)):")
orden = rr.sort_values("bic")
orden = pd.concat([orden.head(4),
                   orden[(orden.p == 1) & (orden.q == 1)]])
print(orden[cols].round(1).to_string(index=False))
OUT [05]Grid of orders for the spread and for the returns
AIC minimum for the spread (p, q): [1, 0]
spread A, by BIC:
 p  q  d_aic  d_bic
 1  0    0.0    0.0
 1  1    1.5    7.5
 2  0    1.5    7.5
IDA returns, by BIC (and the ARMA(1,1)):
 p  q  d_aic  d_bic
 0  0   26.7    0.0
 2  2    0.0    0.4
 1  2   12.2    5.8
 0  1   26.1    6.2
 1  1   30.0   16.9

On the spread, both criteria choose the AR(1), the correct answer, but with different margins: the BIC gives it a 7.5-point lead over the next one and the AIC only 1.5, because it penalizes the extra parameter less. On the IDA returns the two criteria disagree: the BIC chooses the model with no memory (0,0), and the AIC, an ARMA(2,2) with a 26.7-point lead over it. With 6,538 observations, four more parameters cost 8 AIC points and 35 BIC points; some minute dependencies pay for the former and not for the latter. The ARMA(1,1), the usual starting point, ranks behind the (0,0) on both (30.0 and 16.9 points). The coefficients explain why:

IN [06]python
res_r = ARIMA(r.to_numpy(), order=(1, 0, 1), trend="c").fit()
pd.Series(res_r.params, index=["media", "phi", "theta",
                               "sigma2"]).round(4)
OUT [06]The ARMA(1,1) of the returns
media     0.0251
phi      -0.9201
theta     0.9240
sigma2    1.6084
dtype: float64

ϕ=−0.920\phi = -0.920 and θ=0.924\theta = 0.924: they almost cancel out. In (1−ϕB) yt=(1+θB) εt(1 - \phi B)\,y_t = (1 + \theta B)\,\varepsilon_t, with ϕ≈−θ\phi \approx -\theta the two factors cancel and what is left is yt≈εty_t \approx \varepsilon_t: white noise with extra parameters. It is the typical way in which an ARMA fitted to returns says “there is nothing here”. The minute dependencies that do exist (the generator's piecewise drift, with bull years and crises on fixed dates, and the planted momentum, which is slow) do not have the shape of a low-order ARMA.

Diagnosing: Ljung-Box

If the model is good, its residuals should have no memory. The Ljung-Box test summarizes the first mm autocorrelations ρ^k\hat\rho_k of a series in one statistic,

Q=n(n+2)∑k=1mρ^k2n−kQ = n(n+2)\sum_{k=1}^{m}\frac{\hat\rho_k^2}{n-k}

which, without autocorrelation, is distributed as a χ2\chi^2 with mm degrees of freedom (minus the estimated ARMA parameters, if applied to residuals).

IN [07]python
from statsmodels.stats.diagnostic import acorr_ljungbox

def lb(x, gl=0):
    t = acorr_ljungbox(x, lags=[10], model_df=gl)
    return t.iloc[0].round(4).to_dict()

pd.DataFrame({
    "spread A": lb(sA),
    "AR(1) residuals": lb(res_a.resid, gl=1),
    "IDA ret.": lb(r),
    "ARMA(1,1) residuals": lb(res_r.resid, gl=2)}).T
OUT [07]Ljung-Box before and after modeling
                       lb_stat  lb_pvalue
spread A             8347.1553     0.0000
AR(1) residuals         6.8739     0.6502
IDA ret.               50.5221     0.0000
ARMA(1,1) residuals    49.6918     0.0000

Spread A has an enormous memory (Q = 8,347); the residuals of the AR(1) no longer do (p = 0.65): the model captures all the structure. The IDA returns reject the absence of autocorrelation (Q = 50.5, p < 0.0001), and the residuals of the ARMA(1,1) almost the same (Q = 49.7): the model has removed nothing, as its coefficients announced. The dependence that Ljung-Box detects is mostly at lag 4 (0.060), out of reach of an ARMA(1,1). You could raise the order until the residuals are clean, but the question that matters is a different one: whether any of this is useful for forecasting.

Forecasting out of sample

Each model is estimated with part of the history and the rest is forecast without re-estimating: IDA returns with 2000-2019 and a forecast of 2020-2025; spread A with 2014-2021 and a forecast of 2022-2025. res.apply(nuevos_datos) applies the already estimated parameters to new data and returns in fittedvalues the one-step forecast for each date, made with the information of the previous day. The benchmark is the naive forecast: zero for the return (or the training mean) and the last value for the spread (random walk).

IN [08]python
import statsmodels.api as sm

tr, te = r[:"2019"].to_numpy(), r["2020":].to_numpy()
m = ARIMA(tr, order=(1, 0, 1), trend="c").fit()
f1 = m.apply(te).fittedvalues            # E[r_t | up to t-1]

def ecm(real, prev):
    return np.mean((real - prev) ** 2)

print(f"MSE ARMA / MSE zero: {ecm(te, f1) / ecm(te, 0):.4f}")
print(f"MSE ARMA / MSE mean: {ecm(te, f1) / ecm(te, tr.mean()):.4f}")
print(f"sign hits: {np.mean(np.sign(f1) == np.sign(te)):.1%}"
      f" (up days: {np.mean(te > 0):.1%})")

# Diebold-Mariano: is the loss difference different from zero?
dif = (te - f1) ** 2 - te ** 2
dm = sm.OLS(dif, np.ones(len(dif))).fit(
    cov_type="HAC", cov_kwds={"maxlags": 5})
print(f"Diebold-Mariano: t = {dm.tvalues[0]:.2f}, "
      f"p = {dm.pvalues[0]:.3f}")
OUT [08]IDA returns: ARMA(1,1) versus not predicting
MSE ARMA / MSE zero: 1.0027
MSE ARMA / MSE mean: 1.0030
sign hits: 53.4% (up days: 53.2%)
Diebold-Mariano: t = 1.11, p = 0.265

The ARMA(1,1) increases the mean squared error by 0.3% versus forecasting zero. It gets the sign right on 53.4% of the days, but up days are 53.2%: an “always long” rule gets the same hit rate. The Diebold-Mariano test (1995) takes the loss difference of each day and tests whether its mean is zero, with Newey-West errors (lesson 2.1.1): t = 1.11, p = 0.27. The difference cannot be told apart from chance, and it goes against the model. This is the usual result of an ARMA on returns: some significant autocorrelation in sample, nothing exploitable out of sample.

On the spread the opposite happens. The hh-step forecast of an AR(1) has a closed form, u^t+h=μ+ϕh(ut−μ)\hat u_{t+h} = \mu + \phi^h (u_t - \mu), and it is compared with the random walk (u^t+h=ut\hat u_{t+h} = u_t) at several horizons; for the returns, the forecast of the sum of the next hh is compared with zero:

IN [09]python
ta, ea = sA[:"2021"].to_numpy(), sA["2022":].to_numpy()
mu, phi, _ = ARIMA(ta, order=(1, 0, 0), trend="c").fit().params
todo, n0 = np.r_[ta, ea], len(ta)
c, phi_r, _, _ = m.params

filas = {}
for h in [1, 2, 5, 10, 20]:
    t0 = np.arange(n0, len(todo) - h + 1)  # forecast made at t0 - 1
    ultimo, real = todo[t0 - 1], todo[t0 + h - 1]
    ar = mu + phi ** h * (ultimo - mu)
    # returns: forecast sum of the next h versus 0
    idx = np.arange(len(te) - h + 1)
    prev = np.array([sum(c + phi_r ** j * (f1[i] - c)
                         for j in range(h)) for i in idx])
    suma = np.array([te[i:i + h].sum() for i in idx])
    filas[h] = {"spread: AR/walk": ecm(real, ar) / ecm(real, ultimo),
                "ret.: ARMA/zero": ecm(suma, prev) / ecm(suma, 0)}
pd.DataFrame(filas).T.rename_axis("h").round(3)
OUT [09]Gain versus the naive forecast by horizon
    spread: AR/walk  ret.: ARMA/zero
h
1             0.924            1.003
2             0.857            1.002
5             0.710            1.003
10            0.587            1.001
20            0.535            0.999
Fig. — Out-of-sample mean squared error (2020-2025 or 2022-2025) relative to the naive forecast, by horizon: AR(1) of spread A against the random walk and ARMA(1,1) of IDA returns against forecasting zero

At one day, the AR(1) of the spread only reduces the error by 8% versus the random walk: with ϕ=0.87\phi = 0.87, the last value is already almost the best forecast. At 10 sessions, the reduction is 41%, because the spread has covered a good part of the way back (ϕ10≈0.25\phi^{10} \approx 0.25) and the random walk does not know it. With the returns, the ratio stays between 0.999 and 1.003 at every horizon: no gain. The difference between the two lines is the difference between a structure that can be traded (lesson 2.3.3) and one that cannot.

ARIMA on prices

With d=1d = 1, ARIMA differences internally. An ARIMA(1,1,1) of the log price is the ARMA(1,1) of its returns, with no constant unless a trend is requested:

IN [10]python
lp = 100 * ida.to_numpy()
a111 = ARIMA(lp, order=(1, 1, 1), trend="t").fit()
pd.DataFrame({"ARIMA(1,1,1) log price": a111.params[:3],
              "ARMA(1,1) return": res_r.params[:3]},
             index=["deriva", "phi", "theta"]).round(4)
OUT [10]ARIMA(1,1,1) of the log price = ARMA(1,1) of the returns
        ARIMA(1,1,1) log price  ARMA(1,1) return
deriva                  0.0251            0.0251
phi                    -0.9201           -0.9201
theta                   0.9240            0.9240

The coefficients match almost exactly (with trend="t" the drift of the price is the mean of the return). Lesson 2.1.2 explains when to difference; ARIMA just does it for you. For trading, the useful part of ARIMA is almost always in the ARMA of something stationary: a spread, a basis, a rate differential.

SeriesWhat to expect from an ARMAUse
Daily returnsTiny or canceling coefficients, forecast ≈ zeroFilter the mean before a GARCH (lesson 2.1.4)
Cointegrated spreadAR(1) with a clear φ < 1Half-life, entry bands (lessons 2.1.5 and 2.3.3)
Overlapping returnsMA(h − 1) by constructionRemember that the t is inflated (lesson 2.1.1)
Volatility or absolute valueHigh persistenceA GARCH is better (lesson 2.1.4)

Exercises

  1. Fit an AR(1) to the true spread of pair B (ln⁡y−0.7−0.8 ln⁡x\ln y - 0.7 - 0.8\,\ln x, in %) and compute its half-life, ln⁡0.5/ln⁡ϕ\ln 0.5 / \ln \phi. Compare it with the true one, 15 sessions.
Solution
IN [11]python
sB = 100 * (np.log(pares["PZBY"]) - 0.7
            - 0.8 * np.log(pares["PZBX"]))
fb = ARIMA(sB.to_numpy(), order=(1, 0, 0), trend="c").fit()
phi_b = fb.params[1]
print(f"phi = {phi_b:.4f} (truth {0.5 ** (1 / 15):.4f}); "
      f"half-life = {np.log(0.5) / np.log(phi_b):.1f} sessions")
OUT [11]Half-life of spread B
phi = 0.9479 (truth 0.9548); half-life = 13.0 sessions

The AR(1) estimates 13.0 sessions versus the true 15, even though ϕ\phi is only off by 0.007. With ϕ\phi close to 1, a small error in ϕ\phi is a large error in the half-life (the function ln⁡0.5/ln⁡ϕ\ln 0.5/\ln\phi is very steep near 1): that is why long half-lives are estimated less well. Lesson 2.1.5 does the same with the estimated β\beta.

  1. Simulate with seed 2131 an AR(2) of 3,000 observations with ϕ1=0.5\phi_1 = 0.5 and ϕ2=0.3\phi_2 = 0.3. Check that the PACF cuts off after lag 2 and that the BIC chooses the correct order in the grid.
Solution
IN [12]python
rng = np.random.default_rng(2131)
e = rng.standard_normal(3000)
x = np.zeros(3000)
for t in range(2, 3000):
    x[t] = 0.5 * x[t - 1] + 0.3 * x[t - 2] + e[t]
print("PACF:", np.round(pacf(x, nlags=4)[1:], 3))
g = rejilla(x)
print(g.sort_values("bic")[["p", "q", "d_bic"]].head(3)
      .round(1).to_string(index=False))
OUT [12]Simulated AR(2): PACF and BIC
PACF: [0.677 0.306 0.02  0.001]
 p  q  d_bic
 2  0    0.0
 2  1    6.9
 1  2    7.6

The PACF is ≈ 0.7 at lag 1 (the autocorrelation of an AR(2) with these coefficients is 0.5/(1−0.3)=0.710.5/(1-0.3) = 0.71), ≈ 0.3 at lag 2 (the ϕ2\phi_2 itself) and almost zero afterwards: the fingerprint of an AR(2). The BIC chooses it. With simulated data the identification is clean; with market data, rarely.

  1. A colleague fits an ARMA(3,3) to the daily returns of a stock, gets the lowest AIC in their grid and a sign hit rate of 54% in sample. What three checks would you ask for before believing it?
Solution

First: an out-of-sample forecast with the parameters and the order fixed with earlier data, without re-estimating on the test stretch. Second: comparing with the right benchmark, which is not 50% but the proportion of up days (an “always long” rule) and the error of forecasting zero, with a Diebold-Mariano test. Third: costs; a 54% hit rate with small daily returns and one trade a day comes to nothing after the bid-ask spread and commissions (lesson 2.4.7). And a fourth, as a bonus: how many orders and stocks they tried before this one (lesson 2.1.8).

Each answer is folded under its question

Self-assessment

  1. The ACF of spread A decays slowly (0.86, 0.74, 0.64…) and its PACF is 0.86 at lag 1 and almost zero afterwards. What model does it suggest?

    • AAn MA(1)
    • BA random walk
    • CAn ARMA(2,2)
    • DAn AR(1) with φ ≈ 0.86
    Show answer

    Answer D. An AR(1) with φ ≈ 0.86

    In an AR(1), the ACF decays geometrically (φ, φ², φ³…) and the PACF cuts off after the first lag. The known answer for pair A is an AR(1) with φ = 0.5^(1/5) = 0.871; the fit gives 0.866.

  2. The rolling sum of 5 daily returns has an ACF of 0.81, 0.63, 0.45, 0.28 and less than 0.1 from lag 5 on. What process is it?

    • AAn AR(4)
    • BAn MA(4): the ACF cuts off at lag q = 4 and follows the theory (5 − k)/5
    • CA non-stationary process
    • DWhite noise
    Show answer

    Answer B. An MA(4): the ACF cuts off at lag q = 4 and follows the theory (5 − k)/5

    Two 5-day sums k days apart share 5 − k days. It is the overlap of lesson 2.1.1 seen as a model: an MA(4) with all coefficients equal to 1. The PACF, on the other hand, decays and oscillates.

  3. An ARMA(1,1) of the IDA returns gives φ = −0.920 and θ = 0.924. What does it indicate that the two coefficients almost cancel out?

    • AThat the model predicts very well
    • BThat there is an estimation error
    • CThat the model is almost white noise: the AR and MA terms offset each other and hardly any memory is left. That is why the BIC prefers the model with no memory and, out of sample, the ARMA is 0.3% worse than forecasting zero
    • DThat the series is a random walk
    Show answer

    Answer C. That the model is almost white noise: the AR and MA terms offset each other and hardly any memory is left. That is why the BIC prefers the model with no memory and, out of sample, the ARMA is 0.3% worse than forecasting zero

    With φ ≈ −θ, the factors (1 − φB) and (1 + θB) almost cancel and the ARMA(1,1) reduces to white noise. Both criteria rank it behind the (0,0) model (16.9 BIC points, 30.0 AIC points), and the Diebold-Mariano test does not distinguish its forecast from forecasting zero (p = 0.27).

  4. What is the Ljung-Box test on the residuals of a model for?

    • ATo check that no unexplained autocorrelation is left: if the residuals have memory, the model has left out structure
    • BTo choose the number of lags
    • CTo measure volatility
    • DTo compare two models out of sample
    Show answer

    Answer A. To check that no unexplained autocorrelation is left: if the residuals have memory, the model has left out structure

    Ljung-Box summarizes the autocorrelations of the first lags in one statistic; its null is that they are all zero. With the residuals of the AR(1) of spread A, p = 0.65: no structure is left. With the IDA returns and with the residuals of their ARMA(1,1), p < 0.0001: there are small dependencies that the model does not capture.

  5. Out of sample, the AR(1) of spread A reduces the squared error of the 10-session forecast by 41% versus the random walk. Why so much more than at 1 session (8%)?

    • ABy luck
    • BBecause the model is re-estimated every day
    • CBecause volatility falls with the horizon
    • DBecause at one day the last value is already almost the best forecast (φ = 0.87); at 10 days the spread has covered a good part of the way to its mean (φ to the power of 10 ≈ 0.25) and the random walk does not know it
    Show answer

    Answer D. Because at one day the last value is already almost the best forecast (φ = 0.87); at 10 days the spread has covered a good part of the way to its mean (φ to the power of 10 ≈ 0.25) and the random walk does not know it

    The h-step forecast of the AR(1) is the mean plus φ to the power of h times today's distance to the mean: it converges to the mean. That is why the reversion of a spread is traded days ahead and not on the next day.

Key points

  • AR(p): ACF that decays and PACF that cuts off at p. MA(q): ACF that cuts off at q and PACF that decays. No memory: both inside the ±1.96/√n band.
  • The spread of pair A is a planted AR(1) with φ = 0.871: the ACF and the PACF identify it and the fit gives 0.866.
  • An h-day return computed every day is an MA(h − 1): the overlap manufactures autocorrelation.
  • BIC penalizes parameters more than AIC and tends to choose simpler models; both are computed in sample and guarantee nothing out of sample.
  • On the IDA returns, the BIC chooses the model with no memory and the ARMA(1,1) is, out of sample, 0.3% worse than forecasting zero (not significant): the norm with real returns.
  • Where there is structure (a stationary spread), the AR(1) beats the random walk, and more so the longer the horizon: 41% at 10 sessions.

Resources