Johansen Test and VECM for Multi-Asset Cointegration

The Johansen test is the rigorous, multivariate way to find cointegration across three or more assets at once. The two-asset Engle-Granger procedure is fine for a single pair, but the moment you want to build a stationary basket out of a sector of stocks, a curve of futures, or several crypto perpetuals, it falls apart. The Johansen test estimates all the cointegrating relationships symmetrically and hands you the cointegrating vectors — which double as the hedge weights for a market-neutral statistical arbitrage basket. This guide explains why you need it, how the trace and max-eigenvalue tests work, the VECM behind them, and how to run it in Python.

Why Engle-Granger struggles beyond two assets

Engle-Granger picks one series as the dependent variable, regresses the others on it, and tests the residual for stationarity. With two assets that is a minor nuisance — you get slightly different answers depending on which you put on the left-hand side. With three or more assets the problems compound:

  • Arbitrary normalization. Which variable goes on the left changes the estimated

relationship.

  • Only one relationship. Engle-Granger finds at most a single cointegrating vector,

but n assets can support up to n − 1 independent cointegrating relationships, and you would miss all but one.

  • No joint inference. Two-step OLS-then-ADF does not give a clean joint test of how

many cointegrating relations exist.

The Johansen test solves all three: it is symmetric across assets, finds the number of cointegrating vectors (the cointegration rank), and estimates them jointly via maximum likelihood.

The VECM: where Johansen comes from

The Johansen test is built on the Vector Error Correction Model (VECM). Start from a vector autoregression on prices and rewrite it in differences:

ΔY(t) = Π Y(t−1) + Σ Γ_i ΔY(t−i) + ε(t)

The key object is the matrix Π. Its rank r tells you everything:

  • rank(Π) = 0: no cointegration — the system is just n independent random walks;

trade nothing as a basket.

  • rank(Π) = n (full): the levels are already stationary; not the interesting case for

price series.

  • 0 < r < n: there are r cointegrating relationships. This is the goldilocks

zone for basket stat-arb.

When 0 < r < n, you can factor Π = α βᵀ. The columns of β are the cointegrating vectors — linear combinations βᵀ Y that are stationary. The matrix α holds the error-correction speeds: how fast each asset adjusts back when the stationary combination strays. That is the "error correction" intuition — deviations from equilibrium get pushed back, asset by asset.

The trace and max-eigenvalue tests

Johansen turns "what is the rank of Π?" into two sequential eigenvalue-based hypothesis tests. The procedure estimates eigenvalues λ1 ≥ λ2 ≥ … that measure how strongly each potential combination mean-reverts.

  • Trace test. Null hypothesis: there are at most r cointegrating vectors.

The statistic sums −T · ln(1 − λ_i) over the remaining eigenvalues. You test r = 0, then r ≤ 1, and so on, stopping at the first null you cannot reject.

  • Maximum eigenvalue test. Null: exactly r vectors versus the alternative of

r + 1. It uses only the next single eigenvalue and is a sharper, more focused test.

For both, you compare the statistic against critical values that depend on the deterministic terms (constant, trend). Reject while the statistic exceeds the critical value; the rank is the first r where you fail to reject.

Running coint_johansen in Python

statsmodels implements the test directly. Feed it a DataFrame of price levels (one column per asset):

import numpy as np
import pandas as pd
from statsmodels.tsa.vector_ar.vecm import coint_johansen

# prices: DataFrame, one column per asset, aligned on a common index
def johansen_rank(prices, det_order=0, k_ar_diff=1, level=1):
    """det_order=0: constant in cointegration; level: 0=90%,1=95%,2=99%."""
    res = coint_johansen(prices, det_order, k_ar_diff)
    trace_stat = res.lr1            # trace statistics
    trace_crit = res.cvt[:, level]  # critical values for chosen level
    rank = int(np.sum(trace_stat > trace_crit))
    return res, rank

res, rank = johansen_rank(prices, level=1)
print(f"Cointegration rank (95%): {rank}")
print("Trace stats:", np.round(res.lr1, 2))
print("Crit (95%): ", np.round(res.cvt[:, 1], 2))

# The first cointegrating vector = eigenvector with the largest eigenvalue
beta = res.evec[:, 0]
hedge_weights = beta / beta[0]   # normalize to 1 unit of the first asset
print("Hedge weights:", np.round(hedge_weights, 3))

The columns of res.evec are the cointegrating vectors, ordered by eigenvalue (strength of reversion). The first one reverts fastest and is usually the most tradeable.

Building a stationary basket spread

Once you have a cointegrating vector β, the basket spread is just the weighted combination of prices — and it should be stationary:

# build the spread from the strongest cointegrating vector
spread = prices.values @ beta

# sanity-check stationarity and standardize
from statsmodels.tsa.stattools import adfuller
print(f"Spread ADF p-value: {adfuller(spread)[1]:.4f}")  # want < 0.05

z = (spread - spread.mean()) / spread.std()

The weights in β are your portfolio holdings: positive entries are longs, negative entries are shorts, scaled so the combination is mean-neutral. You then trade the z-score of the basket exactly like a two-asset pairs trade — short the basket when z is high, long it when low — but now the position is diversified across several assets. Estimate the OU half-life of the basket spread to set holding periods.

ObjectSymbolTrading meaning
Cointegration rankrHow many independent baskets exist
Cointegrating vectorβ columnHedge weights for one basket
Adjustment speedsαHow fast each leg error-corrects
Largest eigenvalueλ_1Strongest / fastest-reverting basket

Common mistakes

  • Feeding returns instead of prices. Johansen operates on the levels (typically

log-prices). Differencing first destroys the cointegration you are hunting for.

  • Ignoring the lag order. kardiff matters; choose it with an information

criterion on the underlying VAR, not arbitrarily.

  • Wrong deterministic spec. The det_order (constant/trend) changes the critical

values; pick it to match whether your spread should have a non-zero mean or drift.

  • Trading a weak vector. Lower-eigenvalue vectors revert slowly or barely; prefer

the strongest, and confirm with an ADF/stationarity check out-of-sample.

  • Mass scanning without correction. Searching many baskets manufactures false

positives; demand an economic rationale and out-of-sample confirmation.

  • Fixing β forever. Cointegrating vectors drift; re-estimate on a rolling window.

Key takeaways

  • Use the Johansen test when you have three or more assets

Engle-Granger only handles a single pair and one relationship.

  • It estimates the cointegration rank r via the trace and max-eigenvalue

tests built on a VECM.

  • The cointegrating vectors double as hedge weights for a stationary,

market-neutral basket.

  • statsmodels.coint_johansen runs it directly; normalize the strongest eigenvector

to get tradeable weights.

  • Trade the basket z-score like a multi-asset

pairs trade for statistical arbitrage, confirm stationarity out-of-sample, and re-estimate the weights as relationships drift.

#johansen test #cointegration #VECM #statistical arbitrage #multivariate