Bayesian VECM — II. The Fisher effect¶

Vector autoregressions, Part 6b: when the premise fails¶

The Fisher effect says the nominal interest rate moves one-for-one with expected inflation, so the real rate $i_t-\pi_t$ is stationary. Cast as cointegration, the story is irresistible: if the nominal rate and inflation are each I(1), they should be tied together by the cointegrating vector $\beta=(1,-1)$, and the real rate is the error-correction term. This notebook applies the same Bayesian VECM machinery as Part 6a, and finds a cautionary tale: the cointegration premise fails a prior test, and the honest answer changes everything downstream.

Pre-test first: are the series even I(1)?¶

Cointegration is only defined for variables that are individually I(1) but share a stationary combination. So the first, non-negotiable step is to check the integration order of each series with a unit-root test (Augmented Dickey-Fuller). The model, the Gibbs sampler, and the identification are all exactly as laid out in Part 6a ($\Delta y_t=c+\alpha\beta'y_{t-1}+\dots$, reduced-rank $\Pi=\alpha\beta'$, bvecm.py); the Johansen trace test again provides the frequentist rank reference. What differs here is only the verdict the data return.

Data: bvecm_fisher_data.csv — the 3-month Treasury rate and annualised CPI inflation, quarterly 1959–2019 (FRED).

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_fisher_data.csv", index_col=0, parse_dates=True)
cols = ["tbill", "inflation"]; Y = d[cols].values; dates = d.index; m = 2; p = 2

print("ADF unit-root tests (H0: has a unit root, I(1)):")
for j, c in enumerate(cols):
    ad = adfuller(Y[:, j], maxlag=4, autolag="AIC")
    print("  %-10s ADF stat %6.2f  p=%.3f  -> %s" % (c, ad[0], ad[1], "I(1)" if ad[1] > 0.05 else "stationary 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("  => FULL RANK (r=2): the pair tests as jointly STATIONARY, not cointegrated\n")

fit = bvecm.gibbs_vecm(Y, p=p, r=1, ndraw=4000, burn=2000, seed=1)   # impose r=1 to read the long-run coefficient
beta = np.median(fit["beta"], 0)
blo, bhi = np.percentile(fit["beta"][:, 1, 0], [2.5, 97.5])
print("Bayesian long-run relation  tbill %+0.2f * inflation  (Fisher predicts -1.00)" % beta[1, 0])
print("  95%% CI on the coefficient: [%.2f, %.2f]" % (blo, bhi))
realrate = Y[:, 0] - Y[:, 1]
print("real rate (tbill - inflation): mean %.2f, std %.2f (stationary)" % (realrate.mean(), realrate.std()))

# figure: the two series + the (stationary) real rate  ->  vecm_fisher_1.png
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4))
ax1.plot(dates, Y[:, 0], color="firebrick", lw=1.6, label="tbill (3m rate)")
ax1.plot(dates, Y[:, 1], color="navy", lw=1.6, label="inflation")
ax1.set_title("Nominal rate and inflation")
ax1.set_ylabel("percent"); ax1.grid(alpha=.25); ax1.legend(fontsize=8)
ax2.plot(dates, realrate, color="seagreen", lw=1.6, label="real rate = tbill - inflation")
ax2.axhline(realrate.mean(), color="0.4", lw=.8, ls=":")
ax2.set_title("Real rate: stationary (mean-reverting)")
ax2.set_ylabel("percent"); ax2.grid(alpha=.25); ax2.legend(fontsize=8)
fig.tight_layout(); plt.show()
ADF unit-root tests (H0: has a unit root, I(1)):
  tbill      ADF stat  -2.26  p=0.185  -> I(1)
  inflation  ADF stat  -3.14  p=0.024  -> stationary I(0)

JOHANSEN trace test:
  r<=0 : trace   48.5  95%crit  15.5  reject
  r<=1 : trace    5.9  95%crit   3.8  reject
  => FULL RANK (r=2): the pair tests as jointly STATIONARY, not cointegrated

Bayesian long-run relation  tbill -1.59 * inflation  (Fisher predicts -1.00)
  95% CI on the coefficient: [-2.47, -1.19]
real rate (tbill - inflation): mean 0.93, std 2.52 (stationary)
No description has been provided for this image

Reading the (non-)cointegration¶

  • Different integration orders — so cointegration is impossible. The nominal rate tests as I(1) (ADF $p=0.19$), but annualised inflation tests as stationary, I(0) ($p=0.02$). Cointegration requires both variables to be I(1); an I(1) and an I(0) series cannot be cointegrated, full stop. Johansen agrees from the other direction — it finds full rank ($r=2$), i.e. the system already has no common unit root to tie together.
  • The real rate is stationary, but trivially. $i_t-\pi_t$ does mean-revert (right panel) — but only because inflation is already stationary, not because a genuine long-run equilibrium binds two wandering trends. It is a stationary-VAR fact, not a cointegration result.
  • And the coefficient isn't one-for-one. Forcing $r=1$ to read off a "Fisher coefficient" gives $-1.6$ with a 95% interval $[-2.5,-1.2]$ that excludes $-1$ — no clean one-for-one Fisher relation here. Imposing cointegration on a system that doesn't have it produces an estimate, but not a meaningful one.
In [2]:
from statsmodels.tsa.vector_ar.vecm import VECM
from statsmodels.tsa.api import VAR
# Forecast race - the practical cost of getting the integration order wrong.
H = 12; hshow = [1, 4, 8, 12]; start = int(np.where(dates >= pd.Timestamp("1995-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-levels", "VAR-diff", "VECM(r=1)"]: print("  %-11s" % k, np.round(rmse[k], 3))

# figure: RMSE vs forecast horizon, one line per model  ->  vecm_fisher_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 (Fisher effect)")
fig.tight_layout(); plt.show()
OOS RMSE by horizon (quarters): [1, 4, 8, 12]
  VAR-levels  [1.66  1.999 2.472 2.841]
  VAR-diff    [1.774 2.064 2.414 2.612]
  VECM(r=1)   [1.661 1.897 2.204 2.418]
No description has been provided for this image

Results — a cautionary tale about integration order¶

  • Cointegration analysis is only as good as its pre-tests. The Fisher story is textbook, but in this sample the data refuse the setup: inflation is already stationary, so there is no unit-root equilibrium to error-correct toward. Reporting a "cointegrating Fisher relation" here would be a category error — the discipline of testing the integration order first is exactly what stops it.
  • When the series is stationary, the model choice barely matters. All three specifications forecast comparably (RMSE ~2.4–2.8 at three years) — because inflation is already stationary, none of the misspecifications bite hard: differencing an I(0) series is merely inefficient (not the disaster it would be for a true I(1) trend), and imposing a spurious $r=1$ cointegration neither helps nor much hurts. This is the mirror image of Part 6a: there, the presence of cointegration made keeping the levels matter for long-horizon forecasts; here, its absence in the classical sense makes the whole VECM apparatus largely moot. The action is entirely in the pre-test, not the forecast race.
  • Why bother with the Bayesian machine, then? Because it delivers the coefficient's full posterior — and the honest finding that its interval excludes $-1$ — rather than a bare point estimate that invites over-reading. (The R cross-check with urca agrees inflation is I(0); on the borderline rank the two packages' critical-value tables split between $r=1$ and full rank — underlining that, with a stationary series in the mix, the rank is neither well-determined nor the point.)

The final notebook, money demand (Part 6c), shows the third possibility: two genuinely I(1)-looking series whose long-run relation has simply broken down.

References¶

  • Fisher, I. (1930). The Theory of Interest. Macmillan.
  • Mishkin, F. S. (1992). Is the Fisher effect for real? J. Monetary Economics 30, 195–215.
  • Engle, R. F. & Granger, C. W. J. (1987). Econometrica 55, 251–276.

Next: bvecm_fisher_R.ipynb — the R cross-check (urca).