Skip to content

Model card — Cointegration and regime switching

johansen · vecm · markov_switching_ar

Two ways the tidy linear-stationary world breaks. First, series can be individually nonstationary yet move together — share a long-run equilibrium (cointegration); differencing away the trends throws that equilibrium away, and the vector error-correction model keeps it. Second, the parameters themselves can switch between unobserved regimes — expansion and recession, calm and crisis — governed by a hidden Markov chain.


johansen — cointegration rank test

What it estimates. How many independent long-run equilibrium relations tie a set of I(1) series together — the cointegrating rank r. Runs Johansen's trace and maximum-eigenvalue tests sequentially against tabulated critical values.

Assumptions. Each series is I(1) (test first — check_stationarity on each column); the VECM lag order k_ar_diff is correct; the deterministic-term convention matches the critical values (this is the classic cross-package replication trap — five conventions give five critical-value families).

When to use (and when not). Use to decide r before fitting a VECM, when several series trend together and you suspect a shared equilibrium (spot/futures, short/long rates, consumption/income). Not for a single series (that is a unit- root test — adf/kpss), and not on series that are already stationary (fit a VAR in levels).

Key arguments and defaults (and why). data is T×k; k_ar_diff is the number of lagged differences (one less than the VAR level lag order — choose it as you would a VAR lag length).

How to read the output. trace_stat and max_eig_stat (one per null r ≤ i), each with critical values in trace_crit_90_95_99 / max_eig_crit_90_95_99 (columns are the 90/95/99% levels — take column 1 for the 5% test). rank_trace_5pct / rank_max_eig_5pct apply the sequential rule for you. eig are the ordered eigenvalues. Reject r = 0 but not r ≤ 1 ⇒ rank 1.

Failure modes. Using the wrong deterministic convention silently shifts the critical values; testing series that are not actually I(1); the trace and max-eigenvalue tests can disagree at the margin — report both.

Validated against. statsmodels coint_johansen (det_order=0, k_ar_diff=2), statistics and critical values (fixtures/coint.json).

References. Johansen (1988, 1991); Engle & Granger (1987).


vecm — vector error-correction model

What it estimates. Given the rank r, the ML estimate of the VECM: the cointegrating vectors beta (the long-run equilibria — the "leashes"), the adjustment speeds alpha (how fast each equation corrects a disequilibrium), the short-run dynamics gamma, the residual covariance, and the log-likelihood.

Assumptions. The rank coint_rank is correct (take it from johansen); Gaussian innovations for the ML/log-likelihood; the same deterministic convention as the rank test.

When to use. After johansen returns 0 < r < k. It keeps the levels information a differenced VAR discards, and alpha/beta are directly interpretable — which series bear the burden of adjustment back to equilibrium.

Key arguments. data (T×k), k_ar_diff, coint_rank (from the Johansen test).

How to read the output. beta (k×r, each column a cointegrating vector — normalized on the first variable), alpha (k×r adjustment speeds; a large negative entry means that equation does most of the correcting, a near-zero entry means that variable is weakly exogenous), gamma (short-run lag coefficients), sigma_u, llf.

Failure modes. A wrong rank propagates everywhere; imposing cointegration on series that are not cointegrated fabricates a spurious equilibrium.

Validated against. statsmodels VECM (ML estimation; k_ar_diff=2, coint_rank=1, deterministic="n") — alpha, beta, gamma, sigma_u, llf (fixtures/coint.json).

References. Johansen (1995); Lütkepohl (2005, ch. 6–7).

import numpy as np, tsecon

rng = np.random.default_rng(0)
n = 400
common = np.cumsum(rng.standard_normal(n))    # one shared stochastic trend
y1 = common + rng.standard_normal(n)
y2 = common + rng.standard_normal(n)          # y1 - y2 is stationary -> rank 1
y3 = np.cumsum(rng.standard_normal(n))        # an independent I(1) series
data = np.column_stack([y1, y2, y3])

joh = tsecon.johansen(data, k_ar_diff=2)
crit5 = np.asarray(joh["trace_crit_90_95_99"])[:, 1]
print("trace:", np.round(joh["trace_stat"], 1), " 5% crit:", np.round(crit5, 1),
      " -> rank", joh["rank_trace_5pct"])

fit = tsecon.vecm(data, k_ar_diff=2, coint_rank=1)
print("beta :", np.round(np.asarray(fit["beta"])[:, 0], 3))   # ~[1, -1, 0]: y1 - y2
print("alpha:", np.round(np.asarray(fit["alpha"])[:, 0], 3))

markov_switching_ar — Markov-switching AR

What it estimates. A Hamilton (1989) regime-switching autoregression: an AR(p) whose mean (and optionally variance) jumps between k_regimes hidden states, with the state following a first-order Markov chain. Fit by EM; returns the transition matrix, per-regime parameters, and filtered/smoothed regime probabilities.

Assumptions. The regimes are discrete and Markovian; the number of regimes k_regimes is chosen a priori (the likelihood-ratio test for k is non-standard — do not read the LR p-value naively); Gaussian innovations within regime.

When to use (and when not). Use when a series plausibly alternates between persistent states with different means/volatilities — business-cycle expansions and recessions, low- and high-volatility markets. Not for smooth nonlinearity (use STAR/threshold models) or when the "regimes" are really an omitted covariate you could just include.

Key arguments and defaults (and why). k_regimes=2 (the workhorse), order (AR lag order), switching_variance=True lets volatility differ across regimes (usually essential — regimes often are volatility states), max_iter/tol govern EM convergence.

How to read the output. transition (k×k; column-stochastic Markov matrix), means, variances (per regime), expected_durations (average spell length in each regime — the persistence read), loglik, converged, and the smoothed_prob_last_regime / regimes series (the smoothed probability path and the most-likely regime per period). Label regimes by their means/variances, not their index (EM does not order them).

Failure modes. EM converges to local optima — try multiple starts; regime labels are arbitrary across runs; too many regimes on a short sample gives empty or degenerate states.

Validated against. statsmodels MarkovAutoregression (k_regimes=2, order=1, switching_variance=True) — fixed-parameter log-likelihood and filtered/smoothed regime probabilities (fixtures/regime.json).

References. Hamilton (1989); Kim & Nelson (1999).

import numpy as np, tsecon
rng = np.random.default_rng(0)
y = np.concatenate([0.5 + 0.3 * rng.standard_normal(150),    # calm regime
                    -0.5 + 1.2 * rng.standard_normal(150),   # volatile regime
                    0.5 + 0.3 * rng.standard_normal(150)])
ms = tsecon.markov_switching_ar(y, k_regimes=2, order=1, switching_variance=True)
print("regime means    :", np.round(ms["means"], 3))
print("regime variances:", np.round(ms["variances"], 3))
print("expected durations:", np.round(ms["expected_durations"], 1))