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.

In [1]:
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
No description has been provided for this image

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).
In [2]:
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]
No description has been provided for this image

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:

    integrationrank $r$error-correction termbest long-horizon forecast
    Yield curve (6a)all I(1)$r=2$stationary, revertskeep the levels (VECM ≈ levels-VAR)
    Fisher (6b)mixed (I(1), I(0))full rankn/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).