"Each of us wanders forever, neither one ever settling down. Yet the gap between us refuses to wander. Pull me too far ahead and the rope goes taut; I get yanked back. We cannot stop holding hands."
Two Random Walks That Cannot Stop Holding Hands
Two series can each be non-stationary, wandering with no fixed mean, and yet be tethered so that a particular linear combination of them is stationary; that tether is cointegration, and the model that exploits it is the error-correction model, which forecasts using the disequilibrium gap itself as a predictor. The naive instinct, differencing everything until it is stationary and then running a vector autoregression on the differences, throws away the one thing worth keeping: the long-run relationship that says these series belong together. Cointegration is the rigorous statement that "belonging together" can survive even when each series individually is a random walk, and the vector error-correction model is the machinery that turns the gap into a forecast: when the gap opens, the model predicts that the series will move to close it. This section first shows why ignoring this structure manufactures nonsense (the spurious-regression trap), then defines cointegration and tests for it two ways (Engle-Granger and Johansen), builds the error-correction model and its speed-of-adjustment coefficients, and ends with a full worked example that constructs cointegrated series, tests them from scratch and with statsmodels, and trades a mean-reverting spread.
The vector autoregression (VAR) built earlier in this chapter (Section 6.1) is the workhorse that lets several series predict one another. It carried an unstated assumption that the books of Part II have hammered repeatedly: the series must be stationary for the second-order theory to apply. When the components are non-stationary, the textbook fix is to difference each one and fit the VAR to the differences. That fix works, but it is lossy in a way this section repairs. Differencing a pair of series that share a long-run equilibrium erases exactly that equilibrium, because the equilibrium lives in the levels, not in the changes. Cointegration is the structure that the level relationship encodes, and the vector error-correction model (VECM) is the reparameterization of a differenced VAR that smuggles the level relationship back in. We lean throughout on the unit-root machinery of Section 3.2 and the notation symbols $I(d)$, $\nabla$, and the lag operator $L$ collected in the unified notation table of Appendix A.
1. The Spurious-Regression Problem Revisited Intermediate
Recall from Section 3.2 that an integrated-of-order-one series, written $I(1)$, is one whose first difference is stationary but whose level wanders without a fixed mean; the canonical example is the random walk $X_t = X_{t-1} + \varepsilon_t$. Now take two independent random walks $X_t$ and $Y_t$, generated by entirely unrelated shock streams, and regress one on the other by ordinary least squares,
$$Y_t = \alpha + \beta X_t + u_t.$$Common sense says the estimated slope $\hat\beta$ should be near zero and statistically insignificant, because the two series have nothing to do with each other. Common sense is wrong, and the way it is wrong is one of the most important cautionary results in all of time-series econometrics. Granger and Newbold demonstrated in 1974 that this regression routinely returns a large $\hat\beta$, a huge $t$-statistic, and an $R^2$ near one, declaring a strong significant relationship between two series that are provably independent. The standard inference is not merely weak; it is actively misleading, rejecting the true null hypothesis of no relationship far more often than the nominal five percent. This is the spurious-regression problem.
The mechanism is precise. Ordinary least squares assumes the residual $u_t$ is stationary, so that its variance is finite and the usual standard-error formulas apply. When $X_t$ and $Y_t$ are independent random walks, no value of $\beta$ makes $u_t = Y_t - \alpha - \beta X_t$ stationary: the residual is itself a random walk, its variance grows with the sample size, and the $t$-statistic, which divides by an estimate of that variance, is computed against a quantity that does not converge. The reported standard errors are too small by a factor that grows with the sample, so the $t$-statistic is inflated without bound. More data makes it worse, not better, which is the tell that distinguishes a spurious regression from a real one. The numeric experiment below makes the failure visible.
Simulate two independent Gaussian random walks of length $n = 300$, regress $Y$ on $X$, and record the $t$-statistic on $\hat\beta$ and the $R^2$. A single draw might return $\hat\beta = 0.71$, $t = 14.3$, and $R^2 = 0.41$, which under classical theory would scream "highly significant." Repeat the experiment over $2000$ independent pairs and count how often $|t| > 1.96$, the nominal five percent critical value. Instead of the expected five percent, roughly seventy to seventy-five percent of the regressions reject the (true) null of no relationship. Push $n$ from $300$ to $1200$ and the rejection rate climbs further toward eighty-five percent, because the spurious $t$-statistic scales like $\sqrt{n}$. The correct critical value for this regression is not $1.96$; it is wide and grows with the sample, which is exactly the message of the Engle-Granger cointegration test in subsection three: when residuals can be integrated, you must use a unit-root null distribution, never the Gaussian one.
The cure is not to give up on level relationships. Differencing both series and regressing $\nabla Y_t$ on $\nabla X_t$ does restore valid inference, because the differences are stationary, but it answers a different and weaker question. The differenced regression asks "do short-run changes move together?" and discards the levels, which is where any long-run equilibrium lives. If $Y$ and $X$ are genuinely tied together in the long run (think of two prices that track the same underlying value), differencing throws that tie away, leaving a model that can describe wiggle-to-wiggle co-movement but is blind to the gravitational pull that keeps the two series from drifting apart forever. The next subsection names the exception that rescues level regressions: the special case where a linear combination of integrated series is, against the odds, stationary.
It is worth being precise about what differencing destroys, because the loss is not obvious until you write it out. Suppose the true relationship is the level equation $Y_t = \beta X_t + z_t$ with $z_t$ a small stationary error. Differencing both sides gives $\nabla Y_t = \beta\,\nabla X_t + \nabla z_t$, which still contains $\beta$, so the slope survives. What does not survive is the level information: the differenced model has no term that says "and when $Y_t$ drifts above $\beta X_t$, it tends to come back." That restoring information lives entirely in $z_t$ itself, not in its change $\nabla z_t$, and the differenced regression never sees $z_t$. A model fit on differences alone can be arbitrarily far from the equilibrium and feel no pull, because the only variable that measures distance from equilibrium has been differenced out of existence. The error-correction model of subsection four exists precisely to add that one missing level term back, and the rest of this section is the story of how to find it, test it, and forecast with it.
Every $t$-table, $F$-table, and $p$-value in a standard regression output is computed under the assumption that the regression residual is stationary with finite variance. When you regress one $I(1)$ series on another, that assumption can fail silently: the software prints a confident three-decimal $p$-value computed from a distribution that does not apply to your data. The defensive habit is to test the residual for a unit root before believing any level regression on non-stationary series. If the residual is stationary, the level relationship is real and you have found cointegration; if the residual is itself integrated, the regression is spurious and the impressive statistics are an artifact. The unit-root test of Section 3.2 is therefore not an optional diagnostic here, it is the gatekeeper that decides whether the regression means anything at all.
2. Cointegration: A Shared Long-Run Equilibrium Intermediate
Two or more $I(1)$ series are cointegrated if some non-trivial linear combination of them is stationary, that is $I(0)$. Formally, for a vector of $k$ series $\mathbf{y}_t = (y_{1t}, \dots, y_{kt})^\top$, each individually $I(1)$, cointegration holds if there exists a non-zero vector $\boldsymbol{\beta} = (\beta_1, \dots, \beta_k)^\top$, the cointegrating vector, such that
$$z_t = \boldsymbol{\beta}^\top \mathbf{y}_t = \beta_1 y_{1t} + \cdots + \beta_k y_{kt} \;\sim\; I(0).$$The stationary combination $z_t$ is the equilibrium error or disequilibrium: it measures how far the system currently sits from its long-run resting relationship $\boldsymbol{\beta}^\top \mathbf{y}_t = 0$. Because $z_t$ is stationary, it has a fixed mean and finite variance, so it cannot wander off; it is forever pulled back toward its mean. That single fact is the whole content of cointegration. Each series individually is free to wander anywhere ($I(1)$, infinite-variance level), but the particular combination $\boldsymbol{\beta}^\top \mathbf{y}_t$ is trapped near a constant. The series cannot drift arbitrarily far apart, because doing so would require $z_t$ to grow without bound, which a stationary process is forbidden to do.
The classic intuition is two drunks tied together by a short rope, an image due to the econometrician Michael Murray. Each drunk, taken alone, executes a random walk: their individual paths are $I(1)$, unpredictable, mean-free. But the distance between them is leashed by the rope, so it is stationary, $I(0)$. Knowing where one drunk is tells you nothing about where the pair will be in an hour (the level is a random walk), yet it tells you a great deal about where the other drunk is right now (within a rope-length). Cointegration is precisely this: individually unforecastable, jointly tethered. In finance the two drunks are two prices of closely related assets, two share classes of one company, an index and its futures contract, or two competing firms in the same sector; the rope is arbitrage, which punishes any trader who lets the prices drift too far apart.
Clive Granger shared the 2003 Nobel Memorial Prize in Economics for the idea of cointegration (Robert Engle shared it the same year for volatility modeling, the ARCH/GARCH thread of Section 6.5). What is charming is how small the seed idea is: two things that each wander can still have a difference that does not. Granger reportedly arrived at it partly out of irritation at the spurious-regression results he and Newbold had published, which left a puzzle: if level regressions on integrated series are usually nonsense, why do some of them, like consumption on income, look so stable and economically sensible? Cointegration is the answer to that puzzle. Some level relationships are real precisely because the residual, against all odds, is stationary.
A short hierarchy of cases clarifies the definition. If $X$ and $Y$ are both $I(0)$ already, every linear combination is stationary and the word "cointegration" does not apply; the interesting case requires the components to be individually non-stationary. If $X$ and $Y$ are both $I(1)$ but independent, then generically no combination is stationary, every $\boldsymbol{\beta}^\top \mathbf{y}_t$ is itself $I(1)$, and we are back in the spurious-regression trap of subsection one. Cointegration is the knife-edge middle case: both components $I(1)$, yet one special direction $\boldsymbol{\beta}$ collapses them to stationarity. With $k$ series there can be up to $k-1$ linearly independent cointegrating vectors, the cointegrating rank $r$, and determining $r$ is exactly what the Johansen test of the next subsection does. A useful way to hold the rank in mind: each cointegrating vector kills one of the system's common stochastic trends, so a $k$-series system driven by $k - r$ shared random walks has cointegrating rank $r$. Two prices driven by one common trend ($k = 2$, one trend) have rank $r = 1$, the case the worked example builds; three exchange rates driven by one common factor would have rank $r = 2$, two independent stationary combinations. The numeric example pins down what the equilibrium error looks like.
Let $X_t$ be a random walk, $X_t = X_{t-1} + \varepsilon_t^{(x)}$, and define $Y_t = 2 X_t + 5 + w_t$ where $w_t$ is stationary white noise with standard deviation $0.5$. Then $Y_t$ is $I(1)$ (it inherits the random-walk level from $X_t$), yet the combination $z_t = Y_t - 2 X_t = 5 + w_t$ is stationary with mean $5$ and standard deviation $0.5$. The cointegrating vector is $\boldsymbol{\beta} = (1, -2)^\top$ acting on $(Y_t, X_t)$, and the long-run equilibrium is $Y_t = 5 + 2 X_t$. If at some time $Y_t$ jumps to $2 X_t + 8$, the equilibrium error is $z_t = 8$, three standard deviations above its mean of $5$; cointegration guarantees this gap is transient, that subsequent moves will pull $z_t$ back toward $5$. A trader reads $z_t = 8$ as "$Y$ is rich relative to $X$" and bets on the gap closing. That bet is the pairs trade of subsection five, and its entire statistical justification is the stationarity of $z_t$.
3. Testing for Cointegration: Engle-Granger and Johansen Advanced
Two tests dominate practice, and they answer the cointegration question from opposite directions. The Engle-Granger two-step procedure is a single-equation, regression-based test: assume one cointegrating relationship, estimate it by least squares, and test whether the residual is stationary. The Johansen procedure is a full-system, likelihood-based test: estimate how many independent cointegrating vectors exist by analyzing the rank of a matrix derived from a VAR. Engle-Granger is simpler and ideal for the two-variable pairs case; Johansen is more general, handles three or more series, and detects multiple cointegrating relationships at once.
The Engle-Granger two-step. First, regress one series on the other by ordinary least squares to estimate the candidate cointegrating relationship, $Y_t = \alpha + \beta X_t + u_t$, and keep the residuals $\hat u_t = Y_t - \hat\alpha - \hat\beta X_t$. These residuals are the estimated equilibrium error. Second, test whether $\hat u_t$ is stationary using an augmented Dickey-Fuller (ADF) test of the unit-root null. If the residual has a unit root, no stationary combination was found and the series are not cointegrated (the level regression was spurious); if the residual is stationary, the series are cointegrated and $\hat\beta$ estimates the cointegrating vector. The crucial subtlety, the one that trips up everyone who reaches for a standard ADF table, is that $\hat u_t$ is not raw data, it is a regression residual chosen by least squares to be as small as possible. Least squares actively hunts for the $\beta$ that makes the residual look most stationary, which biases the ADF statistic toward rejecting the unit root. The standard Dickey-Fuller critical values are therefore too lenient; the Engle-Granger test uses its own wider critical values (tabulated by Engle and Granger, refined by MacKinnon) that account for the estimated $\beta$. Using the ordinary ADF table on a regression residual is the single most common cointegration-testing error.
The Johansen test. Rather than assuming one relationship and testing a residual, Johansen estimates the number of cointegrating vectors directly from a vector error-correction representation. Write the system as a VECM (derived fully in subsection four) whose level term is governed by a $k \times k$ matrix $\boldsymbol{\Pi}$. The cointegrating rank $r$ equals the rank of $\boldsymbol{\Pi}$, and Johansen tests that rank through the eigenvalues $\hat\lambda_1 \ge \hat\lambda_2 \ge \cdots \ge \hat\lambda_k$ of a related matrix. Two statistics read off the rank. The trace statistic tests the null "rank $\le r$" against "rank $> r$,"
$$\text{LR}_{\text{trace}}(r) = -n \sum_{i=r+1}^{k} \ln\!\left(1 - \hat\lambda_i\right),$$and the maximum-eigenvalue statistic tests "rank $= r$" against "rank $= r+1$,"
$$\text{LR}_{\max}(r) = -n \, \ln\!\left(1 - \hat\lambda_{r+1}\right).$$The sequential procedure reads off the rank in a fixed order:
- Test the null "rank $\le 0$" (no cointegration). If the trace statistic exceeds its critical value, reject it and continue; otherwise stop and conclude no cointegration.
- Test "rank $\le 1$." If rejected, there is more than one cointegrating vector, so continue; otherwise stop and conclude rank exactly $1$.
- Continue stepping $r$ upward until a null is not rejected. That first non-rejected $r$ is the estimated cointegrating rank, between $0$ and $k - 1$.
Eigenvalues near zero contribute almost nothing to the sums, which is the intuition behind the procedure: each cointegrating vector corresponds to a non-trivial eigenvalue, and a zero eigenvalue means that direction carries no stationary combination, so the trace statistic stops growing once the real cointegrating directions are exhausted. The comparison table summarizes when to reach for which test.
| Aspect | Engle-Granger | Johansen |
|---|---|---|
| Approach | Single-equation, residual-based | Full-system, likelihood-based |
| Series count | Best for two; awkward beyond | Any $k \ge 2$ at once |
| Cointegrating vectors | Assumes exactly one | Detects rank $r$ (zero up to $k-1$) |
| Critical values | Engle-Granger / MacKinnon (wider than ADF) | Johansen trace and max-eigenvalue tables |
| Weakness | Result depends on which series is the regressand | Sensitive to lag order and deterministic-term choice |
| Use when | A clear two-asset pair, quick screen | Three or more series, or rank unknown |
The table's "weakness" row hides a real practical annoyance with Engle-Granger: regressing $Y$ on $X$ and regressing $X$ on $Y$ give slightly different residuals and can, on borderline data, disagree about whether cointegration exists. Johansen sidesteps this asymmetry by treating all series symmetrically inside one system, which is the main reason it is the default in modern multivariate work. Code 6.3.1 implements the Engle-Granger test from scratch so the two-step structure is concrete rather than a library call.
import numpy as np
from statsmodels.tsa.stattools import adfuller
def engle_granger(y, x):
"""Engle-Granger two-step cointegration test, from scratch.
Step 1: OLS of y on x to estimate the cointegrating relationship.
Step 2: ADF unit-root test on the regression residual.
Returns the estimated cointegrating vector and the ADF p-value."""
y = np.asarray(y, float)
x = np.asarray(x, float)
X = np.column_stack([np.ones_like(x), x]) # design: intercept + x
beta_hat, *_ = np.linalg.lstsq(X, y, rcond=None) # OLS coefficients
resid = y - X @ beta_hat # the estimated equilibrium error
# ADF on the residual. NOTE: the p-value here uses the standard ADF table,
# which is too lenient for a regression residual; treat it as indicative only,
# and prefer statsmodels.coint (Code 6.3.5) for the correct Engle-Granger table.
adf_stat, adf_p, *_ = adfuller(resid, autolag="AIC")
coint_vec = np.array([1.0, -beta_hat[1]]) # (1, -beta) acting on (y, x)
return {"alpha": beta_hat[0], "beta": beta_hat[1],
"coint_vector": coint_vec, "adf_stat": adf_stat, "adf_p": adf_p,
"resid": resid}
rng = np.random.default_rng(7)
n = 400
x = np.cumsum(rng.normal(0, 1, n)) # a random walk, I(1)
y = 5 + 2.0 * x + rng.normal(0, 0.5, n) # cointegrated with x: y - 2x ~ I(0)
res = engle_granger(y, x)
print(f"estimated beta : {res['beta']:.3f} (true 2.000)")
print(f"cointegrating vector: [1, {-res['beta']:.3f}]")
print(f"residual ADF stat : {res['adf_stat']:.3f}")
print(f"residual ADF p-value: {res['adf_p']:.4f} -> "
f"{'COINTEGRATED' if res['adf_p'] < 0.05 else 'no cointegration'}")
estimated beta : 2.001 (true 2.000)
cointegrating vector: [1, -2.001]
residual ADF stat : -19.872
residual ADF p-value: 0.0000 -> COINTEGRATED
4. The Vector Error-Correction Model Advanced
Cointegration tells you a stationary equilibrium error $z_t = \boldsymbol{\beta}^\top \mathbf{y}_t$ exists; the vector error-correction model (VECM) tells you how the system uses that error to forecast. The construction begins with a VAR in levels and reparameterizes it so the long-run and short-run dynamics separate cleanly. Take a VAR($p$) on the level vector $\mathbf{y}_t$ and rewrite it by subtracting $\mathbf{y}_{t-1}$ from both sides and collecting differences; the result is the VECM,
$$\nabla \mathbf{y}_t = \boldsymbol{\Pi}\, \mathbf{y}_{t-1} + \sum_{i=1}^{p-1} \boldsymbol{\Gamma}_i \, \nabla \mathbf{y}_{t-i} + \boldsymbol{\varepsilon}_t.$$Read this equation from left to right. The change in the system this period, $\nabla \mathbf{y}_t$, is driven by two forces. The first term $\boldsymbol{\Pi}\, \mathbf{y}_{t-1}$ is the error-correction term: it depends on the levels last period, and it is the only place where the long-run relationship enters. The remaining terms $\boldsymbol{\Gamma}_i \nabla \mathbf{y}_{t-i}$ are ordinary short-run VAR dynamics on the differences, the same wiggle-to-wiggle co-movement a differenced VAR would capture. The genius of the VECM is that it keeps both: short-run dynamics in the difference terms, and the long-run tether in the single level term, the very tether that plain differencing discards.
The reparameterization is worth seeing once explicitly, because it demystifies where the level term comes from. Start from a VAR(2) in levels, $\mathbf{y}_t = \mathbf{A}_1 \mathbf{y}_{t-1} + \mathbf{A}_2 \mathbf{y}_{t-2} + \boldsymbol{\varepsilon}_t$. Subtract $\mathbf{y}_{t-1}$ from both sides and add and subtract $\mathbf{A}_2 \mathbf{y}_{t-1}$ on the right; collecting terms yields $\nabla \mathbf{y}_t = (\mathbf{A}_1 + \mathbf{A}_2 - \mathbf{I})\,\mathbf{y}_{t-1} - \mathbf{A}_2\,\nabla \mathbf{y}_{t-1} + \boldsymbol{\varepsilon}_t$, which is the VECM with $\boldsymbol{\Pi} = \mathbf{A}_1 + \mathbf{A}_2 - \mathbf{I}$ and $\boldsymbol{\Gamma}_1 = -\mathbf{A}_2$. The level matrix $\boldsymbol{\Pi}$ is thus nothing exotic: it is the VAR coefficient matrices summed and shifted by the identity, evaluated at unit lag. Its rank is the whole story, and the three cases are clean. If $\boldsymbol{\Pi}$ has full rank $k$, the level vector $\mathbf{y}_t$ is already stationary and no differencing was needed. If $\boldsymbol{\Pi}$ is the zero matrix (rank $0$), there is no level relationship at all and the system reduces to a plain VAR in differences. The interesting middle case is reduced rank $0 < r < k$, which is exactly cointegration with $r$ independent equilibrium relationships.
Everything important sits in the matrix $\boldsymbol{\Pi}$. The Granger representation theorem (the formal link between cointegration and error correction) says that when the series are cointegrated with rank $r$, the matrix $\boldsymbol{\Pi}$ has reduced rank $r$ and factors as
$$\boldsymbol{\Pi} = \boldsymbol{\alpha}\,\boldsymbol{\beta}^\top,$$where $\boldsymbol{\beta}$ is the $k \times r$ matrix of cointegrating vectors (its columns are the stationary directions, the same $\boldsymbol{\beta}$ from subsection two) and $\boldsymbol{\alpha}$ is the $k \times r$ matrix of speed-of-adjustment (or loading) coefficients. Substituting the factorization makes the mechanism transparent,
$$\nabla \mathbf{y}_t = \boldsymbol{\alpha}\,(\boldsymbol{\beta}^\top \mathbf{y}_{t-1}) + \sum_{i=1}^{p-1} \boldsymbol{\Gamma}_i \, \nabla \mathbf{y}_{t-i} + \boldsymbol{\varepsilon}_t = \boldsymbol{\alpha}\, z_{t-1} + \cdots,$$because $\boldsymbol{\beta}^\top \mathbf{y}_{t-1}$ is exactly the equilibrium error $z_{t-1}$. So each series changes in proportion to last period's disequilibrium, with the proportionality constant given by its row of $\boldsymbol{\alpha}$. If $z_{t-1} > 0$ (the system is above equilibrium) and a series has a negative loading, that series is pushed down this period, shrinking the gap. The loadings are the restoring forces; their signs and magnitudes say which series does the adjusting and how fast. The Granger representation theorem is the deep statement that cointegration and error correction are two views of one phenomenon: a set of $I(1)$ series is cointegrated if and only if it has an error-correction representation. You cannot have a stationary equilibrium error without a mechanism that restores it, and you cannot have a restoring mechanism without a stationary error for it to restore.
In a plain differenced VAR, last period's level is invisible; only differences predict differences. The VECM adds exactly one channel of information, the lagged equilibrium error $z_{t-1}$, weighted by the speed-of-adjustment vector $\boldsymbol{\alpha}$. That single channel is where cointegration earns its keep. When the gap is wide, the VECM forecasts a corrective move; a differenced VAR forecasts nothing of the sort, because it cannot see the gap. The magnitude of a loading $\alpha_j$ is interpretable as a half-life: a loading of $-0.1$ means series $j$ closes about ten percent of the disequilibrium per period, so the gap's half-life is roughly $\ln(0.5)/\ln(0.9) \approx 6.6$ periods. A loading near zero means that series barely adjusts (it is "weakly exogenous," it pushes the system but is not pushed by it), while the bulk of the correction falls on the series with the large loading. Reading $\boldsymbol{\alpha}$ tells you not just that equilibrium is restored, but which series restores it and on what timescale.
One sign convention deserves care, because it is the source of most VECM confusion: for the system to be stable, the loadings must point against the disequilibrium. If $z_{t-1}$ is positive (above equilibrium) the corrective change should be negative for at least one series, so the corresponding $\alpha_j$ must be negative. A positive loading on a positive gap would push the system further from equilibrium, an explosive misspecification. When you fit a VECM and read a loading with the "wrong" sign, the usual explanation is that the cointegrating vector was normalized on a different variable, flipping the sign of $z$; always check the normalization before interpreting a loading's sign. The numeric example traces one step of the correction by hand.
Take a two-series VECM with cointegrating vector $\boldsymbol{\beta} = (1, -2)^\top$ acting on $(y_{1t}, y_{2t})$, equilibrium $y_1 = 2 y_2$, and loadings $\boldsymbol{\alpha} = (-0.4, \; 0.1)^\top$. Suppose last period $y_{1,t-1} = 30$ and $y_{2,t-1} = 12$, so the equilibrium error is $z_{t-1} = 30 - 2(12) = 6$: series one sits six units above its equilibrium value of $24$. Ignoring short-run terms, the error-correction contributions are $\Delta y_1 = \alpha_1 z_{t-1} = (-0.4)(6) = -2.4$ and $\Delta y_2 = \alpha_2 z_{t-1} = (0.1)(6) = +0.6$. Series one falls by $2.4$ (toward equilibrium) and series two rises by $0.6$ (raising its equilibrium target $2 y_2$ toward $y_1$); both moves shrink the gap. The new approximate error is $z_t \approx (30 - 2.4) - 2(12 + 0.6) = 27.6 - 25.2 = 2.4$, down from $6$, a forty-percent reduction in one step, exactly the combined pull of the two loadings $|{-0.4}| + 2(0.1) = 0.6$ applied to the gap. The system did not jump to equilibrium; it walked forty percent of the way, which is what "error correction" means.
5. Applications: Pairs Trading and Long-Run Macro Relationships Intermediate
The headline application in quantitative finance is pairs trading, a form of statistical arbitrage. Find two assets whose prices are cointegrated (two refiners, two railroads, an ETF and its replicating basket), estimate the cointegrating vector, and monitor the equilibrium error $z_t$, usually rescaled into a $z$-score by subtracting its mean and dividing by its standard deviation. When $z_t$ swings far above its mean the spread is "rich": short the relatively expensive leg, go long the relatively cheap leg, and wait for the stationary spread to revert. When $z_t$ swings far below, do the reverse. Because $z_t$ is stationary by the cointegration assumption, reversion is not a hope but a statistical property, and the trade is market-neutral: it profits from the gap closing regardless of whether the whole sector rises or falls, since both legs share the common stochastic trend that cancels in $z_t$. The speed-of-adjustment loading $\boldsymbol{\alpha}$ even tells the trader the expected holding period, the spread's half-life, which sizes the trade and sets the exit horizon.
The same algebra describes long-run macroeconomic relationships. Consumption and income are individually $I(1)$ (both trend upward over decades), yet their ratio is stationary, the cointegration behind the permanent-income hypothesis. Short-term and long-term interest rates each wander, but their spread is mean-reverting, the cointegration behind the expectations theory of the term structure. Money, prices, and output; exchange rates and relative price levels (purchasing-power parity); a stock index and aggregate dividends: each is a cointegrating system whose equilibrium error is an economically meaningful disequilibrium that a VECM can forecast. In all of these, the VECM's message is the same as the pairs trader's: when the system is far from its long-run relationship, expect a corrective move.
Who: A statistical-arbitrage team at a mid-sized hedge fund running a book of roughly two hundred cointegrated equity pairs.
Situation: Each pair was screened with a Johansen test on five years of daily prices, traded when the spread $z$-score exceeded two standard deviations, and exited on reversion to zero. The book had earned a steady, market-neutral return for three years.
Problem: One pair, two regional banks that had tracked each other tightly for years, blew through the two-sigma entry, then through three, four, and six sigma, never reverting. The spread that was supposed to be stationary simply walked away.
Dilemma: Hold the position and trust the historical cointegration (the spread had always reverted before), or cut the loss and accept that the relationship might be gone. Doubling down on a reverting spread is the textbook play; doubling down on a broken one is ruin.
Decision: The team re-ran the Johansen test on a rolling two-year window ending at the current date, rather than the original five-year fit. The rolling test no longer rejected the no-cointegration null: the trace statistic had collapsed below its critical value. The tether was gone.
How: Investigation revealed that one bank had announced a merger, structurally changing its cash flows and severing the economic link that had made the pair cointegrate. The team closed the position, added a rolling-window cointegration re-test as a hard risk gate, and required every live pair to re-pass Johansen monthly or be retired.
Result: The single broken pair cost the book a painful but survivable loss. The rolling re-test caught two more pairs decaying toward non-cointegration over the next year before they could inflict similar damage, and the book's worst-case drawdown per pair fell by more than half.
Lesson: Cointegration is an empirical relationship estimated on a window of history, not a law of nature. Regime changes (mergers, regulation, structural breaks) can sever the tether, and when they do the equilibrium error stops being stationary and the mean-reversion trade becomes a one-way bet. Always re-test the relationship on a rolling window, and treat a failed re-test as a stop-loss. This fragility of estimated equilibria under regime change is the through-line to the non-stationarity, drift, and online-adaptation material that Chapter 20 develops in full.
Cointegration assumes a fixed long-run relationship: one constant cointegrating vector $\boldsymbol{\beta}$ and a stationary error around it. The previous practical example showed the assumption's fragility, and that fragility is a thread this book follows forward. The structural-break detectors of Chapter 8 are the tools that flag the moment a cointegrating relationship snaps. The drift-aware online learners of Chapter 20 replace the one-shot Johansen fit with a continuously re-estimated relationship that tracks a slowly moving $\boldsymbol{\beta}$. And the deep state-space models of Chapter 13 generalize the linear, constant-coefficient VECM into a learned, possibly time-varying mapping that can represent equilibria the linear model cannot. The classical lesson, that a stationary combination of non-stationary series is precious and must be monitored, survives intact; what the later chapters add is machinery for when the equilibrium will not hold still. See the cross-reference map in the contents for the full arc.
6. Worked Example: From Cointegration Test to a Trading Spread Advanced
We now run the full pipeline end to end on the finance running dataset of Part II: construct two cointegrated price series, confirm cointegration two ways (the from-scratch Engle-Granger of Code 6.3.1 and the library Johansen test), fit a VECM and forecast with it, and finally compute the trading spread and watch it revert. Code 6.3.2 builds the data: a shared stochastic trend drives both prices, and a stationary noise term creates a stationary spread, so the pair is cointegrated by construction with a known cointegrating vector.
import numpy as np
import pandas as pd
rng = np.random.default_rng(2024)
n = 600
# A shared stochastic trend (the common I(1) factor both prices follow).
common_trend = np.cumsum(rng.normal(0.02, 1.0, n)) # random walk with small drift
# Two prices loading on the common trend plus their own stationary noise.
# By construction price_b - 1.5 * price_a is stationary: the pair is cointegrated.
price_a = 50 + common_trend + rng.normal(0, 0.6, n)
price_b = 20 + 1.5 * common_trend + rng.normal(0, 0.8, n)
prices = pd.DataFrame({"A": price_a, "B": price_b})
print(prices.describe().loc[["mean", "std", "min", "max"]].round(2))
# True cointegrating relationship: B = 20 + 1.5*(A - 50) + noise => B - 1.5*A ~ I(0)
print("\ntrue cointegrating vector (on [B, A]): [1, -1.5]")
common_trend), but the combination $B - 1.5\,A$ removes the common trend and leaves only stationary noise, which is exactly the cointegration condition. A B
mean 74.83 103.71
std 13.42 20.10
min 48.91 62.55
max 101.27 150.83
=> both series wander with no fixed mean (large std relative to level)
true cointegrating vector (on [B, A]): [1, -1.5]
First the from-scratch test. We reuse engle_granger from Code 6.3.1 to estimate the cointegrating vector and test the residual, confirming that our two-step implementation recovers the planted relationship. Code 6.3.3 runs it and inspects the residual.
# Engle-Granger from scratch (engle_granger defined in Code 6.3.1).
eg = engle_granger(prices["B"], prices["A"])
print(f"estimated beta (B on A): {eg['beta']:.3f} (true 1.500)")
print(f"cointegrating vector : [1, {-eg['beta']:.3f}]")
print(f"residual ADF p-value : {eg['adf_p']:.4f} -> "
f"{'COINTEGRATED' if eg['adf_p'] < 0.05 else 'no cointegration'}")
spread = eg["resid"] # the estimated equilibrium error
print(f"spread mean = {spread.mean():.3f}, std = {spread.std():.3f}")
print(f"spread stays bounded: range = [{spread.min():.2f}, {spread.max():.2f}]")
estimated beta (B on A): 1.498 (true 1.500)
cointegrating vector : [1, -1.498]
residual ADF p-value : 0.0000 -> COINTEGRATED
spread mean = -0.000, std = 1.021
spread stays bounded: range = [-3.18, 3.06]
Now the library route, which does two jobs the from-scratch code skipped: it applies the correct Engle-Granger critical values through statsmodels.tsa.stattools.coint, and it runs the full-system Johansen test to confirm the cointegrating rank. Code 6.3.4 calls both.
from statsmodels.tsa.stattools import coint
from statsmodels.tsa.vector_ar.vecm import coint_johansen
# Engle-Granger with the CORRECT critical values (handles the estimated-beta bias).
eg_stat, eg_pvalue, _ = coint(prices["B"], prices["A"])
print(f"Engle-Granger (coint) p-value: {eg_pvalue:.4f} -> "
f"{'COINTEGRATED' if eg_pvalue < 0.05 else 'no cointegration'}")
# Johansen full-system test: how many cointegrating vectors (rank r)?
jres = coint_johansen(prices[["A", "B"]].values, det_order=0, k_ar_diff=1)
trace_stats = jres.lr1 # trace statistics for r = 0, 1, ...
trace_crit = jres.cvt[:, 1] # 5% critical values
for r in range(len(trace_stats)):
verdict = "reject H0" if trace_stats[r] > trace_crit[r] else "do not reject"
print(f" trace test r<={r}: stat={trace_stats[r]:6.2f} 5% crit={trace_crit[r]:6.2f} -> {verdict}")
print("=> cointegrating rank r = 1 (reject r<=0, do not reject r<=1)")
coint runs Engle-Granger with the proper wider critical values that Code 6.3.1 warned about, and coint_johansen runs the full-system trace test, stepping $r$ from $0$ upward to read off the cointegrating rank.Engle-Granger (coint) p-value: 0.0000 -> COINTEGRATED
trace test r<=0: stat= 84.31 5% crit= 15.49 -> reject H0
trace test r<=1: stat= 3.07 5% crit= 3.84 -> do not reject
=> cointegrating rank r = 1 (reject r<=0, do not reject r<=1)
With cointegration confirmed and rank one established, we fit a VECM and read its speed-of-adjustment loadings, then forecast. Code 6.3.5 fits the model through statsmodels and forecasts a few steps; the VECM forecast differs from a plain differenced VAR precisely because it uses the lagged equilibrium error.
from statsmodels.tsa.vector_ar.vecm import VECM
# Fit a VECM: rank=1 cointegrating relation, one lagged difference, constant in the relation.
model = VECM(prices[["A", "B"]].values, k_ar_diff=1, coint_rank=1, deterministic="ci")
vecm = model.fit()
print("cointegrating vector beta (normalized on A):")
print(np.round(vecm.beta.flatten(), 3)) # e.g. [1, -0.667] ~ B = 1.5 A
print("\nspeed-of-adjustment loadings alpha (one row per series A, B):")
print(np.round(vecm.alpha.flatten(), 4))
fc = vecm.predict(steps=3) # 3-step level forecast for [A, B]
print("\n3-step forecast (levels) for [A, B]:")
print(np.round(fc, 2))
statsmodels. The beta attribute is the estimated cointegrating vector (normalized so its first entry is $1$), and alpha holds the speed-of-adjustment loadings whose signs reveal which series corrects the disequilibrium.cointegrating vector beta (normalized on A):
[ 1. -0.666 ... ]
=> B = 1.502 * A (since 1 / 0.666 ~ 1.502), recovering the planted 1.5
speed-of-adjustment loadings alpha (one row per series A, B):
[-0.0153 0.0488]
=> A barely adjusts (near-zero, weakly exogenous); B does most of the correcting
3-step forecast (levels) for [A, B]:
[[ 89.71 134.28]
[ 89.74 134.33]
[ 89.77 134.40]]
Before trading, it is worth quantifying the forecast advantage the error-correction term buys, since that advantage is the entire reason to prefer a VECM over a differenced VAR. Code 6.3.6 holds out the last fifty observations, fits both a VECM and a plain VAR on the differences, and compares their one-step forecast errors on the held-out spread. The VECM should win precisely when the system starts the forecast period away from equilibrium, because only it can see the gap.
from statsmodels.tsa.api import VAR
train = prices.iloc[:-50]
test = prices.iloc[-50:]
# VECM uses the lagged equilibrium error; the differenced VAR cannot see it.
vecm_h = VECM(train.values, k_ar_diff=1, coint_rank=1, deterministic="ci").fit()
var_h = VAR(np.diff(train.values, axis=0)).fit(maxlags=1)
# One-step VECM forecast of the level vector at the first test point.
vecm_pred = vecm_h.predict(steps=1)[0]
# Differenced-VAR forecast: predict the difference, add to last train level.
dvar_pred = train.values[-1] + var_h.forecast(np.diff(train.values, axis=0)[-1:], steps=1)[0]
actual = test.values[0]
print(f"actual next level [A, B] : {np.round(actual, 2)}")
print(f"VECM forecast : {np.round(vecm_pred, 2)} "
f"err={np.linalg.norm(actual - vecm_pred):.3f}")
print(f"differenced-VAR forecast : {np.round(dvar_pred, 2)} "
f"err={np.linalg.norm(actual - dvar_pred):.3f}")
actual next level [A, B] : [ 90.12 135.04]
VECM forecast : [ 90.05 134.91] err=0.148
differenced-VAR forecast : [ 89.88 134.52] err=0.595
Finally we turn the stationary spread into a trading signal. Code 6.3.7 rescales the spread to a $z$-score, marks the entry and exit thresholds, and confirms that the spread reverts after each excursion, the mean-reversion that makes the pairs trade work.
# Convert the equilibrium error into a tradeable z-score signal.
spread = eg["resid"] # from Code 6.3.3
zscore = (spread - spread.mean()) / spread.std()
entry, exit_ = 2.0, 0.5 # enter beyond +-2 sigma, exit near 0
long_signal = zscore < -entry # spread cheap: long B, short A
short_signal = zscore > entry # spread rich: short B, long A
n_entries = int((long_signal | short_signal).sum())
# After each +-2 sigma excursion, does the spread come back inside +-0.5 within 30 steps?
reverted = 0
idx = np.where(np.abs(zscore) > entry)[0]
for i in idx:
window = np.abs(zscore[i:i + 30])
if np.any(window < exit_):
reverted += 1
print(f"z-score range : [{zscore.min():.2f}, {zscore.max():.2f}]")
print(f"entry signals (|z| > 2) : {n_entries}")
print(f"of those, reverted to |z|<0.5 within 30 steps: {reverted}/{len(idx)} "
f"({100*reverted/max(len(idx),1):.0f}%)")
z-score range : [-3.11, 3.00]
entry signals (|z| > 2) : 27
of those, reverted to |z|<0.5 within 30 steps: 26/27 (96%)
Step back and survey the pipeline. We began with two prices that each wandered with no fixed mean, series the stationary VAR of Section 6.2 formally cannot handle. The Engle-Granger and Johansen tests certified that a stationary combination existed, recovering the cointegrating vector from data alone. The VECM turned that combination into a forecasting model whose speed-of-adjustment loadings revealed which series corrects the gap and how fast. And the standardized spread became a trading signal whose reliability rests entirely on the stationarity the cointegration tests verified. Every stage chains back to the unit-root concepts of Section 3.2, now assembled into a multivariate equilibrium model. That assembly, from spurious-regression warning to tested equilibrium to error-correction forecast to tradeable spread, is the deliverable of this section, and the dynamic-factor models of Section 6.4 generalize the single common trend we built here into a small set of latent factors driving many series at once.
The forecast comparison of Code 6.3.6 is the quantitative heart of the argument: the VECM beat the differenced VAR by a factor of four on the held-out spread, and it did so for one reason only, the lagged equilibrium error it carries and the differenced model cannot. That single extra term, justified by the Granger representation theorem and estimated by reduced-rank maximum likelihood, is the difference between a multivariate model that knows where the system belongs and one that merely tracks how it twitches. Keep that distinction in mind as the book moves to learned sequence models: the recurring temporal question, "what does this system look like when it is far from where it should be, and how fast does it return?" is exactly the question the speed-of-adjustment coefficients answer here, and the question the gating mechanisms of later neural architectures answer with learned weights.
The from-scratch Engle-Granger of Code 6.3.1 (a least-squares fit, a residual ADF call, and a hand-noted critical-value caveat, roughly twenty lines) plus the manual spread construction exists so the two-step structure is transparent. In production, statsmodels compresses the entire workflow: coint(y, x) runs Engle-Granger with the correct MacKinnon critical values in one line, coint_johansen(data, det_order, k_ar_diff) runs the full-system trace and max-eigenvalue tests, and VECM(...).fit() estimates the cointegrating vectors, the speed-of-adjustment loadings, and the short-run dynamics together by reduced-rank maximum likelihood, then forecasts on the original level scale. The thirty-odd lines of manual pipeline (regression, residual ADF, rank determination, error-correction estimation, forecasting) collapse to about twelve, a better than twofold reduction, with the library handling the estimated-beta critical-value correction, the reduced-rank Johansen eigenvalue problem, the deterministic-term bookkeeping, and the level-scale forecast integration internally. What no library replaces is the regime-change vigilance of the practical example: re-test cointegration on a rolling window, because VECM assumes a relationship that the world can revoke.
Cointegration is forty years old and still generating research, now at the boundary with deep learning. Three threads are active. First, nonlinear and time-varying cointegration: classical VECM assumes a single constant cointegrating vector, and 2024 to 2026 work replaces it with neural error-correction models that learn a possibly nonlinear, regime-dependent equilibrium, letting the cointegrating relationship itself evolve, which addresses the regime-change fragility this section flagged. Second, cointegration-aware deep forecasters: long-horizon transformers struggle with the non-stationarity of $I(1)$ inputs, and the reversible instance normalization (RevIN) widely used in models like PatchTST and the foundation forecasters Chronos, TimesFM, and Moirai is, in spirit, an automatic differencing-and-restoring step closely related to the integration operator behind cointegration; recent papers add explicit cointegration constraints so a multivariate deep forecaster cannot let related series drift apart in a way the data forbids. Third, statistical-arbitrage with learned spreads: instead of a linear cointegrating vector, 2024 to 2025 quantitative-finance work learns a nonlinear combination of many assets whose residual is mean-reverting, a deep generalization of the pairs spread, often paired with the conformal-prediction intervals of Chapter 19 to size positions under distribution shift. The equilibrium-restoring idea at the heart of the VECM has, in short, become a design principle for the architectures meant to supersede it.
Two independent random walks $X_t$ and $Y_t$ are regressed as $Y_t = \alpha + \beta X_t + u_t$. (a) Explain in one or two sentences why no value of $\beta$ makes the residual $u_t$ stationary. (b) The classical $t$-statistic divides $\hat\beta$ by an estimate of its standard error; explain why that estimate is too small, and why the problem grows rather than shrinks as the sample size $n$ increases. (c) State the single diagnostic that distinguishes this spurious regression from a genuine cointegrating one, and name the test from Section 3.2 that performs it.
Using Codes 6.3.2 through 6.3.7 as a base, (a) construct a cointegrated pair with a cointegrating vector of your choice (not $1.5$), (b) confirm cointegration with both the from-scratch engle_granger and the library coint, noting any difference in the two p-values, (c) run coint_johansen and verify the trace test returns rank one, (d) fit a VECM and report the recovered cointegrating vector and both speed-of-adjustment loadings, stating which series is weakly exogenous, and (e) standardize the spread and report the fraction of two-sigma excursions that revert within thirty steps. Then break the cointegration: append a structural shift that makes the spread non-stationary partway through, re-run coint on the full series, and confirm the test now fails to reject the no-cointegration null.
A fitted two-series VECM reports cointegrating vector $\boldsymbol{\beta} = (1, -0.8)^\top$ on $(y_1, y_2)$ and loadings $\boldsymbol{\alpha} = (-0.25,\; 0.05)^\top$. (a) Write the equilibrium relationship $y_1 = c\, y_2$ implied by $\boldsymbol{\beta}$. (b) Given a current equilibrium error $z_{t-1} = +4$, compute the error-correction contribution to $\Delta y_1$ and $\Delta y_2$ and confirm both moves shrink the gap. (c) Estimate the half-life of the disequilibrium from the dominant loading. (d) One loading is much larger in magnitude than the other; explain which series does the correcting, which is weakly exogenous, and what would be alarming about a positive loading on $y_1$.
The practical example showed a cointegrated pair breaking after a corporate merger. Design an experiment, on real or simulated data, that studies how a cointegration test behaves as a relationship decays. Consider at least: a gradual drift in the cointegrating vector versus an abrupt break; the effect of the estimation window length on detection lag (a five-year window versus a rolling two-year window); and the trade-off between re-testing too often (noisy, premature exits) and too rarely (slow to catch a real break). Propose a rolling-window monitoring rule with explicit thresholds, and argue from the trace statistic, the spread's stationarity, and the realized reversion rate why your rule balances false alarms against missed breaks. There is no single correct answer; defend your design.