Bayesian VECM — III. Money demand¶
Vector autoregressions, Part 6c: a long-run relation that broke¶
The classic long-run money demand function ties real balances to real income and the opportunity cost of holding money: $m_t-p_t = \gamma_y\,y_t - \gamma_i\,i_t$. For decades it was the workhorse cointegrating relation of monetary economics — real M2, real income and an interest rate, each I(1), bound together by one stationary equilibrium. Then, from the early 1990s and decisively after 2008, it fell apart: M2 velocity became unstable, and the tidy long-run relation stopped holding. This notebook runs the same Bayesian VECM machinery as Parts 6a–6b on (real M2, real GDP, the 3-month rate) and lets the cointegration test deliver the verdict — the third of three possible outcomes, after genuine cointegration (yields) and a stationary system (Fisher).
Data: log real M2, log real GDP, and the 3-month Treasury rate, quarterly 1959–2019 (FRED).
Setup¶
The variables are the log of real M2 balances, log real GDP, and the 3-month Treasury rate ($\times 100$ where in logs), quarterly 1959–2019 (bvecm_money_data.csv). The model, the reduced-rank $\Pi=\alpha\beta'$, the three-block Gibbs sampler and the identification are all exactly as in Part 6a. As before, we first check that each series is I(1) (a genuine candidate for cointegration — unlike the Fisher case), then run the Johansen trace test and the Bayesian VECM. The tell-tale of a broken relation is that the estimated error-correction term, which for the yield curve reverted to a fixed level, here wanders like a unit-root process — there is no equilibrium to correct toward.
import numpy as np, pandas as pd, matplotlib.pyplot as plt
from statsmodels.tsa.vector_ar.vecm import coint_johansen
from statsmodels.tsa.stattools import adfuller
import bvecm
d = pd.read_csv("bvecm_money_data.csv", index_col=0, parse_dates=True)
cols = ["realM2", "realGDP", "tbill"]; Y = d[cols].values; dates = d.index; m = 3; p = 2
print("ADF unit-root tests (H0: I(1)):")
for j, c in enumerate(cols):
ad = adfuller(Y[:, j], maxlag=4, autolag="AIC")
print(" %-9s ADF %6.2f p=%.3f -> %s" % (c, ad[0], ad[1], "I(1)" if ad[1] > 0.05 else "I(0)"))
jo = coint_johansen(Y, det_order=0, k_ar_diff=1)
print("\nJOHANSEN trace test:")
for i in range(m):
print(" r<=%d : trace %6.1f 95%%crit %5.1f %s" % (i, jo.lr1[i], jo.cvt[i, 1],
"reject" if jo.lr1[i] > jo.cvt[i, 1] else "ACCEPT -> r=%d" % i))
print(" => r = 0: NO cointegration - no stable long-run money demand\n")
fit = bvecm.gibbs_vecm(Y, p=p, r=1, ndraw=4000, burn=2000, seed=1) # impose r=1 to inspect the 'disequilibrium'
beta = np.median(fit["beta"], 0); ec = bvecm.ec_term(fit, Y)[:, 0]
ad_ec = adfuller(ec, maxlag=4, autolag="AIC")
print("imposed r=1 long-run relation: realM2 %+0.2f*realGDP %+0.2f*tbill" % (beta[1, 0], beta[2, 0]))
print("ADF on the money-demand 'error' term: stat %.2f p=%.3f -> %s" % (ad_ec[0], ad_ec[1],
"NON-stationary: relation has BROKEN DOWN" if ad_ec[1] > 0.05 else "stationary"))
# figure: velocity drifts; the imposed error-correction term wanders (I(1)) -> vecm_money_1.png
_dec = dates[p - 1:len(Y) - 1] # dates aligned to Y_{t-1} in the EC term
_vel = Y[:, 1] - Y[:, 0] # log M2 velocity = log real GDP - log real M2
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4))
ax1.plot(dates, _vel, color="firebrick", lw=1.6)
ax1.set_title("M2 velocity (log real GDP - log real M2): drifts")
ax1.set_ylabel("log velocity"); ax1.grid(alpha=.25)
ax2.plot(_dec, ec, color="navy", lw=1.6, label=r"imposed $r=1$ EC term")
ax2.axhline(ec.mean(), color="0.4", lw=.8, ls=":")
ax2.set_title("Money-demand error term: wanders (I(1)), no reversion")
ax2.grid(alpha=.25); ax2.legend(fontsize=8)
fig.tight_layout(); plt.show()
ADF unit-root tests (H0: I(1)): realM2 ADF -0.39 p=0.912 -> I(1) realGDP ADF -2.00 p=0.287 -> I(1) tbill ADF -2.24 p=0.191 -> I(1) JOHANSEN trace test: r<=0 : trace 18.1 95%crit 29.8 ACCEPT -> r=0 r<=1 : trace 5.6 95%crit 15.5 ACCEPT -> r=1 r<=2 : trace 0.5 95%crit 3.8 ACCEPT -> r=2 => r = 0: NO cointegration - no stable long-run money demand
imposed r=1 long-run relation: realM2 -0.92*realGDP -4.35*tbill ADF on the money-demand 'error' term: stat -2.24 p=0.191 -> NON-stationary: relation has BROKEN DOWN
Reading the breakdown¶
- The ingredients are right, but the relation is gone. All three series are genuinely I(1) (unlike the Fisher case) — so cointegration is at least well-defined here. Yet the Johansen trace test cannot reject $r=0$: no linear combination of real M2, real income and the rate is stationary. The classic money-demand equilibrium does not hold over 1959–2019.
- The error-correction term wanders instead of reverting. Force the model to $r=1$ and inspect the resulting "money-demand disequilibrium": an ADF test fails to reject a unit root ($p=0.19$) — it is itself non-stationary (right panel), drifting away rather than mean-reverting. This is the visual signature of a failed cointegrating relation, the exact opposite of the yield curve's tightly reverting error-correction terms in Part 6a.
- Why it broke. M2 velocity (left panel) trends and shifts rather than fluctuating around a constant — the financial innovation of the 1980s–90s and the balance-sheet expansion after 2008 severed the old link between money, income and rates (Friedman-Kuttner 1992; Ball 2001).
from statsmodels.tsa.vector_ar.vecm import VECM
from statsmodels.tsa.api import VAR
# Forecast race: when r=0, the correct model is the VAR in differences (no levels relation to exploit).
H = 12; hshow = [1, 4, 8, 12]; start = int(np.where(dates >= pd.Timestamp("1990-01-01"))[0][0])
sse = {k: {h: [] for h in hshow} for k in ["VECM(r=1)", "VAR-diff", "VAR-levels"]}
for t in range(start, len(Y) - H):
tr = Y[:t]
fV = VECM(tr, k_ar_diff=1, coint_rank=1, deterministic="co").fit().predict(steps=H)
fL = VAR(tr).fit(p).forecast(tr[-p:], H)
dtr = np.diff(tr, axis=0); fD = tr[-1] + np.cumsum(VAR(dtr).fit(1).forecast(dtr[-1:], H), 0)
for h in hshow:
a = Y[t + h - 1]
sse["VECM(r=1)"][h].append(np.mean((fV[h-1]-a)**2)); sse["VAR-levels"][h].append(np.mean((fL[h-1]-a)**2))
sse["VAR-diff"][h].append(np.mean((fD[h-1]-a)**2))
rmse = {k: [np.sqrt(np.mean(sse[k][h])) for h in hshow] for k in sse}
print("OOS RMSE by horizon (quarters):", hshow)
for k in ["VAR-diff", "VAR-levels", "VECM(r=1)"]: print(" %-11s" % k, np.round(rmse[k], 3))
# figure: RMSE vs forecast horizon, one line per model -> vecm_money_2.png
_models = [("VECM(r=1)", "firebrick"), ("VAR-diff", "navy"), ("VAR-levels", "seagreen")]
fig, ax = plt.subplots(figsize=(8, 4))
for _name, _col in _models:
ax.plot(hshow, rmse[_name], color=_col, lw=1.6, marker="o", ms=4, label=_name)
ax.set_xticks(hshow)
ax.set_xlabel("forecast horizon (quarters)"); ax.set_ylabel("RMSE")
ax.grid(alpha=.25); ax.legend(title="model")
ax.set_title("Out-of-sample RMSE by forecast horizon (money demand)")
fig.tight_layout(); plt.show()
OOS RMSE by horizon (quarters): [1, 4, 8, 12] VAR-diff [0.671 2.055 3.416 4.596] VAR-levels [0.696 2.311 4.148 5.909] VECM(r=1) [0.685 2.158 3.635 4.964]
Results — and the trilogy¶
With no cointegration, difference. The forecast race delivers the textbook prescription for $r=0$: the VAR-in-differences forecasts best at long horizons (RMSE 4.6 at three years), while the levels-VAR is worst (5.9) — its unrestricted level dynamics chase spurious long-run structure that isn't there — and the $r=1$ VECM, imposing a cointegration the data reject, sits in between. Getting the rank right is getting the forecasting model right.
The three notebooks together. Part 6 has walked the full spectrum of what a cointegration analysis can find, and shown that the right forecasting model follows directly from the rank:
integration rank $r$ error-correction term best long-horizon forecast Yield curve (6a) all I(1) $r=2$ stationary, reverts keep the levels (VECM ≈ levels-VAR) Fisher (6b) mixed (I(1), I(0)) full rank n/a (stationary system) any level model (all comparable) Money demand (6c) all I(1) $r=0$ wanders (broken) VAR in differences Cointegration is not a technique you apply and report — it is a question you ask the data, and the honest answer dictates everything downstream. The Bayesian VECM supplies that answer with full posterior uncertainty; the R cross-check (
urca) reproduces the $r=0$ verdict.
References¶
- Friedman, B. M. & Kuttner, K. N. (1992). Money, income, prices, and interest rates. American Economic Review 82, 472–492.
- Ball, L. (2001). Another look at long-run money demand. J. Monetary Economics 47, 31–44.
- Stock, J. H. & Watson, M. W. (1993). A simple estimator of cointegrating vectors in higher order integrated systems. Econometrica 61, 783–820.
Next: bvecm_money_R.ipynb — the R cross-check (urca).