Macro-Finance Simulation

Asset Pricing, DSGE Solution, Term Structure and Macro-Financial Risk
using R, Python & Stata

Applied Informatics and Computational Economics Lab

18 July 2026

Outline

  • Part 1 — Why simulate macro-finance?
    the equity premium puzzle on 2026 data, the SDF, Hansen–Jagannathan bounds
  • Part 2 — The simulation toolkit
    Tauchen & Rouwenhorst, SDE discretisation, variance reduction
  • Part 3 — Consumption-based asset pricing
    Lucas tree, Epstein–Zin, long-run risk, habit, rare disasters
  • Part 4 — Solving & simulating a DSGE
    Blanchard–Kahn, perturbation, risk premia, the financial accelerator
  • Part 5 — Term structure simulation
    Vasicek, CIR, Nelson–Siegel, the GSW curve, bond scenarios
  • Part 6 — Macro-financial risk
    Growth-at-Risk, regime switching, scenario and tail simulation
  • Part 7 — Estimation by simulation
    SMM, indirect inference, weak identification, small samples
  • Part 8 — Practice & exercises
    reporting checklist, pitfalls, exercises, further reading

Every part runs the same loop:

Write the model down solve it simulate it compute the moments it implies put those beside the same moments in 2026-vintage US data say where the model wins and where it does not.

Two deliberate departures from the standard slide order used in this lecture series. Both are choices, not drift.

1. The motivating puzzle comes before the literature review. Nobody cares which papers solved the equity premium puzzle until they have watched it happen on data that ends this year. Required Packages still comes first.

2. The tests are distributed, not collected in one section. This deck uses seven of them — Ljung–Box on simulated against actual series, the Blanchard–Kahn rank condition, the Feller condition, Hansen’s \(J\), the Hamilton likelihood-ratio test for regimes, a quantile-crossing check, and Diebold–Mariano on Growth-at-Risk densities. Each belongs beside the stage it validates.

This deck is about simulation as the engine: solving stochastic macro-finance models, simulating them, and holding the simulations to account against data.

Owned by another deck Where the boundary is touched here
Monte Carlo Methods — MC as an estimator and test-design tool Part 2 recaps the \(1/\sqrt{B}\) law in one slide, then covers variance reduction, which that deck does not
Bayesian Computation — MCMC, HMC, Kalman, particle filters, BVAR, DSGE estimation Part 5 uses a Kalman filter for dynamic Nelson–Siegel; it is cited, not re-derived
DCC-GARCH-Copula — conditional volatility, dependence, VaR backtesting, CoVaR Part 6 does macro-financial risk; systemic risk is one cross-reference
Structural Estimation — GMM, SMM and indirect inference theory Part 7 applies both to a macro-finance model
Numerical Applications — floating point, decompositions, optimisation, ODEs Part 2 uses those tools; SDE simulation is new
VAR & Local Projections — VAR, SVAR, LP as estimators Part 4 uses lpirf to confront model IRFs with empirical ones

68 content slides is three to four sittings, not one. The core path below is 55 of them, in order, grouped into four sessions. The other 13 are reference depth: they earn their place, but they are scrollable deep dives that read better after class than during it. Seven of those carry a visible reference depth tag on the slide itself.

Session 1 — Parts 1 & 2 · the puzzle and the toolkit
Core (12). Equity Premium Puzzle on 2026 Data → Puzzle Code → Puzzle Results & Interpretation → SDF and the Hansen–Jagannathan Bound → The Data → DGP Mathematical Specification → DGP Code Implementation → Discretising a Persistent AR(1) → Tauchen & Rouwenhorst Code → Simulating in Continuous Time → Discretisation Bias → Monte Carlo Error, Burn-in & Variance Reduction
Reference (4). Four Stylised Facts · DGP Diagnostics · Accuracy vs Persistence · Variance Reduction Code

Session 2 — Part 3 · consumption-based asset pricing
Core (9). The Lucas Tree → Lucas Tree Code → Lucas Tree Results → Epstein–Zin → Long-Run Risks → Long-Run Risks Results & Fragility → Habit Formation → Rare Disasters & the Peso Problem → Model Horse Race
Reference (1). Long-Run Risks Code

Session 3 — Parts 4 & 5 · solving models, pricing bonds
Core (14). The Model → Log-Linearisation & Blanchard–Kahn → First-Order Perturbation Code → Simulating the Solved Model → Stochastic Simulation & Model Moments → Why First Order Kills the Risk Premium → Second Order: the Risk Correction → Financial Frictions → Affine Term Structure Models → Vasicek & CIR → Simulated vs Actual GSW Curves → Estimating the Short-Rate Process → Nelson–Siegel–Svensson & the GSW Parameters → Scenario Simulation for a Bond Portfolio
Reference (4). Financial Accelerator vs BAA-10Y · Model IRFs vs Local Projections · From Short Rate to Yield Curve · Dynamic Nelson–Siegel

Session 4 — Parts 6, 7 & 8 · risk, estimation, practice
Core (20). Growth-at-Risk → GaR Quantile Regression → The Predictive Density → GaR Fan Chart → Regime Switching → Simulating Macro Scenarios → Simulated Method of Moments → SMM Recovering Known Truth → SMM Does It Recover the Truth? → SMM on a Noisy Objective (CRN) → Indirect Inference → Weak Identification → A Century Is Not Enough → Model Comparison → What This Deck Found → What to Report → Common Pitfalls → Exercises (Simulation & Estimation) → Exercises (Testing & Diagnostics) → Further Reading
Reference (4). Regime Switching Code · Tail Simulation & Importance Sampling · Reproducibility & Computation · Variations & Method Chooser

Required Packages

library(QZ)           # qz.dgges() — generalized Schur; the Blanchard-Kahn solution
library(sn)           # dst(), rst() — skew-t densities for Growth-at-Risk
library(quantreg)     # rq() — quantile regression
library(randtoolbox)  # sobol() — quasi-Monte Carlo sequences
library(vars)         # VAR(), irf() — scenario simulation, auxiliary models
library(tseries)      # adf.test(), jarque.bera.test()
library(expm)         # %^% — matrix powers for Markov chains and IRFs
library(future.apply) # future_lapply() — parallel Monte Carlo
library(tidyverse)    # data wrangling & ggplot2
library(png)          # readPNG() — reload Stata-exported graphs
import numpy as np                      # arrays, linear algebra
import pandas as pd                     # data frames, CSV input
import scipy.stats as st                # distributions, skew-t, KDE
from scipy.linalg import ordqz          # generalized Schur — DSGE solution
from scipy.optimize import minimize     # SMM, indirect inference, MLE
from scipy.stats import qmc             # Sobol sequences
import statsmodels.api as sm            # quantile regression, VAR, state space
from statsmodels.tsa.regime_switching.markov_regression import MarkovRegression
import matplotlib.pyplot as plt         # all figures
* Nothing to install: every command used here ships with Stata SE 19.
dsge          // linear DSGE: solve, simulate, estimate
dsgenl        // nonlinear DSGE, log-linearised around the steady state
sspace        // state-space models — dynamic Nelson-Siegel
mswitch       // Markov-switching mean and variance
qreg          // quantile regression — Growth-at-Risk
var, irf      // scenario simulation, auxiliary models
lpirf         // Jorda local projections — Part 4
mata          // Tauchen, SDE paths, bond pricing, SMM via optimize()

Three routines have no Stata counterpart and the relevant tab says so rather than being dropped: Sobol sequences (Part 2), Epstein–Zin and habit preferences (Part 3), and the skew-t predictive density (Part 6).

Part 1 — Why Simulate Macro-Finance?

The equity premium puzzle on 2026 data, the stochastic discount factor,
Hansen-Jagannathan bounds, and the five simulated laboratories.

The Equity Premium Puzzle on 2026 Data

Two facts about the United States, measured on data that ends this year:

  • holding the market rather than a Treasury bill has paid about 8.6% a year, with a standard deviation of 16.4%
  • aggregate consumption per head has grown at 2.1% a year, with a standard deviation of 2.1%

Consumption is eight times smoother than the asset whose risk it is supposed to price. Worse, the two barely move together — their correlation is \(0.06\).

A representative investor with standard preferences buys stocks only to the extent that stocks pay badly when consumption is low. If stocks and consumption are almost unrelated, stocks are almost riskless to that investor, and she should demand almost no premium for holding them.

The market pays 8.6% a year for a risk that, measured through consumption, is barely there. That gap is the equity premium puzzle — and it is the reason the rest of this deck simulates models rather than estimating regressions.

The consumer’s first-order condition for any gross return \(R_{t+1}\):

\[E_t\!\left[ M_{t+1} R_{t+1} \right] = 1, \qquad M_{t+1} = \beta \left( \frac{C_{t+1}}{C_t} \right)^{-\gamma}\]

Applied to an excess return \(R^e_{t+1} = R_{t+1} - R^f_{t+1}\) it becomes

\[E_t\!\left[ M_{t+1} R^e_{t+1} \right] = 0\]

Taking unconditional expectations and expanding the covariance:

\[E\!\left[ R^e \right] = - \frac{\mathrm{Cov}\!\left( M, R^e \right)}{E[M]}\]

For small risks the CRRA discount factor gives \(\mathrm{Cov}(M, R^e) \approx -\gamma \, E[M] \, \mathrm{Cov}(\Delta c, R^e)\), so

\[E\!\left[ R^e \right] \approx \gamma \, \mathrm{Cov}\!\left( \Delta c,\, R^e \right)\]

Everything on the left and the covariance on the right are measured. Only \(\gamma\) is free. The slide after next solves for it.

The Equity Premium Puzzle — Code

Monthly market factors are compounded to quarterly and merged with quarterly consumption growth, so returns and consumption are measured over the same interval. Common sample: 1947Q2 to 2026Q2, 317 quarters.

Code
eq <- read.csv("../data/mfsim-equity.csv")
ma <- read.csv("../data/mfsim-macro.csv")

# Monthly returns compound to quarterly through the sum of their logs
eq <- eq |>
  mutate(qtr = sprintf("%sQ%d", substr(date, 1, 4),
                       (as.integer(substr(date, 6, 7)) - 1) %/% 3 + 1),
         lm  = log(1 + (mktrf + rf) / 100),
         lf  = log(1 + rf / 100))

agg <- aggregate(cbind(lm, lf) ~ qtr, data = eq, FUN = sum) |>
  mutate(re = exp(lm) - exp(lf))

d <- merge(agg, ma[, c("qtr", "dcons")], by = "qtr") |>
  mutate(dc = dcons / 400)          # dcons is 400 x log-difference

# Annualised moments: means x 4, volatilities x sqrt(4)
mre <- mean(d$re); sre <- sd(d$re)
mdc <- mean(d$dc); sdc <- sd(d$dc)
gamma_euler <- mre / cov(d$dc, d$re)
Sample: 1947Q2 to 2026Q2   n = 317 quarters
                 Quantity       Value
      Excess return, mean  8.61% / yr
        Excess return, sd 16.39% / yr
             Sharpe ratio       0.525
 Consumption growth, mean  2.08% / yr
   Consumption growth, sd  2.13% / yr
      Correlation(dc, Re)       0.061
       Covariance(dc, Re)   5.352e-05
    Implied risk aversion         402
Code
import numpy as np
import pandas as pd

eq = pd.read_csv("../data/mfsim-equity.csv")
ma = pd.read_csv("../data/mfsim-macro.csv")

# Monthly returns compound to quarterly through the sum of their logs
eq["qtr"] = (eq["date"].str[:4] + "Q" +
             (((eq["date"].str[5:7].astype(int) - 1) // 3) + 1).astype(str))
eq["lm"] = np.log(1 + (eq["mktrf"] + eq["rf"]) / 100)
eq["lf"] = np.log(1 + eq["rf"] / 100)

agg = eq.groupby("qtr", as_index=False)[["lm", "lf"]].sum()
agg["re"] = np.exp(agg["lm"]) - np.exp(agg["lf"])

d = agg.merge(ma[["qtr", "dcons"]], on="qtr")
d["dc"] = d["dcons"] / 400        # dcons is 400 x log-difference

mre, sre = d["re"].mean(), d["re"].std(ddof=1)
mdc, sdc = d["dc"].mean(), d["dc"].std(ddof=1)
cv = np.cov(d["dc"], d["re"], ddof=1)[0, 1]
gamma_euler = mre / cv

out = (f"Sample: {d['qtr'].min()} to {d['qtr'].max()}   n = {len(d)} quarters\n\n"
       f"Excess return, mean        {400*mre:10.2f}% / yr\n"
       f"Excess return, sd          {200*sre:10.2f}% / yr\n"
       f"Sharpe ratio               {2*mre/sre:10.3f}\n"
       f"Consumption growth, mean   {400*mdc:10.2f}% / yr\n"
       f"Consumption growth, sd     {200*sdc:10.2f}% / yr\n"
       f"Correlation(dc, Re)        {np.corrcoef(d['dc'], d['re'])[0,1]:10.3f}\n"
       f"Covariance(dc, Re)         {cv:10.3e}\n"
       f"Implied risk aversion      {gamma_euler:10.0f}")
import sys; nchars = sys.stdout.write(out + "\n"); sys.stdout.flush()
Sample: 1947Q2 to 2026Q2   n = 317 quarters

Excess return, mean              8.61% / yr
Excess return, sd               16.39% / yr
Sharpe ratio                    0.525
Consumption growth, mean         2.08% / yr
Consumption growth, sd           2.13% / yr
Correlation(dc, Re)             0.061
Covariance(dc, Re)          5.352e-05
Implied risk aversion             402
Code
quietly import delimited "../data/mfsim-equity.csv", clear stringcols(2)

* Monthly returns compound to quarterly through the sum of their logs
generate qtr = substr(date,1,4) + "Q" + string(int((real(substr(date,6,2))-1)/3) + 1, "%1.0f")
generate lm = ln(1 + (mktrf + rf)/100)
generate lf = ln(1 + rf/100)
collapse (sum) lm lf, by(qtr)
generate re = exp(lm) - exp(lf)
tempfile eq
quietly save `eq'

quietly import delimited "../data/mfsim-macro.csv", clear stringcols(3)
keep qtr dcons
quietly merge 1:1 qtr using `eq', keep(match) nogenerate
generate dc = dcons/400

quietly summarize re
scalar mre = r(mean)
scalar sre = r(sd)
quietly summarize dc
scalar mdc = r(mean)
scalar sdc = r(sd)
quietly correlate dc re, covariance
scalar cv = r(cov_12)
quietly correlate dc re
scalar rho = r(rho)

* One display command, so the table prints as one block
display "n = " _N " quarters" _newline ///
  _newline ///
  "Excess return, mean       " %10.2f 400*mre " % / yr"  _newline ///
  "Excess return, sd         " %10.2f 200*sre " % / yr"  _newline ///
  "Sharpe ratio              " %10.3f 2*mre/sre          _newline ///
  "Consumption growth, mean  " %10.2f 400*mdc " % / yr"  _newline ///
  "Consumption growth, sd    " %10.2f 200*sdc " % / yr"  _newline ///
  "Correlation(dc, Re)       " %10.3f rho                _newline ///
  "Covariance(dc, Re)        " %10.3e cv                 _newline ///
  "Implied risk aversion     " %10.0f mre/cv
n = 317 quarters

Excess return, mean             8.61 % / yr
Excess return, sd              16.39 % / yr
Sharpe ratio                   0.525
Consumption growth, mean        2.08 % / yr
Consumption growth, sd          2.13 % / yr
Correlation(dc, Re)            0.061
Covariance(dc, Re)         5.352e-05
Implied risk aversion            402

The Puzzle — Results & Interpretation

Quantity Value
Excess return, mean 8.61% / yr
Excess return, sd 16.39% / yr
Sharpe ratio 0.525
Consumption growth, mean 2.08% / yr
Consumption growth, sd 2.13% / yr
Correlation \((\Delta c, R^e)\) 0.061
Covariance \((\Delta c, R^e)\) \(5.35 \times 10^{-5}\)
Implied risk aversion \(\gamma\) 402

Sample 1947Q2–2026Q2, 317 quarters. Over the full 1926–2026 monthly sample the premium is 8.36% with a Sharpe ratio of 0.455 — the puzzle is not an artefact of the postwar window.

\(\gamma = 402\) is not a preference parameter, it is a rejection. Survey and experimental evidence puts risk aversion in the low single digits. Values above about 10 imply behaviour nobody displays: at \(\gamma = 402\) an agent would pay almost any premium to avoid a coin flip over 1% of consumption.

The arithmetic is driven by the correlation of 0.061, not by the premium. Because

\[\mathrm{Cov}\!\left( \Delta c, R^e \right) = \rho \cdot \sigma_{\Delta c} \cdot \sigma_{R^e}\]

a near-zero \(\rho\) makes the covariance vanish, and \(\gamma\) must explode to keep \(E[R^e] = \gamma \, \mathrm{Cov}(\Delta c, R^e)\) in balance. Equities are not risky in the consumption metric. Any model that solves the puzzle has to make them risky some other way — that is what Part 3 is for.

Raising \(\gamma\) to rescue the premium breaks a second equation. For lognormal consumption growth the model’s own risk-free rate is

\[r^f = -\ln \beta + \gamma \mu_{\Delta c} - \tfrac{1}{2} \gamma^2 \sigma^2_{\Delta c}\]

With \(\beta = 0.99\) per quarter, at the values just measured:

\(\gamma\) model \(r^f\)
2 8.09% / yr
16.6 32.27% / yr
402 \(-2837\%\) / yr

The actual ex-post real policy rate over the same sample is 1.42% / yr.

Even \(\gamma = 2\) — far too low for the premium — already predicts a risk-free rate six times too high. This is Weil’s risk-free rate puzzle, and it means the two puzzles cannot be traded off against each other.

The SDF and the Hansen–Jagannathan Bound

The previous slide assumed a particular discount factor and asked what \(\gamma\) would have to be. Hansen and Jagannathan (1991) turn the question around and ask something far more useful for a simulator:

Forget preferences. What must be true of any discount factor that prices these returns, whatever model produced it?

The answer is a lower bound on how volatile the discount factor has to be, set entirely by the Sharpe ratio of the assets:

\[\frac{\sigma(M)}{E[M]} \;\ge\; \frac{\left| E[R^e] \right|}{\sigma\!\left( R^e \right)} \;=\; \text{Sharpe ratio}\]

A high Sharpe ratio in the data is a demand for a volatile \(M\) — and \(M\) is the one object every model in Parts 3 to 5 produces. That makes this the cheapest validation check a simulated model will ever face: simulate, compute two moments of \(M\), plot the point, see whether it clears the line. No estimation, no standard errors, no auxiliary assumptions.

For CRRA preferences under lognormal consumption growth the left-hand side is approximately \(\gamma \sigma_{\Delta c}\), which yields the textbook rule of thumb

\[\gamma \;\ge\; \frac{\text{Sharpe}}{\sigma_{\Delta c}}\]

Two ways to compute it, and they disagree. The lognormal rule of thumb demands \(\gamma \ge 24.6\); the exact bound, computed on the consumption data as it actually is, is cleared at \(\gamma = 16.6\). Consumption growth has fatter tails than a lognormal — 2020Q2 alone is \(-36.5\%\) at an annual rate — and those tails make \(\exp(-\gamma \Delta c)\) more volatile than the approximation allows. Neither number is remotely defensible as a preference parameter, but a model builder should quote the one computed from the data.

The code tabs compute both. The feasible region is everything on or above the ray of slope \(\text{Sharpe} = 0.2626\) (quarterly); the curve traces CRRA discount factors \(M = \beta \exp(-\gamma \Delta c)\) built from the actual consumption data, for \(\gamma\) from 0 to 22.

Code
beta_q <- 0.99
sharpe <- mean(d$re) / sd(d$re)          # quarterly Sharpe ratio

# CRRA locus: one (E[M], sd(M)) point per level of risk aversion
gammas <- seq(0, 22, by = 0.5)
locus <- data.frame(gamma = gammas) |>
  mutate(EM  = sapply(gamma, \(g) mean(beta_q * exp(-g * d$dc))),
         sdM = sapply(gamma, \(g) sd(beta_q * exp(-g * d$dc))))

# Where does the locus first clear the bound?
gap      <- \(g) sd(beta_q * exp(-g * d$dc)) / mean(beta_q * exp(-g * d$dc)) - sharpe
gamma_hj <- uniroot(gap, c(1, 25))$root
gamma_ln <- sharpe / sd(d$dc)            # the lognormal rule of thumb

ggplot(locus) +
  aes(x = EM, y = sdM) +
  geom_ribbon(aes(ymin = sharpe * EM, ymax = 0.40), fill = "#1D9E75", alpha = 0.12) +
  geom_line(aes(y = sharpe * EM), colour = "#1D9E75", linewidth = 1.1) +
  geom_line(colour = "#185FA5", linewidth = 1.1) +
  geom_point(data = subset(locus, gamma %in% c(0, 5, 10, 16.5, 20)),
             colour = "#185FA5", size = 2.4) +
  coord_cartesian(xlim = c(0.91, 1.00), ylim = c(0, 0.40))


quarterly Sharpe        0.2626
exact bound cleared at  gamma = 16.6
lognormal rule of thumb gamma = 24.6
Code
import matplotlib.pyplot as plt
from scipy.optimize import brentq

beta_q = 0.99
sharpe = d["re"].mean() / d["re"].std(ddof=1)
dc = d["dc"].to_numpy()

def moments(g):
    m = beta_q * np.exp(-g * dc)
    return m.mean(), m.std(ddof=1)

gammas = np.arange(0, 22.5, 0.5)
EM  = np.array([moments(g)[0] for g in gammas])
sdM = np.array([moments(g)[1] for g in gammas])

gamma_hj = brentq(lambda g: moments(g)[1] / moments(g)[0] - sharpe, 1, 25)
gamma_ln = sharpe / dc.std(ddof=1)

fig, ax = plt.subplots(figsize=(8, 4.8))
xs = np.linspace(0.91, 1.00, 50)
ax.fill_between(xs, sharpe * xs, 0.40, color="#1D9E75", alpha=0.12)
ax.plot(xs, sharpe * xs, color="#1D9E75", lw=1.6)
ax.plot(EM, sdM, color="#185FA5", lw=1.6)
for g in [0, 5, 10, 16.5, 20]:
    e, s = moments(g)
    ax.plot(e, s, "o", color="#185FA5", ms=5)
    ax.annotate(f"g={g:.0f}", (e, s), textcoords="offset points",
                xytext=(7, -2), color="#185FA5", fontsize=9)
ax.text(0.955, 0.355, "feasible: sd(M)/E[M] >= Sharpe", color="#1D9E75", fontsize=11)
ax.text(0.917, 0.055, f"bound cleared at g = {gamma_hj:.1f}",
        color="#D85A30", fontsize=11)
ax.text(0.917, 0.020,
        f"Sharpe = {sharpe:.4f}   lognormal rule of thumb g = {gamma_ln:.1f}",
        color="#555555", fontsize=9)
axopts = ax.set(xlim=(0.91, 1.00), ylim=(0, 0.40),
                xticks=np.arange(0.91, 1.001, 0.01),
                yticks=np.arange(0, 0.41, 0.10),
                xlabel="E[M]", ylabel="sd(M)",
                title="Hansen-Jagannathan bound, US 1947Q2-2026Q2")
plt.show()

Code
quietly import delimited "../data/mfsim-equity.csv", clear stringcols(2)
generate qtr = substr(date,1,4) + "Q" + string(int((real(substr(date,6,2))-1)/3) + 1, "%1.0f")
generate lm = ln(1 + (mktrf + rf)/100)
generate lf = ln(1 + rf/100)
collapse (sum) lm lf, by(qtr)
generate re = exp(lm) - exp(lf)
tempfile eq
quietly save `eq'

quietly import delimited "../data/mfsim-macro.csv", clear stringcols(3)
keep qtr dcons
quietly merge 1:1 qtr using `eq', keep(match) nogenerate
generate dc = dcons/400

quietly summarize re
scalar sharpe = r(mean)/r(sd)
quietly summarize dc
scalar sdc = r(sd)
scalar gamma_ln = sharpe/sdc

* Bisection for the exact crossing -- the same root R finds with uniroot()
scalar lo = 1
scalar hi = 25
forvalues it = 1/60 {
    scalar mid = (lo + hi)/2
    quietly generate double mtmp = 0.99*exp(-mid*dc)
    quietly summarize mtmp
    scalar ratio = r(sd)/r(mean)
    quietly drop mtmp
    if (ratio < sharpe) scalar lo = mid
    else scalar hi = mid
}
scalar gamma_hj = (lo + hi)/2

* One (E[M], sd(M)) point per level of risk aversion
tempfile locus
tempname loc
postfile `loc' gamma em sdm using `locus'
forvalues j = 0/44 {
    local g = `j' * 0.5
    quietly generate double mtmp = 0.99*exp(-`g'*dc)
    quietly summarize mtmp
    post `loc' (`g') (r(mean)) (r(sd))
    quietly drop mtmp
}
postclose `loc'

quietly use `locus', clear
generate bound = sharpe * em
generate top   = 0.40

* Markers at a few levels of risk aversion, as in the R and Python tabs
generate byte mark = abs(gamma-0) < 0.01 | abs(gamma-5) < 0.01 |     ///
                     abs(gamma-10) < 0.01 | abs(gamma-16.5) < 0.01 | ///
                     abs(gamma-20) < 0.01
generate mem = em  if mark
generate msd = sdm if mark
generate str8 glab = "g=" + string(gamma, "%2.0f") if mark

local t1 "feasible: sd(M)/E[M] >= Sharpe"
local t2 = "bound cleared at g = " + string(gamma_hj, "%3.1f")
local t3 = "Sharpe = " + string(sharpe, "%5.4f") +                   ///
           "   lognormal rule of thumb g = " + string(gamma_ln, "%3.1f")

twoway (rarea top bound em, color("29 158 117%12") lwidth(none))     ///
       (line bound em, sort lcolor("29 158 117") lwidth(medium))     ///
       (line sdm em, sort lcolor("24 95 165") lwidth(medium))        ///
       (scatter msd mem, mcolor("24 95 165") msymbol(circle)         ///
          mlabel(glab) mlabcolor("24 95 165") mlabposition(3)        ///
          mlabsize(small)),                                          ///
  xscale(range(0.91 1.00)) yscale(range(0 0.40))                     ///
  xlabel(0.91(0.01)1.00) ylabel(0(0.10)0.40)                         ///
  xtitle("E[M]") ytitle("sd(M)")                                     ///
  title("Hansen-Jagannathan bound, US 1947Q2-2026Q2")                ///
  text(0.355 0.955 "`t1'", color("29 158 117") size(medsmall))       ///
  text(0.055 0.917 "`t2'", color("216 90 48") size(medsmall)         ///
       placement(e) justification(left))                             ///
  text(0.020 0.917 "`t3'", color(gs7) size(vsmall)                   ///
       placement(e) justification(left))                             ///
  legend(off) graphregion(color(white))
graph export "../plots/mfsim-hj-stata.png", replace width(1600)

The Data

Nine CSVs, all written by mfsim-data.R and read — never written — by the deck. Four hold real data; five are simulated laboratories with known truth.

File What it is Why the deck needs it
mfsim-equity.csv Ken French monthly market factors the premium, the Sharpe ratio, every moment Part 3 tries to match
mfsim-macro.csv FRED quarterly real GDP, consumption per head, inflation, policy rate, credit spread, financial conditions the consumption side of every Euler equation, and the macro side of Parts 4 and 6
mfsim-yields.csv Gürkaynak–Sack–Wright zero-coupon curve, month-end Part 5 — a curve to fit, and the Nelson–Siegel–Svensson parameters behind it
mfsim-daily.csv S&P 500, VIX, 10-year yield, credit spread Part 6 — daily risk measures and scenario inputs
mfsim-lucas.csv Lucas tree endowment economy Part 3 — a model whose answer is known in closed form
mfsim-lrr.csv Bansal–Yaron long-run risk Parts 3 and 7 — a latent state nobody observes
mfsim-dsge.csv log-linear RBC with a finance premium Parts 4 and 7 — known deep parameters to recover
mfsim-term.csv Vasicek and CIR paths + implied curves Parts 2 and 5 — exact transitions, so discretisation error is measurable
mfsim-regime.csv two-state Markov-switching growth Part 6 — a regime the filter has to find
            Dataset  Obs       From         To                       Headline
   equity (monthly) 1199    1926-07    2026-05 excess ret 8.36%/yr, sd 18.39%
  macro (quarterly)  317     1947Q2     2026Q2 cons growth 2.08%/yr, sd 2.13%
 yields (month-end)  660    1971-08    2026-07       10y from 0.55% to 14.89%
              daily 2492 2016-08-02 2026-07-30           VIX from 9.1 to 82.7

The consumption number is the one people misread. The column dcons is \(400 \times\) the log difference — an annualised growth rate, not a level. Its own standard deviation (4.27) is therefore not a volatility. Divide by 400 for quarterly log growth, then annualise the standard deviation by \(\sqrt{4}\):

\[\sigma_{\Delta c}^{\text{ann}} = 2 \times \mathrm{sd}\!\left( \frac{\texttt{dcons}}{400} \right) = 2.13\% \text{ per year}\]

Everything here ends in 2026. That is the point of rebuilding rather than reusing a textbook table.

Source Latest observation Note
Ken French factors 2026-05 built from the 202605 CRSP database
GSW zero-coupon curve 2026-07-24 daily since 1961; the Fed Board revises it as new data arrive
FRED macro 2026Q2 GDP and consumption are revised for years after first release
FRED daily 2026-07-30

Three honest warnings:

  • Revisions. Real GDP and consumption for recent quarters will change. A rendered deck is stable because it reads a CSV; the re-download is not bit-identical. Anyone rerunning mfsim-data.R next year gets slightly different numbers, and should.
  • Yahoo Finance is not used. It rate-limited every request while this deck was built. Nothing here depends on it.

Real data cannot answer the question this deck asks. To say this model explains the premium, you need a case where the right answer is known before you start — otherwise a good fit and a lucky bug are indistinguishable.

So five of the nine files are simulated from models whose parameters are written down in mfsim-data.R and carried as columns in the CSV. Every estimator in Parts 3 to 7 is scored against those columns, so a slide can say recovered 0.9773 against a true 0.979 rather than the estimate looks reasonable.

Random-number streams differ across R, Python and Stata: the same seed does not give the same draws. So any simulated path the three tabs must agree on lives in a CSV, and all three read that one file. Simulations run live inside a tab are labelled as illustrative, and their low-order digits will differ.

Four Stylised Facts

reference depth — scrollable, read after class

The four facts the rest of the deck is built to confront. Each panel carries the number that makes it a fact rather than an impression.

Code
# Quarterly excess returns merged with quarterly consumption growth
eq <- read.csv("../data/mfsim-equity.csv")
ma <- read.csv("../data/mfsim-macro.csv")
eq <- eq |>
  mutate(qtr = sprintf("%sQ%d", substr(date, 1, 4),
                       (as.integer(substr(date, 6, 7)) - 1) %/% 3 + 1),
         lm  = log(1 + (mktrf + rf) / 100), lf = log(1 + rf / 100))
agg <- aggregate(cbind(lm, lf) ~ qtr, data = eq, FUN = sum) |>
  mutate(re = 100 * (exp(lm) - exp(lf)))
d <- merge(agg, ma[, c("qtr", "year", "dcons")], by = "qtr") |>
  mutate(dc = dcons / 4)                      # quarterly percent
d <- d[order(d$year), ]

# Rolling ten-year volatility of the excess return, annualised
d$rvol <- NA_real_
for (i in 40:nrow(d)) d$rvol[i] <- 2 * sd(d$re[(i - 39):i])

yl <- read.csv("../data/mfsim-yields.csv") |>
  mutate(slope = y10 - y01)

pA <- ggplot(d) + aes(x = year) +
  geom_line(aes(y = re), colour = "#D85A30", linewidth = 0.4) +
  geom_line(aes(y = dc), colour = "#185FA5", linewidth = 0.6)
pB <- ggplot(d) + aes(x = dc, y = re) +
  geom_point(colour = "#185FA5", alpha = 0.5, size = 1) +
  geom_smooth(method = "lm", se = FALSE, colour = "#D85A30")
pC <- ggplot(d) + aes(x = year, y = rvol) +
  geom_line(colour = "#185FA5", linewidth = 0.7)
pD <- ggplot(yl) + aes(x = year) +
  geom_line(aes(y = y10), colour = "#185FA5", linewidth = 0.6) +
  geom_line(aes(y = slope), colour = "#D85A30", linewidth = 0.6)
(pA | pB) / (pC | pD)

Code
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

eq = pd.read_csv("../data/mfsim-equity.csv")
ma = pd.read_csv("../data/mfsim-macro.csv")
eq["qtr"] = (eq["date"].str[:4] + "Q" +
             (((eq["date"].str[5:7].astype(int) - 1) // 3) + 1).astype(str))
eq["lm"] = np.log(1 + (eq["mktrf"] + eq["rf"]) / 100)
eq["lf"] = np.log(1 + eq["rf"] / 100)
agg = eq.groupby("qtr", as_index=False)[["lm", "lf"]].sum()
agg["re"] = 100 * (np.exp(agg["lm"]) - np.exp(agg["lf"]))
d = agg.merge(ma[["qtr", "year", "dcons"]], on="qtr").sort_values("year")
d["dc"] = d["dcons"] / 4                       # quarterly percent

# Rolling ten-year volatility of the excess return, annualised
d["rvol"] = 2 * d["re"].rolling(40).std(ddof=1)

yl = pd.read_csv("../data/mfsim-yields.csv")
yl["slope"] = yl["y10"] - yl["y01"]

fig, ax = plt.subplots(2, 2, figsize=(10, 6))

ax[0, 0].plot(d["year"], d["re"], color="#D85A30", lw=0.5)
ax[0, 0].plot(d["year"], d["dc"], color="#185FA5", lw=0.8)
ax[0, 0].text(1950, -26, "sd 2.13% vs 16.39% per year", color="#4d4d4d", fontsize=8)
a1 = ax[0, 0].set(xlim=(1947, 2026), ylim=(-30, 30),
                  xticks=np.arange(1950, 2021, 20), yticks=np.arange(-30, 31, 15),
                  ylabel="percent per quarter",
                  title="1. Consumption is smooth, equity is not")

b = np.polyfit(d["dc"], d["re"], 1)
xs = np.linspace(-10, 9, 20)
ax[0, 1].plot(d["dc"], d["re"], "o", color="#185FA5", alpha=0.5, ms=2.5)
ax[0, 1].plot(xs, b[1] + b[0] * xs, color="#D85A30", lw=1.3)
ax[0, 1].text(-9.5, -26, "correlation = 0.061", color="#4d4d4d", fontsize=8)
a2 = ax[0, 1].set(xlim=(-10, 9), ylim=(-30, 30),
                  xticks=np.arange(-10, 6, 5), yticks=np.arange(-30, 31, 15),
                  xlabel="consumption growth", ylabel="excess return",
                  title="2. And they barely move together")

ax[1, 0].plot(d["year"], d["rvol"], color="#185FA5", lw=1.0)
ax[1, 0].text(1960, 9.2, "rolling 10-year vol: 11.2% to 21.1%",
              color="#4d4d4d", fontsize=8)
a3 = ax[1, 0].set(xlim=(1957, 2026), ylim=(8, 24),
                  xticks=np.arange(1960, 2021, 20), yticks=np.arange(8, 25, 4),
                  ylabel="percent per year",
                  title="3. Risk itself is not constant")

ax[1, 1].plot(yl["year"], yl["y10"], color="#185FA5", lw=0.8)
ax[1, 1].plot(yl["year"], yl["slope"], color="#D85A30", lw=0.8)
ax[1, 1].axhline(0, color="#999999", lw=0.5)
ax[1, 1].text(1974, -3, "10y from 0.55% to 14.89%", color="#4d4d4d", fontsize=8)
a4 = ax[1, 1].set(xlim=(1971, 2026), ylim=(-4, 16),
                  xticks=np.arange(1980, 2021, 20), yticks=np.arange(-4, 17, 4),
                  ylabel="percent",
                  title="4. The curve moves in level and in slope")

fig.tight_layout()
plt.show()

Code
quietly import delimited "../data/mfsim-equity.csv", clear stringcols(2)
generate qtr = substr(date,1,4) + "Q" + string(int((real(substr(date,6,2))-1)/3) + 1, "%1.0f")
generate lm = ln(1 + (mktrf + rf)/100)
generate lf = ln(1 + rf/100)
collapse (sum) lm lf, by(qtr)
generate re = 100*(exp(lm) - exp(lf))
tempfile eq
quietly save `eq'

quietly import delimited "../data/mfsim-macro.csv", clear stringcols(3)
keep qtr year dcons
quietly merge 1:1 qtr using `eq', keep(match) nogenerate
generate dc = dcons/4
sort year

* Rolling ten-year volatility of the excess return, annualised
generate rvol = .
forvalues i = 40/`=_N' {
    quietly summarize re in `=`i'-39'/`i'
    quietly replace rvol = 2*r(sd) in `i'
}

twoway (line re year, lcolor("216 90 48") lwidth(vthin))                  ///
       (line dc year, lcolor("24 95 165") lwidth(thin)),                  ///
  xscale(range(1947 2026)) yscale(range(-30 30))                          ///
  xlabel(1950(20)2020) ylabel(-30(15)30)                                  ///
  xtitle("") ytitle("percent per quarter")                                ///
  title("1. Consumption is smooth, equity is not", size(medsmall))        ///
  text(-26 1950 "sd 2.13% vs 16.39% per year", color(gs6) size(vsmall)    ///
       placement(e) justification(left))                                  ///
  legend(off) graphregion(color(white)) name(pA, replace)

twoway (scatter re dc, mcolor("24 95 165%50") msize(vsmall))              ///
       (lfit re dc, lcolor("216 90 48") lwidth(medthin)),                 ///
  xscale(range(-10 9)) yscale(range(-30 30))                              ///
  xlabel(-10(5)5) ylabel(-30(15)30)                                       ///
  xtitle("consumption growth") ytitle("excess return")                    ///
  title("2. And they barely move together", size(medsmall))               ///
  text(-26 -9.5 "correlation = 0.061", color(gs6) size(vsmall)            ///
       placement(e) justification(left))                                  ///
  legend(off) graphregion(color(white)) name(pB, replace)

twoway (line rvol year, lcolor("24 95 165") lwidth(medthin)),             ///
  xscale(range(1957 2026)) yscale(range(8 24))                            ///
  xlabel(1960(20)2020) ylabel(8(4)24)                                     ///
  xtitle("") ytitle("percent per year")                                   ///
  title("3. Risk itself is not constant", size(medsmall))                 ///
  text(9.2 1960 "rolling 10-year vol: 11.2% to 21.1%", color(gs6)         ///
       size(vsmall) placement(e) justification(left))                     ///
  legend(off) graphregion(color(white)) name(pC, replace)

quietly import delimited "../data/mfsim-yields.csv", clear stringcols(2 3)
generate slope = y10 - y01

twoway (line y10 year, lcolor("24 95 165") lwidth(thin))                  ///
       (line slope year, lcolor("216 90 48") lwidth(thin))                ///
       (function y = 0, range(1971 2026) lcolor(gs10) lwidth(vthin)),     ///
  xscale(range(1971 2026)) yscale(range(-4 16))                           ///
  xlabel(1980(20)2020) ylabel(-4(4)16)                                    ///
  xtitle("") ytitle("percent")                                            ///
  title("4. The curve moves in level and in slope", size(medsmall))       ///
  text(-3 1974 "10y from 0.55% to 14.89%", color(gs6) size(vsmall)        ///
       placement(e) justification(left))                                  ///
  legend(off) graphregion(color(white)) name(pD, replace)

graph combine pA pB pC pD, cols(2) graphregion(color(white)) ysize(6) xsize(10)
graph export "../plots/mfsim-facts-stata.png", replace width(1900)

Facts 1 and 2 are the puzzle: the asset is volatile, the risk measure is not, and they barely co-move. Fact 3 says a model with constant risk cannot be right — Parts 2 and 6. Fact 4 says the same about a single interest rate — Part 5.

DGP — Mathematical Specification

Five laboratories. Each is simulated once by mfsim-data.R with the seed 14159, and each carries its true parameters as columns in its CSV, so every estimator later in the deck can be scored rather than admired.

I.i.d. lognormal endowment growth with CRRA preferences — the one case where every asset-pricing quantity is closed form, so simulation error can be measured against an exact answer.

\[\Delta c_{t+1} = \mu_c + \sigma_c z_{1,t+1}\]

\[\Delta d_{t+1} = \mu_d + \sigma_d \left( \rho \, z_{1,t+1} + \sqrt{1-\rho^2} \, z_{2,t+1} \right)\]

The discount factor, the price-dividend ratio and the two returns follow:

\[M_{t+1} = \beta \, e^{-\gamma \Delta c_{t+1}}, \qquad \frac{P}{D} = \frac{k}{1-k}\]

\[k = \beta \exp\!\left( -\gamma \mu_c + \mu_d + \tfrac{1}{2}\!\left( \gamma^2 \sigma_c^2 + \sigma_d^2 \right) - \gamma \rho \sigma_c \sigma_d \right)\]

\[R_{t+1} = \frac{1 + P/D}{P/D} \, e^{\Delta d_{t+1}}, \qquad R^f = \left[ \beta \exp\!\left( -\gamma \mu_c + \tfrac{1}{2}\gamma^2 \sigma_c^2 \right) \right]^{-1}\]

\(\beta\) \(\gamma\) \(\mu_c\) \(\sigma_c\) \(\mu_d\) \(\sigma_d\) \(\rho\)
0.99 2 0.005 0.0075 0.005 0.03 0.20

Quarterly. \(P/D = 68.10\) by construction.

Bansal–Yaron: a small, very persistent predictable component in consumption growth, plus stochastic volatility. Monthly.

\[x_{t+1} = \rho \, x_t + \varphi_e \, \sigma_t \, e_{t+1}\]

\[\sigma^2_{t+1} = \bar\sigma^2 + \nu \left( \sigma^2_t - \bar\sigma^2 \right) + \sigma_w \, w_{t+1}\]

\[\Delta c_{t+1} = \mu + x_t + \sigma_t \, \eta_{t+1}\]

\[\Delta d_{t+1} = \mu_d + \phi \, x_t + \varphi_d \, \sigma_t \, u_{t+1}\]

\(\rho\) \(\varphi_e\) \(\bar\sigma\) \(\nu\) \(\sigma_w\) \(\phi\) \(\varphi_d\) \(\mu = \mu_d\)
0.979 0.044 0.0078 0.987 \(0.23\times10^{-5}\) 3.0 4.5 0.0015

\(x_t\) and \(\sigma_t\) are latent — the CSV carries them as x_true and sigma_true so Part 7 can ask whether SMM finds a state nobody observes.

Log-linear real business cycle with fixed labour and log utility. States are capital \(k_t\) and technology \(a_t\); consumption is the forward-looking variable.

\[k_{t+1} = \left[ (1-\delta) + \alpha \phi \right] k_t + \phi \, a_t - \phi \, s_c \, c_t, \qquad \phi = Y/K\]

\[c_t = E_t c_{t+1} - E_t r_{t+1}, \qquad r_{t+1} = \chi \left( a_{t+1} + (\alpha-1) k_{t+1} \right)\]

\[a_t = \rho_a \, a_{t-1} + \sigma_a \, \varepsilon_t\]

Guessing \(c_t = g_k k_t + g_a a_t\) and matching the \(k_t\) terms gives a quadratic whose stable root is the policy rule:

\[\phi s_c \, g_k^2 + \left[ 1 - a_0 - \phi s_c \chi (\alpha-1) \right] g_k + a_0 \chi (\alpha-1) = 0\]

\[g_a = \frac{m - \chi \rho_a}{1 + m \, s_c - \rho_a}, \qquad m = \left[ g_k - \chi(\alpha-1) \right] \phi\]

\(\alpha\) \(\beta\) \(\delta\) \(\rho_a\) \(\sigma_a\) \(\nu\) \(\rho_n\) \(\kappa\)
0.36 0.99 0.025 0.95 0.007 0.05 0.90 2.0

Implied: \(g_k = 0.6182\), \(g_a = 0.3052\), \(A_k = 0.9653\). Part 4 solves this same model from scratch with a generalized Schur decomposition and has to land on these three numbers.

The credit spread is a reduced-form accelerator overlay, not a micro-founded contract, and the deck says so rather than dressing it up:

\[n_t = \rho_n \, n_{t-1} + \kappa \, y_{t-1} + \sigma_n \, \varepsilon^n_t, \qquad s_t = -\nu \left( n_t - k_t \right)\]

Two affine short-rate models, both simulated by their exact transition laws rather than an Euler step — so Part 2 can measure discretisation bias against a path that has none.

\[dr = \kappa (\theta - r) \, dt + \sigma \, dW \qquad \text{(Vasicek)}\]

\[r_{t+\Delta} = \theta + \left( r_t - \theta \right) e^{-\kappa \Delta} + \sigma \sqrt{\frac{1 - e^{-2\kappa\Delta}}{2\kappa}} \; z\]

\[dr = \kappa (\theta - r) \, dt + \sigma \sqrt{r} \, dW \qquad \text{(CIR)}\]

CIR is drawn from its exact non-central \(\chi^2\) transition, and stays strictly positive provided the Feller condition holds:

\[2 \kappa \theta > \sigma^2\]

Yields are affine in the short rate, so the simulated curve is the model curve:

\[y(\tau) = -\frac{A(\tau) - B(\tau) \, r}{\tau}\]

\(\kappa\) \(\theta\) \(\sigma\) \(\lambda\) \(\Delta\)
Vasicek 0.30 0.045 0.015 \(-0.10\) 1/12
CIR 0.35 0.045 0.06 \(-0.10\) 1/12

Feller for CIR: \(2(0.35)(0.045) = 0.0315 > 0.0036 = \sigma^2\). Vasicek has no such guarantee and does go negative in this sample — a difference Part 5 uses.

Two states with different means and different variances — the minimum model in which risk is itself a state variable.

\[y_t = \mu_{s_t} + \sigma_{s_t} \, \varepsilon_t, \qquad s_t \in \{1, 2\}\]

\[P = \begin{pmatrix} 0.95 & 0.05 \\ 0.25 & 0.75 \end{pmatrix}\]

\(\mu\) \(\sigma\) stay probability
state 1 (expansion) 3.2 1.6 0.95
state 2 (contraction) \(-2.0\) 3.4 0.75

The realised path visits state 2 for 12.8% of the sample — close to the share of postwar quarters the NBER dates as recessions. state_true is carried so the three filters in Part 6 are scored on the same answer.

The parity rule. Random-number streams differ across R, Python and Stata, so the same seed does not give the same draws. Every path above is therefore generated once, in mfsim-data.R, and written to a CSV that all three languages read. Where a slide simulates live inside a tab, its numbers will differ in the low-order digits between languages, and the slide says so. The next slide demonstrates exactly that.

DGP — Code Implementation

The Lucas tree, generated live in each language from the parameters on the previous slide. The other four laboratories are generated the same way by mfsim-data.R; only this one is short enough to run on a slide.

Watch the two blocks of output. The closed-form quantities are functions of the parameters alone and agree to every digit printed. The simulated moments come from each language’s own random-number generator and do not.

Code
set.seed(14159)
n     <- 3000
beta  <- 0.99; gamma <- 2
mu_c  <- 0.005; sig_c <- 0.0075
mu_d  <- 0.005; sig_d <- 0.03; rho <- 0.20

z1 <- rnorm(n); z2 <- rnorm(n)
dc <- mu_c + sig_c * z1
dd <- mu_d + sig_d * (rho * z1 + sqrt(1 - rho^2) * z2)

# Closed form: a constant price-dividend ratio and a constant risk-free rate
k   <- beta * exp(-gamma * mu_c + mu_d +
                  0.5 * (gamma^2 * sig_c^2 + sig_d^2) -
                  gamma * rho * sig_c * sig_d)
pd  <- k / (1 - k)
rf  <- 1 / (beta * exp(-gamma * mu_c + 0.5 * gamma^2 * sig_c^2))
sdf <- beta * exp(-gamma * dc)
ret <- (1 + pd) / pd * exp(dd)
closed form (parameters only -- identical in every language)
  price-dividend ratio         68.098503
  risk-free rate, % per yr      7.975134
  premium, % per yr             0.036000

simulated (own draws -- differs across languages)
  sd(dc)                        0.007562
  sd(dd)                        0.030416
  E[M R]                        1.000326
Code
import numpy as np

rng   = np.random.default_rng(14159)
n     = 3000
beta, gamma = 0.99, 2
mu_c, sig_c = 0.005, 0.0075
mu_d, sig_d, rho = 0.005, 0.03, 0.20

z1 = rng.normal(size=n)
z2 = rng.normal(size=n)
dc = mu_c + sig_c * z1
dd = mu_d + sig_d * (rho * z1 + np.sqrt(1 - rho**2) * z2)

# Closed form: a constant price-dividend ratio and a constant risk-free rate
k   = beta * np.exp(-gamma * mu_c + mu_d
                    + 0.5 * (gamma**2 * sig_c**2 + sig_d**2)
                    - gamma * rho * sig_c * sig_d)
pd_ = k / (1 - k)
rf  = 1 / (beta * np.exp(-gamma * mu_c + 0.5 * gamma**2 * sig_c**2))
sdf = beta * np.exp(-gamma * dc)
ret = (1 + pd_) / pd_ * np.exp(dd)

out = ("closed form (parameters only -- identical in every language)\n"
       f"  price-dividend ratio      {pd_:12.6f}\n"
       f"  risk-free rate, % per yr  {400*np.log(rf):12.6f}\n"
       f"  premium, % per yr         {400*gamma*rho*sig_c*sig_d:12.6f}\n"
       "\nsimulated (own draws -- differs across languages)\n"
       f"  sd(dc)                    {dc.std(ddof=1):12.6f}\n"
       f"  sd(dd)                    {dd.std(ddof=1):12.6f}\n"
       f"  E[M R]                    {(sdf*ret).mean():12.6f}")
import sys; nchars = sys.stdout.write(out + "\n"); sys.stdout.flush()
closed form (parameters only -- identical in every language)
  price-dividend ratio         68.098503
  risk-free rate, % per yr      7.975134
  premium, % per yr             0.036000

simulated (own draws -- differs across languages)
  sd(dc)                        0.007319
  sd(dd)                        0.030084
  E[M R]                        1.000024
Code
* The whole build is silent: only the two blocks at the end print
quietly {
    clear
    set seed 14159
    set obs 3000

    scalar beta = 0.99
    scalar gamma = 2
    scalar mu_c = 0.005
    scalar sig_c = 0.0075
    scalar mu_d = 0.005
    scalar sig_d = 0.03
    scalar rho = 0.20

    generate double z1 = rnormal()
    generate double z2 = rnormal()
    generate double dc = mu_c + sig_c*z1
    generate double dd = mu_d + sig_d*(rho*z1 + sqrt(1 - rho^2)*z2)

    * Closed form: a constant price-dividend ratio and a constant risk-free rate
    scalar k  = beta*exp(-gamma*mu_c + mu_d ///
                + 0.5*(gamma^2*sig_c^2 + sig_d^2) - gamma*rho*sig_c*sig_d)
    scalar pd = k/(1 - k)
    scalar rf = 1/(beta*exp(-gamma*mu_c + 0.5*gamma^2*sig_c^2))
    generate double sdf = beta*exp(-gamma*dc)
    generate double ret = (1 + pd)/pd*exp(dd)
    generate double mr  = sdf*ret

    summarize dc
    scalar sdc = r(sd)
    summarize dd
    scalar sdd = r(sd)
    summarize mr
    scalar emr = r(mean)
}

display "closed form (parameters only -- identical in every language)" _newline ///
  "  price-dividend ratio      " %12.6f pd                            _newline ///
  "  risk-free rate, % per yr  " %12.6f 400*ln(rf)                    _newline ///
  "  premium, % per yr         " %12.6f 400*gamma*rho*sig_c*sig_d     _newline ///
  _newline ///
  "simulated (own draws -- differs across languages)"                 _newline ///
  "  sd(dc)                    " %12.6f sdc                           _newline ///
  "  sd(dd)                    " %12.6f sdd                           _newline ///
  "  E[M R]                    " %12.6f emr
closed form (parameters only -- identical in every language)
  price-dividend ratio         68.098503
  risk-free rate, % per yr      7.975134
  premium, % per yr             0.036000

simulated (own draws -- differs across languages)
  sd(dc)                        0.007460
  sd(dd)                        0.029889
  E[M R]                        1.000122

DGP — Diagnostics

reference depth — scrollable, read after class

Now the opposite test. Every tab below reads the same CSVs, so every number must agree to the last printed digit in all three languages. Each row scores a laboratory against the truth columns it carries.

Code
lu <- read.csv("../data/mfsim-lucas.csv")
lr <- read.csv("../data/mfsim-lrr.csv")
ds <- read.csv("../data/mfsim-dsge.csv")
tm <- read.csv("../data/mfsim-term.csv")
rg <- read.csv("../data/mfsim-regime.csv")

ac1 <- function(x) cor(x[-1], x[-length(x)])

# The RBC policy rule is deterministic, so OLS must return it exactly
nd  <- nrow(ds)
fit <- lm(ds$k[-1] ~ ds$k[-nd] + ds$a[-nd])

# Transition probabilities from the realised regime path
nr  <- nrow(rg)
s0  <- rg$state_true[-nr]; s1 <- rg$state_true[-1]
p11 <- sum(s0 == 1 & s1 == 1) / sum(s0 == 1)
p22 <- sum(s0 == 2 & s1 == 2) / sum(s0 == 2)
    Lab     Diagnostic Recovered    Truth       Gap
  lucas         E[M R]  1.000326 1.000000  0.000326
  lucas         sd(dc)  0.007562 0.007500  0.000062
    lrr         ac1(x)  0.977298 0.979000 -0.001702
    lrr   ac1(sigma^2)  0.977554 0.987000 -0.009446
    rbc     A_k by OLS  0.965276 0.965276  0.000000
    rbc     B_a by OLS  0.075372 0.075372  0.000000
   term ac1(r Vasicek)  0.976910 0.975310  0.001600
   term    mean(r CIR)  0.041399 0.045000 -0.003601
   term     min(r CIR)  0.011946 0.000000  0.011946
 regime            p11  0.968481 0.950000  0.018481
 regime            p22  0.792079 0.750000  0.042079

policy rule check: max |c - (g_k k + g_a a)| = 1.07e-14
Code
import numpy as np
import pandas as pd

lu = pd.read_csv("../data/mfsim-lucas.csv")
lr = pd.read_csv("../data/mfsim-lrr.csv")
ds = pd.read_csv("../data/mfsim-dsge.csv")
tm = pd.read_csv("../data/mfsim-term.csv")
rg = pd.read_csv("../data/mfsim-regime.csv")

def ac1(x):
    x = np.asarray(x)
    return np.corrcoef(x[1:], x[:-1])[0, 1]

# The RBC policy rule is deterministic, so OLS must return it exactly
X = np.column_stack([np.ones(len(ds) - 1), ds["k"][:-1], ds["a"][:-1]])
b = np.linalg.lstsq(X, ds["k"][1:], rcond=None)[0]

# Transition probabilities from the realised regime path
s0 = rg["state_true"].to_numpy()[:-1]
s1 = rg["state_true"].to_numpy()[1:]
p11 = ((s0 == 1) & (s1 == 1)).sum() / (s0 == 1).sum()
p22 = ((s0 == 2) & (s1 == 2)).sum() / (s0 == 2).sum()

rows = [
    ("lucas",  "E[M R]",         (lu["sdf"] * lu["ret"]).mean(), 1.0),
    ("lucas",  "sd(dc)",         lu["dc"].std(ddof=1),           lu["sigc_true"][0]),
    ("lrr",    "ac1(x)",         ac1(lr["x_true"]),              lr["rho_true"][0]),
    ("lrr",    "ac1(sigma^2)",   ac1(lr["sigma_true"]**2),       lr["nu_true"][0]),
    ("rbc",    "A_k by OLS",     b[1],                           ds["Ak_true"][0]),
    ("rbc",    "B_a by OLS",     b[2],                           0.075372),
    ("term",   "ac1(r Vasicek)", ac1(tm["r_vasicek"]),
                                 np.exp(-tm["vkappa_true"][0] / 12)),
    ("term",   "mean(r CIR)",    tm["r_cir"].mean(),             tm["ctheta_true"][0]),
    ("term",   "min(r CIR)",     tm["r_cir"].min(),              0.0),
    ("regime", "p11",            p11,                            rg["p11_true"][0]),
    ("regime", "p22",            p22,                            rg["p22_true"][0]),
]

hdr = f"{'Lab':>7} {'Diagnostic':>15} {'Recovered':>12} {'Truth':>12} {'Gap':>12}"
lines = [hdr]
for lab, name, rec, tru in rows:
    lines.append(f"{lab:>7} {name:>15} {rec:12.6f} {tru:12.6f} {rec-tru:12.6f}")
err = np.max(np.abs(ds["c"] - (ds["gk_true"][0]*ds["k"] + ds["ga_true"][0]*ds["a"])))
lines.append(f"\npolicy rule check: max |c - (g_k k + g_a a)| = {err:.2e}")

import sys; nchars = sys.stdout.write("\n".join(lines) + "\n"); sys.stdout.flush()
    Lab      Diagnostic    Recovered        Truth          Gap
  lucas          E[M R]     1.000326     1.000000     0.000326
  lucas          sd(dc)     0.007562     0.007500     0.000062
    lrr          ac1(x)     0.977298     0.979000    -0.001702
    lrr    ac1(sigma^2)     0.977554     0.987000    -0.009446
    rbc      A_k by OLS     0.965276     0.965276     0.000000
    rbc      B_a by OLS     0.075372     0.075372    -0.000000
   term  ac1(r Vasicek)     0.976910     0.975310     0.001600
   term     mean(r CIR)     0.041399     0.045000    -0.003601
   term      min(r CIR)     0.011946     0.000000     0.011946
 regime             p11     0.968481     0.950000     0.018481
 regime             p22     0.792079     0.750000     0.042079

policy rule check: max |c - (g_k k + g_a a)| = 1.07e-14
Code
quietly import delimited "../data/mfsim-lucas.csv", clear
generate double mr = sdf*ret
quietly summarize mr
scalar r1 = r(mean)
scalar t1 = 1
quietly summarize dc
scalar r2 = r(sd)
scalar t2 = sigc_true[1]

quietly import delimited "../data/mfsim-lrr.csv", clear
generate double lx = x_true[_n-1]
quietly correlate x_true lx
scalar r3 = r(rho)
scalar t3 = rho_true[1]
generate double s2 = sigma_true^2
generate double ls2 = s2[_n-1]
quietly correlate s2 ls2
scalar r4 = r(rho)
scalar t4 = nu_true[1]

* The RBC policy rule is deterministic, so OLS must return it exactly
quietly import delimited "../data/mfsim-dsge.csv", clear
generate double lk = k[_n-1]
generate double la = a[_n-1]
quietly regress k lk la
scalar r5 = _b[lk]
scalar r6 = _b[la]
scalar t5 = ak_true[1]
scalar t6 = 0.075372
generate double perr = abs(c - (gk_true*k + ga_true*a))
quietly summarize perr
scalar pmax = r(max)

quietly import delimited "../data/mfsim-term.csv", clear
generate double lrv = r_vasicek[_n-1]
quietly correlate r_vasicek lrv
scalar r7 = r(rho)
scalar t7 = exp(-vkappa_true[1]/12)
quietly summarize r_cir
scalar r8 = r(mean)
scalar r9 = r(min)
scalar t8 = ctheta_true[1]
scalar t9 = 0

* Transition probabilities from the realised regime path
quietly import delimited "../data/mfsim-regime.csv", clear
generate byte s0 = state_true[_n-1]
scalar t10 = p11_true[1]
scalar t11 = p22_true[1]
quietly count if s0 == 1 & state_true == 1
scalar n11 = r(N)
quietly count if s0 == 1 & !missing(state_true)
scalar n1 = r(N)
quietly count if s0 == 2 & state_true == 2
scalar n22 = r(N)
quietly count if s0 == 2 & !missing(state_true)
scalar n2 = r(N)
scalar r10 = n11/n1
scalar r11 = n22/n2

display "    Lab      Diagnostic    Recovered        Truth          Gap"
local labs `" "lucas" "lucas" "lrr" "lrr" "rbc" "rbc" "term" "term" "term" "regime" "regime" "'
local nms  `" "E[M R]" "sd(dc)" "ac1(x)" "ac1(sigma^2)" "A_k by OLS" "B_a by OLS" "ac1(r Vasicek)" "mean(r CIR)" "min(r CIR)" "p11" "p22" "'
forvalues j = 1/11 {
    local lb : word `j' of `labs'
    local nm : word `j' of `nms'
    display %7s "`lb'" " " %15s "`nm'" " " %12.6f r`j' " " %12.6f t`j' ///
            " " %12.6f (r`j' - t`j')
}
display ""
display "policy rule check: max |c - (g_k k + g_a a)| = " %8.2e pmax
(1 missing value generated)





(1 missing value generated)





(1 missing value generated)

(1 missing value generated)










(1 missing value generated)










(1 missing value generated)

    Lab      Diagnostic    Recovered        Truth          Gap



  5. }
  lucas          E[M R]     1.000326     1.000000     0.000326
  lucas          sd(dc)     0.007562     0.007500     0.000062
    lrr          ac1(x)     0.977298     0.979000    -0.001702
    lrr    ac1(sigma^2)     0.977554     0.987000    -0.009446
    rbc      A_k by OLS     0.965276     0.965276     0.000000
    rbc      B_a by OLS     0.075372     0.075372    -0.000000
   term  ac1(r Vasicek)     0.976910     0.975310     0.001600
   term     mean(r CIR)     0.041399     0.045000    -0.003601
   term      min(r CIR)     0.011946     0.000000     0.011946
 regime             p11     0.968481     0.950000     0.018481
 regime             p22     0.792079     0.750000     0.042079



policy rule check: max |c - (g_k k + g_a a)| =  1.1e-14

Part 2 — The Simulation Toolkit

Discretising persistent processes, simulating stochastic differential
equations, Monte Carlo error, and variance reduction.

Discretising a Persistent AR(1)

Almost every model in this deck contains a continuous state that follows an AR(1): technology in the RBC model, the long-run risk component \(x_t\), the short rate. To solve such a model on a computer you usually have to replace that continuous state with a finite Markov chain — a handful of grid points and a transition matrix.

The obvious question is how many points you need. The more interesting question, and the one that decides whether Part 3 works at all, is which method.

The long-run risk calibration has \(\rho = 0.979\). That is exactly where the textbook method stops working — and where the method almost nobody teaches first is still exact.

Two candidates:

  • Tauchen (1986) — put evenly spaced points on the grid, then integrate the normal density over the cell around each one. Intuitive, and the standard choice.
  • Rouwenhorst — forget the density; build the chain recursively from a two-state binomial so that its mean, variance and autocorrelation match the AR(1) by construction, for any \(N\) and any \(\rho\).

The process to discretise:

\[z_{t+1} = \rho \, z_t + \sigma \, \varepsilon_{t+1}, \qquad \varepsilon \sim N(0,1)\]

\[\sigma_z = \frac{\sigma}{\sqrt{1-\rho^2}}\]

Tauchen. Place \(N\) evenly spaced points on \([-m\sigma_z,\; m\sigma_z]\) with spacing \(w\), then assign each cell the normal probability of landing in it:

\[P_{ij} = \Phi\!\left( \frac{z_j + w/2 - \rho z_i}{\sigma} \right) - \Phi\!\left( \frac{z_j - w/2 - \rho z_i}{\sigma} \right)\]

with the two edge columns absorbing the tails.

Rouwenhorst. Set \(p = q = \dfrac{1+\rho}{2}\) and start from

\[P^{(2)} = \begin{pmatrix} p & 1-p \\ 1-p & p \end{pmatrix}\]

Then build \(P^{(n)}\) from \(P^{(n-1)}\) by

\[P^{(n)} = p \begin{pmatrix} P^{(n-1)} & 0 \\ 0 & 0 \end{pmatrix} + (1-p) \begin{pmatrix} 0 & P^{(n-1)} \\ 0 & 0 \end{pmatrix} + (1-p) \begin{pmatrix} 0 & 0 \\ P^{(n-1)} & 0 \end{pmatrix} + p \begin{pmatrix} 0 & 0 \\ 0 & P^{(n-1)} \end{pmatrix}\]

halving every interior row so each sums to one. The grid spans

\[\pm \, \psi = \pm \, \sigma_z \sqrt{N-1}\]

and it is a theorem, not an approximation, that the resulting chain reproduces \(\rho\) and \(\sigma_z\) exactly.

Tauchen & Rouwenhorst — Code

Both methods are deterministic functions of \((N, \rho, \sigma)\), so all three languages must return the same transition matrix. Here \(N = 9\), \(\rho = 0.9\), \(\sigma = 0.01\).

Code
tauchen <- function(N, rho, sigma, m = 3) {
  sz <- sigma / sqrt(1 - rho^2)
  z  <- seq(-m * sz, m * sz, length.out = N)
  w  <- z[2] - z[1]
  P  <- matrix(0, N, N)
  for (i in 1:N) {
    for (j in 1:N) {
      if (j == 1) {
        P[i, j] <- pnorm((z[1] + w/2 - rho * z[i]) / sigma)
      } else if (j == N) {
        P[i, j] <- 1 - pnorm((z[N] - w/2 - rho * z[i]) / sigma)
      } else {
        P[i, j] <- pnorm((z[j] + w/2 - rho * z[i]) / sigma) -
                   pnorm((z[j] - w/2 - rho * z[i]) / sigma)
      }
    }
  }
  list(z = z, P = P)
}

rouwenhorst <- function(N, rho, sigma) {
  p   <- (1 + rho) / 2
  sz  <- sigma / sqrt(1 - rho^2)
  psi <- sz * sqrt(N - 1)
  z   <- seq(-psi, psi, length.out = N)
  P   <- matrix(c(p, 1 - p, 1 - p, p), 2, 2, byrow = TRUE)
  if (N > 2) {
    for (n in 3:N) {
      Pn <- matrix(0, n, n)
      Pn[1:(n-1), 1:(n-1)] <- Pn[1:(n-1), 1:(n-1)] + p * P
      Pn[1:(n-1), 2:n]     <- Pn[1:(n-1), 2:n]     + (1 - p) * P
      Pn[2:n, 1:(n-1)]     <- Pn[2:n, 1:(n-1)]     + (1 - p) * P
      Pn[2:n, 2:n]         <- Pn[2:n, 2:n]         + p * P
      Pn[2:(n-1), ] <- Pn[2:(n-1), ] / 2
      P <- Pn
    }
  }
  list(z = z, P = P)
}

tt <- tauchen(9, 0.9, 0.01)
rr <- rouwenhorst(9, 0.9, 0.01)
N = 9, rho = 0.9, sigma = 0.01

tauchen      P[1,1] = 0.568306   P[5,5] = 0.610381   grid halfwidth = 0.068825
rouwenhorst  P[1,1] = 0.663420   P[5,5] = 0.693008   grid halfwidth = 0.064889

row sums (both must be 1): 1.000000  1.000000
Code
import numpy as np
from scipy.stats import norm

def tauchen(N, rho, sigma, m=3):
    sz = sigma / np.sqrt(1 - rho**2)
    z  = np.linspace(-m * sz, m * sz, N)
    w  = z[1] - z[0]
    P  = np.zeros((N, N))
    for i in range(N):
        for j in range(N):
            if j == 0:
                P[i, j] = norm.cdf((z[0] + w/2 - rho * z[i]) / sigma)
            elif j == N - 1:
                P[i, j] = 1 - norm.cdf((z[N-1] - w/2 - rho * z[i]) / sigma)
            else:
                P[i, j] = (norm.cdf((z[j] + w/2 - rho * z[i]) / sigma) -
                           norm.cdf((z[j] - w/2 - rho * z[i]) / sigma))
    return z, P

def rouwenhorst(N, rho, sigma):
    p   = (1 + rho) / 2
    sz  = sigma / np.sqrt(1 - rho**2)
    psi = sz * np.sqrt(N - 1)
    z   = np.linspace(-psi, psi, N)
    P   = np.array([[p, 1 - p], [1 - p, p]])
    for n in range(3, N + 1):
        Pn = np.zeros((n, n))
        Pn[:n-1, :n-1] += p * P
        Pn[:n-1, 1:n]  += (1 - p) * P
        Pn[1:n, :n-1]  += (1 - p) * P
        Pn[1:n, 1:n]   += p * P
        Pn[1:n-1, :]   /= 2
        P = Pn
    return z, P

zt, Pt = tauchen(9, 0.9, 0.01)
zr, Pr = rouwenhorst(9, 0.9, 0.01)

out = ("N = 9, rho = 0.9, sigma = 0.01\n\n"
       f"tauchen      P[1,1] = {Pt[0,0]:.6f}   P[5,5] = {Pt[4,4]:.6f}   "
       f"grid halfwidth = {zt.max():.6f}\n"
       f"rouwenhorst  P[1,1] = {Pr[0,0]:.6f}   P[5,5] = {Pr[4,4]:.6f}   "
       f"grid halfwidth = {zr.max():.6f}\n"
       f"\nrow sums (both must be 1): {np.abs(Pt.sum(1)).max():.6f}  "
       f"{np.abs(Pr.sum(1)).max():.6f}")
import sys; nchars = sys.stdout.write(out + "\n"); sys.stdout.flush()
N = 9, rho = 0.9, sigma = 0.01

tauchen      P[1,1] = 0.568306   P[5,5] = 0.610381   grid halfwidth = 0.068825
rouwenhorst  P[1,1] = 0.663420   P[5,5] = 0.693008   grid halfwidth = 0.064889

row sums (both must be 1): 1.000000  1.000000
Code
* Pure Stata matrices: a mata function definition in a chunk that must print
* silently breaks the render's output capture, so mata is kept out of here.
quietly {
    scalar N = 9
    scalar rho = 0.9
    scalar sigma = 0.01
    scalar m = 3
    scalar sz = sigma/sqrt(1 - rho^2)

    * Tauchen: evenly spaced grid, normal probability of each cell
    scalar w = 2*m*sz/(N - 1)
    matrix zt = J(1, 9, 0)
    forvalues i = 1/9 {
        matrix zt[1,`i'] = -m*sz + (`i' - 1)*w
    }
    matrix Pt = J(9, 9, 0)
    forvalues i = 1/9 {
        forvalues j = 1/9 {
            scalar zi = zt[1,`i']
            scalar zj = zt[1,`j']
            if (`j' == 1) {
                matrix Pt[`i',`j'] = normal((zj + w/2 - rho*zi)/sigma)
            }
            else if (`j' == 9) {
                matrix Pt[`i',`j'] = 1 - normal((zj - w/2 - rho*zi)/sigma)
            }
            else {
                matrix Pt[`i',`j'] = normal((zj + w/2 - rho*zi)/sigma) ///
                                   - normal((zj - w/2 - rho*zi)/sigma)
            }
        }
    }

    * Rouwenhorst: build the chain recursively from the two-state binomial
    scalar p = (1 + rho)/2
    scalar psi = sz*sqrt(N - 1)
    matrix zr = J(1, 9, 0)
    forvalues i = 1/9 {
        matrix zr[1,`i'] = -psi + (`i' - 1)*2*psi/(N - 1)
    }
    matrix Pr = (p, 1-p \ 1-p, p)
    forvalues n = 3/9 {
        local k = `n' - 1
        matrix Pn = J(`n', `n', 0)
        forvalues i = 1/`k' {
            forvalues j = 1/`k' {
                matrix Pn[`i',`j']     = Pn[`i',`j']     + p*Pr[`i',`j']
                matrix Pn[`i',`j'+1]   = Pn[`i',`j'+1]   + (1-p)*Pr[`i',`j']
                matrix Pn[`i'+1,`j']   = Pn[`i'+1,`j']   + (1-p)*Pr[`i',`j']
                matrix Pn[`i'+1,`j'+1] = Pn[`i'+1,`j'+1] + p*Pr[`i',`j']
            }
        }
        forvalues i = 2/`k' {
            forvalues j = 1/`n' {
                matrix Pn[`i',`j'] = Pn[`i',`j']/2
            }
        }
        matrix Pr = Pn
    }

    * Row sums, as a check that both are proper transition matrices
    matrix ones = J(9, 1, 1)
    matrix rst = Pt*ones
    matrix rsr = Pr*ones
    scalar mint = 1
    scalar minr = 1
    forvalues i = 1/9 {
        scalar mint = min(mint, rst[`i',1])
        scalar minr = min(minr, rsr[`i',1])
    }
}

display "N = 9, rho = 0.9, sigma = 0.01" _newline ///
  _newline ///
  "tauchen      P[1,1] = " %8.6f Pt[1,1] "   P[5,5] = " %8.6f Pt[5,5] ///
  "   grid halfwidth = " %8.6f zt[1,9] _newline ///
  "rouwenhorst  P[1,1] = " %8.6f Pr[1,1] "   P[5,5] = " %8.6f Pr[5,5] ///
  "   grid halfwidth = " %8.6f zr[1,9] _newline ///
  _newline ///
  "row sums (both must be 1): " %8.6f mint "  " %8.6f minr
N = 9, rho = 0.9, sigma = 0.01

tauchen      P[1,1] = 0.568306   P[5,5] = 0.610381   grid halfwidth = 0.068825
rouwenhorst  P[1,1] = 0.663420   P[5,5] = 0.693008   grid halfwidth = 0.064889

row sums (both must be 1): 1.000000  1.000000

Which Discretisation? Accuracy vs Persistence

reference depth — scrollable, read after class

For each \(\rho\), build a 9-state chain, compute its stationary standard deviation and first autocorrelation, and compare them with the AR(1) they are supposed to represent. The dashed line marks \(\rho = 0.979\) — the long-run risk calibration from Part 1.

Code
# Stationary distribution: the left eigenvector of P with eigenvalue 1
stat_dist <- function(P) {
  e <- eigen(t(P))
  v <- Re(e$vectors[, which.max(Re(e$values))])
  v / sum(v)
}

# Implied sd and first autocorrelation of a finite chain
chain_moments <- function(g) {
  pr <- stat_dist(g$P)
  m  <- sum(pr * g$z)
  v  <- sum(pr * (g$z - m)^2)
  ez <- 0
  for (i in seq_along(g$z)) ez <- ez + pr[i] * g$z[i] * sum(g$P[i, ] * g$z)
  c(sd = sqrt(v), ac1 = (ez - m^2) / v)
}

rhos <- seq(0.50, 0.995, length.out = 40)
acc  <- data.frame(rho = rhos, t_ac1 = NA, r_ac1 = NA, t_sd = NA, r_sd = NA)
for (i in seq_along(rhos)) {
  mt <- chain_moments(tauchen(9, rhos[i], 0.01))
  mr <- chain_moments(rouwenhorst(9, rhos[i], 0.01))
  true_sd <- 0.01 / sqrt(1 - rhos[i]^2)
  acc[i, 2:5] <- c(mt["ac1"], mr["ac1"], mt["sd"] / true_sd, mr["sd"] / true_sd)
}

Code
import matplotlib.pyplot as plt

def stat_dist(P):
    w, v = np.linalg.eig(P.T)
    x = np.real(v[:, np.argmax(np.real(w))])
    return x / x.sum()

def chain_moments(z, P):
    pr = stat_dist(P)
    m  = (pr * z).sum()
    v  = (pr * (z - m)**2).sum()
    ez = sum(pr[i] * z[i] * (P[i, :] * z).sum() for i in range(len(z)))
    return np.sqrt(v), (ez - m**2) / v

rhos = np.linspace(0.50, 0.995, 40)
t_ac1, r_ac1, t_sd, r_sd = [], [], [], []
for r in rhos:
    zt, Pt = tauchen(9, r, 0.01)
    zr, Pr = rouwenhorst(9, r, 0.01)
    st, at = chain_moments(zt, Pt)
    sr, ar = chain_moments(zr, Pr)
    true_sd = 0.01 / np.sqrt(1 - r**2)
    t_ac1.append(at); r_ac1.append(ar)
    t_sd.append(st / true_sd); r_sd.append(sr / true_sd)

fig, ax = plt.subplots(1, 2, figsize=(10, 4.2))

ax[0].plot([0.5, 1.0], [0.5, 1.0], color="#999999", lw=0.7)
ax[0].axvline(0.979, color="#999999", ls="--", lw=0.7)
ax[0].plot(rhos, t_ac1, color="#D85A30", lw=1.5)
ax[0].plot(rhos, r_ac1, color="#1D9E75", lw=1.5)
ax[0].text(0.52, 0.98, "Tauchen", color="#D85A30", fontsize=9)
ax[0].text(0.52, 0.92, "Rouwenhorst", color="#1D9E75", fontsize=9)
a1 = ax[0].set(xlim=(0.50, 1.00), ylim=(0.45, 1.02),
               xticks=np.arange(0.5, 1.01, 0.1), yticks=np.arange(0.5, 1.01, 0.1),
               xlabel="true rho", ylabel="chain ac1",
               title="Implied autocorrelation vs the truth")

ax[1].axhline(1, color="#999999", lw=0.7)
ax[1].axvline(0.979, color="#999999", ls="--", lw=0.7)
ax[1].plot(rhos, t_sd, color="#D85A30", lw=1.5)
ax[1].plot(rhos, r_sd, color="#1D9E75", lw=1.5)
ax[1].text(0.52, 1.28, "Tauchen sd is 24.5% too big at rho = 0.979",
           color="#4d4d4d", fontsize=8)
a2 = ax[1].set(xlim=(0.50, 1.00), ylim=(0.95, 1.35),
               xticks=np.arange(0.5, 1.01, 0.1), yticks=np.arange(1.0, 1.36, 0.1),
               xlabel="true rho", ylabel="chain sd / true sd",
               title="Implied sd, relative to the truth")

fig.tight_layout()
plt.show()

Code
mata:
real matrix tauchen_P(real scalar N, real scalar rho, real scalar sigma,
                      real scalar m, real colvector z)
{
    real scalar sz, w, i, j
    real matrix P
    sz = sigma/sqrt(1 - rho^2)
    z  = rangen(-m*sz, m*sz, N)
    w  = z[2] - z[1]
    P  = J(N, N, 0)
    for (i=1; i<=N; i++) for (j=1; j<=N; j++) {
        if (j==1)      P[i,j] = normal((z[1] + w/2 - rho*z[i])/sigma)
        else if (j==N) P[i,j] = 1 - normal((z[N] - w/2 - rho*z[i])/sigma)
        else           P[i,j] = normal((z[j] + w/2 - rho*z[i])/sigma) -
                                normal((z[j] - w/2 - rho*z[i])/sigma)
    }
    return(P)
}
real matrix rouwen_P(real scalar N, real scalar rho, real scalar sigma,
                     real colvector z)
{
    real scalar p, n, sz, psi
    real matrix P, Pn
    p   = (1 + rho)/2
    sz  = sigma/sqrt(1 - rho^2)
    psi = sz*sqrt(N - 1)
    z   = rangen(-psi, psi, N)
    P   = (p, 1-p \ 1-p, p)
    for (n=3; n<=N; n++) {
        Pn = J(n, n, 0)
        Pn[|1,1 \ n-1,n-1|] = Pn[|1,1 \ n-1,n-1|] + p*P
        Pn[|1,2 \ n-1,n|]   = Pn[|1,2 \ n-1,n|]   + (1-p)*P
        Pn[|2,1 \ n,n-1|]   = Pn[|2,1 \ n,n-1|]   + (1-p)*P
        Pn[|2,2 \ n,n|]     = Pn[|2,2 \ n,n|]     + p*P
        Pn[|2,1 \ n-1,n|]   = Pn[|2,1 \ n-1,n|]/2
        P = Pn
    }
    return(P)
}
// Stationary moments: iterate the distribution to convergence
real rowvector chain_moments(real colvector z, real matrix P)
{
    real scalar N, k, i, m, v, ez
    real rowvector pr
    N  = rows(P)
    pr = J(1, N, 1/N)
    for (k=1; k<=3000; k++) pr = pr*P
    m  = pr*z
    v  = pr*((z :- m):^2)
    ez = 0
    for (i=1; i<=N; i++) ez = ez + pr[i]*z[i]*(P[i,.]*z)
    return((sqrt(v), (ez - m^2)/v))
}

rhos = rangen(0.50, 0.995, 40)
RES  = J(40, 5, .)
for (k=1; k<=40; k++) {
    r  = rhos[k]
    zt = J(0,1,.) ; zr = J(0,1,.)
    Pt = tauchen_P(9, r, 0.01, 3, zt)
    Pr = rouwen_P(9, r, 0.01, zr)
    mt = chain_moments(zt, Pt)
    mr = chain_moments(zr, Pr)
    ts = 0.01/sqrt(1 - r^2)
    RES[k,.] = (r, mt[2], mr[2], mt[1]/ts, mr[1]/ts)
}
st_matrix("RES", RES)
end

quietly {
    clear
    svmat RES
    rename (RES1 RES2 RES3 RES4 RES5) (rho t_ac1 r_ac1 t_sd r_sd)
    generate diag = rho
    generate one = 1
}

twoway (line diag rho if diag <= 1.02, lcolor(gs10) lwidth(vthin))                        ///
       (line t_ac1 rho if t_ac1 <= 1.02, lcolor("216 90 48") lwidth(medium)) ///
       (line r_ac1 rho if r_ac1 <= 1.02, lcolor("29 158 117") lwidth(medium)), ///
  xline(0.979, lpattern(dash) lcolor(gs10) lwidth(vthin))                 ///
  xscale(range(0.50 1.00)) yscale(range(0.45 1.02))                       ///
  xlabel(0.5(0.1)1.0) ylabel(0.5(0.1)1.0)                                 ///
  xtitle("true rho") ytitle("chain ac1")                                  ///
  title("Implied autocorrelation vs the truth", size(medsmall))           ///
  text(0.98 0.52 "Tauchen", color("216 90 48") size(vsmall)               ///
       placement(e) justification(left))                                  ///
  text(0.92 0.52 "Rouwenhorst", color("29 158 117") size(vsmall)          ///
       placement(e) justification(left))                                  ///
  legend(off) graphregion(color(white)) name(qA, replace)

twoway (line one rho, lcolor(gs10) lwidth(vthin))                         ///
       (line t_sd rho if t_sd <= 1.35, lcolor("216 90 48") lwidth(medium)) ///
       (line r_sd rho if r_sd <= 1.35, lcolor("29 158 117") lwidth(medium)), ///
  xline(0.979, lpattern(dash) lcolor(gs10) lwidth(vthin))                 ///
  xscale(range(0.50 1.00)) yscale(range(0.95 1.35))                       ///
  xlabel(0.5(0.1)1.0) ylabel(1.0(0.1)1.3)                                 ///
  xtitle("true rho") ytitle("chain sd / true sd")                         ///
  title("Implied sd, relative to the truth", size(medsmall))              ///
  text(1.28 0.52 "Tauchen sd is 24.5% too big at rho = 0.979"             ///
       , color(gs6) size(vsmall) placement(e) justification(left))        ///
  legend(off) graphregion(color(white)) name(qB, replace)

graph combine qA qB, cols(2) graphregion(color(white)) ysize(4.2) xsize(10)
graph export "../plots/mfsim-disc-stata.png", replace width(1900)

Rouwenhorst is exact everywhere; Tauchen is not. The green lines sit on the truth at every persistence, because matching \(\rho\) and \(\sigma_z\) is built into the construction rather than approximated. Tauchen’s chain is 10% too volatile at \(\rho = 0.9\), 24.5% too volatile at \(\rho = 0.979\), and its autocorrelation overshoots to 0.9999 by \(\rho = 0.995\): a fixed grid of nine points spanning \(\pm 3\sigma_z\) simply cannot represent a process that spends most of its time far from the mean. Use Rouwenhorst for anything persistent, which in macro-finance is nearly everything.

Simulating in Continuous Time

Asset-pricing models are written in continuous time; computers work in discrete steps. Bridging that gap is a modelling decision, not a technicality, and it has three answers.

  • Exact simulation. For a few processes the transition law is known in closed form, so you can jump from \(t\) to \(t+\Delta\) with no error at all, however large the step. The Vasicek and CIR paths in Part 1 were built this way.
  • Euler–Maruyama. Replace \(dW\) with \(\sqrt{\Delta}\,z\). Always available, always biased.
  • Milstein. Add the term in the derivative of the diffusion coefficient. It buys nothing when the diffusion is constant, and helps when it is not.

Euler’s error is not only a matter of accuracy. Applied to CIR it puts positive probability on a negative interest rate — a state the true process can never reach. Some errors shrink with \(\Delta\); some break the model.

For the general scalar diffusion

\[dr_t = \mu(r_t)\,dt + \sigma(r_t)\,dW_t\]

Euler–Maruyama:

\[r_{t+\Delta} = r_t + \mu(r_t)\,\Delta + \sigma(r_t)\sqrt{\Delta}\;z, \qquad z \sim N(0,1)\]

Milstein adds one term from the Itô–Taylor expansion:

\[r_{t+\Delta} = r_t + \mu(r_t)\Delta + \sigma(r_t)\sqrt{\Delta}\,z + \tfrac{1}{2}\sigma(r_t)\sigma'(r_t)\,\Delta\left(z^2 - 1\right)\]

For CIR, \(\sigma(r) = \sigma\sqrt{r}\) and the exact one-step law is known:

\[\mathbb{E}\!\left[ r_{t+\Delta} \mid r_t \right] = \theta + \left( r_t - \theta \right) e^{-\kappa\Delta}\]

\[\mathbb{V}\!\left[ r_{t+\Delta} \mid r_t \right] = r_t \frac{\sigma^2 \left( e^{-\kappa\Delta} - e^{-2\kappa\Delta} \right)}{\kappa} + \theta \frac{\sigma^2 \left( 1 - e^{-\kappa\Delta} \right)^2}{2\kappa}\]

Euler’s conditional moments are \(r_t + \kappa(\theta - r_t)\Delta\) and \(\sigma^2 r_t \Delta\). Expanding the exponentials gives the local, one-step error orders:

\[\left| \text{bias in mean} \right| = O(\Delta^2), \qquad \left| \text{bias in sd} \right| = O(\Delta^{3/2})\]

which accumulate over \(T/\Delta\) steps to the familiar global weak order one.

Discretisation Bias — Code & Plot

The two curves are the analytic one-step errors of the Euler scheme against the exact CIR transition, evaluated at \(r_t = \theta + 2\sigma_{\text{stat}}\). They contain no simulation at all, so all three languages trace the same lines. Printed underneath is the one thing only simulation can show: how often Euler sends the rate below zero.

Code
kap <- 0.35; th <- 0.045; sig <- 0.06
sd_stat <- sqrt(sig^2 * th / (2 * kap))
rt      <- th + 2 * sd_stat

dts <- exp(seq(log(1/52), log(2), length.out = 40))

bias <- data.frame(dt = dts) |>
  mutate(
    mean_bias = abs((rt - th) * (exp(-kap * dt) - (1 - kap * dt))),
    var_exact = rt * sig^2 * (exp(-kap*dt) - exp(-2*kap*dt)) / kap +
                th * sig^2 * (1 - exp(-kap*dt))^2 / (2*kap),
    sd_bias   = abs(sqrt(var_exact) - sqrt(sig^2 * rt * dt)))

# How often does Euler leave the state space the true process never leaves?
set.seed(14159)
n <- 200000
euler_neg <- function(dt) {
  e <- numeric(n); e[1] <- th
  z <- rnorm(n)
  for (i in 2:n) {
    rp   <- max(e[i-1], 0)
    e[i] <- e[i-1] + kap * (th - rp) * dt + sig * sqrt(rp * dt) * z[i]
  }
  mean(e < 0)
}

Feller condition 2*kappa*theta > sigma^2:  0.0315 > 0.0036  -- the exact process never goes negative

Euler P(r < 0):  monthly 0.0000   annual 0.0002   two-yearly 0.0057
Code
import numpy as np
import matplotlib.pyplot as plt

kap, th, sig = 0.35, 0.045, 0.06
sd_stat = np.sqrt(sig**2 * th / (2 * kap))
rt = th + 2 * sd_stat

dts = np.exp(np.linspace(np.log(1/52), np.log(2), 40))
mean_bias = np.abs((rt - th) * (np.exp(-kap*dts) - (1 - kap*dts)))
var_exact = (rt * sig**2 * (np.exp(-kap*dts) - np.exp(-2*kap*dts)) / kap
             + th * sig**2 * (1 - np.exp(-kap*dts))**2 / (2*kap))
sd_bias = np.abs(np.sqrt(var_exact) - np.sqrt(sig**2 * rt * dts))

sm = np.log(mean_bias[-1]/mean_bias[0]) / np.log(dts[-1]/dts[0])
ss = np.log(sd_bias[-1]/sd_bias[0]) / np.log(dts[-1]/dts[0])

# How often does Euler leave the state space the true process never leaves?
rng = np.random.default_rng(14159)
n = 200000
def euler_neg(dt):
    e = np.empty(n); e[0] = th
    z = rng.normal(size=n)
    for i in range(1, n):
        rp = max(e[i-1], 0.0)
        e[i] = e[i-1] + kap*(th - rp)*dt + sig*np.sqrt(rp*dt)*z[i]
    return (e < 0).mean()
pn = [euler_neg(1/12), euler_neg(1.0), euler_neg(2.0)]

fig, ax = plt.subplots(figsize=(8, 4.4))
ax.plot(dts, mean_bias, color="#185FA5", lw=1.6)
ax.plot(dts, sd_bias,   color="#D85A30", lw=1.6)
ax.text(0.022, 3e-3, f"sd bias, slope {ss:.2f}",   color="#D85A30", fontsize=9)
ax.text(0.022, 8e-4, f"mean bias, slope {sm:.2f}", color="#185FA5", fontsize=9)
ax.text(0.022, 2e-7,
        f"Euler P(r<0): monthly {pn[0]:.4f}  annual {pn[1]:.4f}  2-yearly {pn[2]:.4f}",
        color="#4d4d4d", fontsize=8)
axopts = ax.set(xscale="log", yscale="log", xlim=(0.019, 2), ylim=(1e-7, 1e-2),
                xlabel="step size (years)", ylabel="absolute error",
                title="Euler one-step error against the exact CIR transition")
ax.set_xticks([0.02, 0.05, 0.1, 0.25, 0.5, 1, 2])
ax.set_xticklabels(["0.02","0.05","0.1","0.25","0.5","1","2"])
plt.show()

Code
quietly {
    clear
    set obs 40
    scalar kap = 0.35
    scalar th  = 0.045
    scalar sig = 0.06
    scalar sdst = sqrt(sig^2*th/(2*kap))
    scalar rt = th + 2*sdst

    generate double dt = exp(ln(1/52) + (_n-1)*(ln(2)-ln(1/52))/39)
    generate double mean_bias = abs((rt-th)*(exp(-kap*dt) - (1 - kap*dt)))
    generate double var_exact = rt*sig^2*(exp(-kap*dt)-exp(-2*kap*dt))/kap ///
                              + th*sig^2*(1-exp(-kap*dt))^2/(2*kap)
    generate double sd_bias = abs(sqrt(var_exact) - sqrt(sig^2*rt*dt))
}

twoway (line mean_bias dt, lcolor("24 95 165") lwidth(medium))            ///
       (line sd_bias dt, lcolor("216 90 48") lwidth(medium)),             ///
  xscale(log range(0.019 2)) yscale(log range(0.0000001 0.01))            ///
  xlabel(0.02 0.05 0.1 0.25 0.5 1 2)                                      ///
  ylabel(0.0000001 0.000001 0.00001 0.0001 0.001 0.01, format(%9.0e))     ///
  xtitle("step size (years)") ytitle("absolute error")                    ///
  title("Euler one-step error against the exact CIR transition",          ///
        size(medsmall))                                                   ///
  text(0.003 0.022 "sd bias, slope 1.44", color("216 90 48")              ///
       size(vsmall) placement(e) justification(left))                     ///
  text(0.0008 0.022 "mean bias, slope 1.95", color("24 95 165")           ///
       size(vsmall) placement(e) justification(left))                     ///
  legend(off) graphregion(color(white))
graph export "../plots/mfsim-sde-stata.png", replace width(1600)

Monte Carlo Error, Burn-in & Variance Reduction

A simulated answer is an estimate, and it comes with a standard error you are obliged to report. Three practical consequences run through the rest of the deck.

  • The \(1/\sqrt{B}\) wall. Halving the Monte Carlo error costs four times the paths. Beyond a point, buying accuracy with raw draws stops being sensible and you change the estimator instead.
  • Burn-in. A simulation started away from the ergodic distribution carries its initial condition for a while. Every simulated laboratory in this deck discards a burn-in — 500 to 600 draws — and the DGP script says how many.
  • Variance reduction. Three cheap tricks routinely buy what a hundredfold increase in paths would: exploit symmetry, exploit something whose answer you already know, or stop drawing at random altogether.

For an estimator \(\hat\mu_B\) built from \(B\) independent draws:

\[\mathrm{se}\!\left( \hat\mu_B \right) = \frac{\sigma}{\sqrt{B}}\]

Antithetic variates. Pair each draw \(z\) with \(-z\) and average:

\[\hat\mu^{\text{anti}} = \frac{1}{2}\left( \bar{f(z)} + \bar{f(-z)} \right), \qquad \mathbb{V} = \frac{\sigma^2}{B}\left( 1 + \mathrm{Corr}\!\left( f(z), f(-z) \right) \right)\]

The correlation is negative whenever \(f\) is monotone, so the variance falls.

Control variates. Take \(Y\) with a known mean \(\mathbb{E}[Y]\) and correct with it:

\[\hat\mu^{\text{cv}} = \bar{X} - b \left( \bar{Y} - \mathbb{E}[Y] \right), \qquad b^\star = \frac{\mathrm{Cov}(X, Y)}{\mathbb{V}(Y)}\]

\[\mathbb{V}\!\left( \hat\mu^{\text{cv}} \right) = \frac{\sigma_X^2}{B}\left( 1 - \rho^2_{XY} \right)\]

Quasi-Monte Carlo. Replace pseudo-random draws by a low-discrepancy (Sobol) net. The error becomes \(O\!\left( (\log B)^d / B \right)\) rather than \(O(B^{-1/2})\); a random shift restores an honest variance estimate.

Variance Reduction — Code & Results

reference depth — scrollable, read after class

A genuine macro-finance target: the price of a five-year zero-coupon bond in the Vasicek model,

\[P(0,T) = \mathbb{E}\!\left[ \exp\!\left( -\int_0^T r_s \, ds \right) \right]\]

which has a closed form, so every estimator can be scored. The control variate is the integral itself, whose mean is known analytically:

\[\mathbb{E}\!\left[ \int_0^T r_s \, ds \right] = \theta T + \left( r_0 - \theta \right) \frac{1 - e^{-\kappa T}}{\kappa}\]

Each method uses 2000 paths, repeated 50 times, so the spread across runs is the estimator’s own standard error.

Code
kap <- 0.30; th <- 0.045; sig <- 0.015; r0 <- 0.045
Tm  <- 5; m <- 60; dt <- Tm / m
e1  <- exp(-kap * dt)
s1  <- sig * sqrt((1 - exp(-2 * kap * dt)) / (2 * kap))

B    <- (1 - exp(-kap * Tm)) / kap
Aa   <- (B - Tm) * (kap^2 * th - sig^2 / 2) / kap^2 - sig^2 * B^2 / (4 * kap)
truth <- exp(Aa - B * r0)
Eint  <- th * Tm + (r0 - th) * (1 - exp(-kap * Tm)) / kap

# One vectorised sweep: every path advances together, step by step
sim_int <- function(Z) {
  r <- rep(r0, nrow(Z)); s <- rep(0, nrow(Z))
  for (k in 1:ncol(Z)) {
    r <- th + (r - th) * e1 + s1 * Z[, k]
    s <- s + r * dt
  }
  s
}

set.seed(14159)
M <- 2000; R <- 50
Us  <- sobol(M, m)                       # one Sobol net, shifted each run
res <- matrix(NA, R, 4)
for (rep in 1:R) {
  Z <- matrix(rnorm(M * m), M, m)
  I <- sim_int(Z); y <- exp(-I)
  res[rep, 1] <- mean(y)                                   # plain
  h  <- M / 2; Zh <- Z[1:h, , drop = FALSE]
  res[rep, 2] <- mean(c(exp(-sim_int(Zh)), exp(-sim_int(-Zh))))   # antithetic
  b  <- cov(y, I) / var(I)
  res[rep, 3] <- mean(y - b * (I - Eint))                  # control variate
  sh <- matrix(runif(m), M, m, byrow = TRUE)               # random shift
  Zq <- qnorm(pmin(pmax((Us + sh) %% 1, 1e-12), 1 - 1e-12))
  res[rep, 4] <- mean(exp(-sim_int(Zq)))                   # quasi-Monte Carlo
}
closed-form P(0,5) = 0.79991940     M = 2000 paths, R = 50 runs

          Method        Bias          SE   VarRatio
        plain MC    1.90e-05    1.10e-03        1.0
      antithetic    3.21e-05    6.57e-05      280.9
 control variate    2.22e-05    5.26e-05      438.1
       Sobol QMC    1.01e-05    9.73e-05      128.2
Code
import numpy as np
from scipy.stats import qmc, norm

kap, th, sig, r0 = 0.30, 0.045, 0.015, 0.045
Tm, m = 5, 60
dt = Tm / m
e1 = np.exp(-kap * dt)
s1 = sig * np.sqrt((1 - np.exp(-2 * kap * dt)) / (2 * kap))

Bv    = (1 - np.exp(-kap * Tm)) / kap
Aa    = (Bv - Tm) * (kap**2 * th - sig**2 / 2) / kap**2 - sig**2 * Bv**2 / (4*kap)
truth = np.exp(Aa - Bv * r0)
Eint  = th * Tm + (r0 - th) * (1 - np.exp(-kap * Tm)) / kap

# One vectorised sweep: every path advances together, step by step
def sim_int(Z):
    r = np.full(Z.shape[0], r0)
    s = np.zeros(Z.shape[0])
    for k in range(Z.shape[1]):
        r = th + (r - th) * e1 + s1 * Z[:, k]
        s = s + r * dt
    return s

rng = np.random.default_rng(14159)
M, R = 2000, 50
Us  = qmc.Sobol(d=m, scramble=False).random(M)      # one Sobol net, shifted each run
res = np.zeros((R, 4))
for rep in range(R):
    Z = rng.normal(size=(M, m))
    I = sim_int(Z); y = np.exp(-I)
    res[rep, 0] = y.mean()
    h  = M // 2; Zh = Z[:h, :]
    res[rep, 1] = np.concatenate([np.exp(-sim_int(Zh)), np.exp(-sim_int(-Zh))]).mean()
    b  = np.cov(y, I, ddof=1)[0, 1] / I.var(ddof=1)
    res[rep, 2] = (y - b * (I - Eint)).mean()
    sh = rng.random(m)                               # random shift
    Zq = norm.ppf(np.clip((Us + sh) % 1, 1e-12, 1 - 1e-12))
    res[rep, 3] = np.exp(-sim_int(Zq)).mean()

nm = ["plain MC", "antithetic", "control variate", "Sobol QMC"]
v1 = res[:, 0].var(ddof=1)
lines = [f"closed-form P(0,5) = {truth:.8f}     M = 2000 paths, R = 50 runs", "",
         f"{'Method':>16} {'Bias':>11} {'SE':>11} {'VarRatio':>10}"]
for j, nmj in enumerate(nm):
    lines.append(f"{nmj:>16} {res[:,j].mean()-truth:11.2e} "
                 f"{res[:,j].std(ddof=1):11.2e} {v1/res[:,j].var(ddof=1):10.1f}")
import sys; nchars = sys.stdout.write("\n".join(lines) + "\n"); sys.stdout.flush()
closed-form P(0,5) = 0.79991940     M = 2000 paths, R = 50 runs

          Method        Bias          SE   VarRatio
        plain MC    2.47e-04    1.15e-03        1.0
      antithetic    2.44e-05    7.25e-05      253.0
 control variate    2.51e-05    4.56e-05      638.2
       Sobol QMC    1.32e-05    7.50e-05      236.2
Code
quietly {
    scalar kap = 0.30
    scalar th  = 0.045
    scalar sig = 0.015
    scalar r0  = 0.045
    scalar Tm  = 5
    scalar m   = 60
    scalar dt  = Tm/m
    scalar e1  = exp(-kap*dt)
    scalar s1  = sig*sqrt((1 - exp(-2*kap*dt))/(2*kap))

    scalar Bv = (1 - exp(-kap*Tm))/kap
    scalar Aa = (Bv - Tm)*(kap^2*th - sig^2/2)/kap^2 - sig^2*Bv^2/(4*kap)
    scalar truth = exp(Aa - Bv*r0)
    scalar Eint  = th*Tm + (r0 - th)*(1 - exp(-kap*Tm))/kap

    clear
    set seed 14159
    set obs 2000
    generate double y  = .
    generate double ya = .
    generate double yc = .
    generate double ii = .

    tempname pf
    tempfile runs
    postfile `pf' m1 m2 m3 using `runs'

    forvalues rep = 1/50 {
        * plain and antithetic share one set of shocks; the integral is the
        * control variate, and its mean is known in closed form
        replace ii = 0
        generate double r  = r0
        generate double ra = r0
        generate double ia = 0
        forvalues k = 1/60 {
            generate double z = rnormal()
            replace r  = th + (r  - th)*e1 + s1*z
            replace ra = th + (ra - th)*e1 - s1*z
            replace ii = ii + r*dt
            replace ia = ia + ra*dt
            drop z
        }
        replace y  = exp(-ii)
        replace ya = exp(-ia)
        summarize ii, meanonly
        scalar mi = r(mean)
        correlate y ii, covariance
        scalar bb = r(cov_12)/r(Var_2)
        replace yc = y - bb*(ii - Eint)

        summarize y, meanonly
        scalar s_plain = r(mean)
        generate double ymid = (y + ya)/2
        summarize ymid, meanonly
        scalar s_anti = r(mean)
        summarize yc, meanonly
        scalar s_cv = r(mean)
        post `pf' (s_plain) (s_anti) (s_cv)
        drop r ra ia ymid
    }
    postclose `pf'

    use `runs', clear
    summarize m1
    scalar b1 = r(mean) - truth
    scalar e_1 = r(sd)
    summarize m2
    scalar b2 = r(mean) - truth
    scalar e_2 = r(sd)
    summarize m3
    scalar b3 = r(mean) - truth
    scalar e_3 = r(sd)
}

display "closed-form P(0,5) = " %10.8f truth ///
  "     M = 2000 paths, R = 50 runs" _newline ///
  _newline ///
  "          Method        Bias          SE   VarRatio" _newline ///
  "        plain MC  " %10.2e b1 "  " %10.2e e_1 "  " %9.1f 1 _newline ///
  "      antithetic  " %10.2e b2 "  " %10.2e e_2 "  " %9.1f (e_1/e_2)^2 _newline ///
  " control variate  " %10.2e b3 "  " %10.2e e_3 "  " %9.1f (e_1/e_3)^2
closed-form P(0,5) = 0.79991940     M = 2000 paths, R = 50 runs

          Method        Bias          SE   VarRatio
        plain MC   -2.44e-05    1.25e-03        1.0
      antithetic    2.59e-05    5.24e-05      573.4
 control variate    2.42e-05    5.30e-05      561.7

Stata has no Sobol generator — neither built in nor on SSC — so the quasi-Monte Carlo row has no Stata counterpart. The other three methods are implemented in full.

Part 3 — Consumption-Based Asset Pricing

The Lucas tree, Epstein-Zin preferences, long-run risk, habit formation,
rare disasters, and a horse race against the data.

The Lucas Tree

The simplest complete asset-pricing model there is. One representative consumer, one tree, and the tree’s fruit is aggregate consumption — so there is nothing to save and nothing to invest. In equilibrium the consumer eats the endowment and prices adjust until she is content to do so.

That sounds too simple to be interesting. It is exactly the point: with i.i.d. lognormal growth and CRRA preferences every quantity is closed form, so when the model fails we know it is the economics failing, not the numerics.

The consumer maximises

\[\mathbb{E}_0 \sum_{t=0}^{\infty} \beta^t \frac{C_t^{1-\gamma}}{1-\gamma}\]

giving the discount factor and the pricing equation

\[M_{t+1} = \beta \left( \frac{C_{t+1}}{C_t} \right)^{-\gamma}, \qquad P_t = \mathbb{E}_t \left[ M_{t+1} \left( P_{t+1} + D_{t+1} \right) \right]\]

With i.i.d. lognormal growth the price-dividend ratio is a constant:

\[\frac{P}{D} = \frac{k}{1-k}, \qquad k = \beta \, \mathbb{E}\!\left[ e^{-\gamma \Delta c + \Delta d} \right]\]

\[k = \beta \exp\!\left( -\gamma\mu_c + \mu_d + \tfrac{1}{2}\!\left( \gamma^2\sigma_c^2 + \sigma_d^2 \right) - \gamma\rho\sigma_c\sigma_d \right)\]

The gross return and the risk-free rate follow:

\[R_{t+1} = \frac{1 + P/D}{P/D} \, e^{\Delta d_{t+1}}, \qquad R^f = \left[ \beta \, e^{-\gamma\mu_c + \frac{1}{2}\gamma^2\sigma_c^2} \right]^{-1}\]

And — the equation the whole part turns on — the equity premium:

\[\mathbb{E}\!\left[ R \right] - R^f \;\approx\; \gamma \, \rho \, \sigma_c \, \sigma_d\]

Three small numbers multiplied together, scaled by \(\gamma\). Nothing else is available to make it large.

value source
\(\beta\) 0.99 per quarter conventional; implies a 4% annual discount rate
\(\gamma\) 2 the range experimental evidence supports
\(\mu_c\) 0.005 2% per year, matching the 2.08% measured in Part 1
\(\sigma_c\) 0.0075 1.5% per year, the smoother pre-COVID reading
\(\mu_d\) 0.005 dividends grow with consumption
\(\sigma_d\) 0.03 6% per year — dividends are four times more volatile
\(\rho\) 0.20 generous: the measured value in Part 1 is 0.061

Every one of these is chosen to give the model its best shot. The correlation in particular is set more than three times the value in the data.

Lucas Tree — Code: Simulate and Price

Reading ../data/mfsim-lucas.csv, written once by mfsim-data.R — so all three tabs price the identical 3000-quarter path.

Code
lu <- read.csv("../data/mfsim-lucas.csv")

# Everything below is already implied by the parameters; the simulated path is
# used only to confirm the closed forms and to show the Euler equation holds.
beta <- lu$beta_true[1]; gamma <- lu$gamma_true[1]

pd    <- lu$pd[1]
rf    <- lu$rf[1]
ER    <- mean(lu$ret)
prem  <- ER - rf
euler <- mean(lu$sdf * lu$ret)

# The same premium from the covariance formula, as a cross-check
prem_cov <- -cov(lu$sdf, lu$ret) / mean(lu$sdf)
Lucas tree, 3000 quarters, beta = 0.99, gamma = 2

  price-dividend ratio           68.0985
  risk-free rate                  7.9751 % per year
  E[R]                            8.0838 % per year
  equity premium                  0.0286 % per year
  same, from -cov(M,R)/E[M]       0.0399 % per year

  Euler equation E[M R]        1.000326  (must be 1)
Code
import numpy as np
import pandas as pd

lu = pd.read_csv("../data/mfsim-lucas.csv")

# Everything below is already implied by the parameters; the simulated path is
# used only to confirm the closed forms and to show the Euler equation holds.
beta, gamma = lu["beta_true"][0], lu["gamma_true"][0]

pd_ = lu["pd"][0]
rf  = lu["rf"][0]
ER  = lu["ret"].mean()
prem = ER - rf
euler = (lu["sdf"] * lu["ret"]).mean()

# The same premium from the covariance formula, as a cross-check
prem_cov = -np.cov(lu["sdf"], lu["ret"], ddof=1)[0, 1] / lu["sdf"].mean()

out = (f"Lucas tree, {len(lu)} quarters, beta = {beta:.2f}, gamma = {gamma:.0f}\n\n"
       f"  price-dividend ratio        {pd_:10.4f}\n"
       f"  risk-free rate              {400*np.log(rf):10.4f} % per year\n"
       f"  E[R]                        {400*(ER-1):10.4f} % per year\n"
       f"  equity premium              {400*prem:10.4f} % per year\n"
       f"  same, from -cov(M,R)/E[M]   {400*prem_cov:10.4f} % per year\n"
       f"\n  Euler equation E[M R]      {euler:10.6f}  (must be 1)\n")
import sys; nchars = sys.stdout.write(out); sys.stdout.flush()
Lucas tree, 3000 quarters, beta = 0.99, gamma = 2

  price-dividend ratio           68.0985
  risk-free rate                  7.9751 % per year
  E[R]                            8.0838 % per year
  equity premium                  0.0286 % per year
  same, from -cov(M,R)/E[M]       0.0399 % per year

  Euler equation E[M R]        1.000326  (must be 1)
Code
quietly {
    import delimited "../data/mfsim-lucas.csv", clear
    scalar beta  = beta_true[1]
    scalar gamma = gamma_true[1]
    scalar pd    = pd[1]
    scalar rf    = rf[1]
    scalar nq    = _N

    summarize ret, meanonly
    scalar ER = r(mean)
    scalar prem = ER - rf

    generate double mr = sdf*ret
    summarize mr, meanonly
    scalar euler = r(mean)

    * The same premium from the covariance formula, as a cross-check
    correlate sdf ret, covariance
    scalar cmr = r(cov_12)
    summarize sdf, meanonly
    scalar prem_cov = -cmr/r(mean)
}

display "Lucas tree, " %4.0f nq " quarters, beta = " %4.2f beta ///
  ", gamma = " %2.0f gamma _newline ///
  _newline ///
  "  price-dividend ratio        " %10.4f pd _newline ///
  "  risk-free rate              " %10.4f 400*ln(rf) " % per year" _newline ///
  "  E[R]                        " %10.4f 400*(ER-1) " % per year" _newline ///
  "  equity premium              " %10.4f 400*prem " % per year" _newline ///
  "  same, from -cov(M,R)/E[M]   " %10.4f 400*prem_cov " % per year" _newline ///
  _newline ///
  "  Euler equation E[M R]      " %10.6f euler "  (must be 1)"
Lucas tree, 3000 quarters, beta = 0.99, gamma =  2

  price-dividend ratio           68.0985
  risk-free rate                  7.9751 % per year
  E[R]                            8.0838 % per year
  equity premium                  0.0286 % per year
  same, from -cov(M,R)/E[M]       0.0399 % per year

  Euler equation E[M R]        1.000326  (must be 1)

Lucas Tree — Results: the Premium It Cannot Produce

model (\(\gamma = 2\)) US data, 1947Q2–2026Q2
equity premium, population 0.036% per year 8.61% per year
equity premium, simulated sample 0.029% per year
risk-free rate 7.98% per year 1.42% per year (real)
price-dividend ratio 68.10

The population premium is \(\gamma\rho\sigma_c\sigma_d = 0.036\%\); the 3000-quarter simulated sample gives 0.029%. The gap between them is Monte Carlo error, and it is larger than the premium itself — a warning worth carrying into Part 7.

The model premium is 0.036% a year against 8.61% in the data — smaller by a factor of 239, more than two orders of magnitude. And it produces that failure while simultaneously setting a risk-free rate almost six times too high.

This is not a calibration accident. Every parameter on the previous slide was chosen to flatter the model, including a consumption-return correlation of 0.20 when the data say 0.061.

Look at the two panels together — that is the whole argument.

The premium is linear in risk aversion with a tiny slope, because it is \(\gamma\rho\sigma_c\sigma_d\) and the last three terms multiply to almost nothing. To reach the data’s 8.61% you need \(\gamma = 478\).

The risk-free rate is quadratic in risk aversion, and rises far faster. At \(\gamma = 478\) the same model implies a risk-free rate of \(-1613\%\) per year. Long before that it has passed through absurdity: at \(\gamma = 50\) the premium is still only 0.9% while the risk-free rate is already 76%.

There is no value of \(\gamma\) that fixes both. The failure is structural, not numerical: one parameter is being asked to set the price of risk and the level of rates, and those two jobs pull in opposite directions.

The rest of Part 3 is a catalogue of ways out. Epstein–Zin unties the two jobs by giving the model a second preference parameter. Long-run risk and habit make consumption riskier than it looks. Disasters argue the sample never showed us the risk that was actually being priced.

Epstein–Zin — One Extra Parameter

The previous slide ended on a structural complaint: one parameter is doing two jobs. Under CRRA, \(\gamma\) measures both how much you dislike risk across states and how much you dislike variation across time. They are forced to be reciprocals:

\[\psi = \frac{1}{\gamma}\]

There is no reason for that. A consumer might hate gambles yet be happy to substitute consumption between this year and next. Epstein and Zin (1989) build a recursive utility that separates the two, and it costs exactly one extra parameter.

The price of that parameter is structural, not just numerical: utility becomes recursive, so the continuation value enters the pricing kernel. That is the whole novelty — and it is the door long-run risk walks through two slides from now.

It also introduces something CRRA does not have: a preference over when uncertainty is resolved. That turns out to matter enormously — but, as the tabs opposite show, only once consumption growth is predictable.

Everything is closed form here, so both code tabs print the same numbers to every digit. The two rows to compare are the CRRA benchmark \(\psi = 1/\gamma\) and anything else.

Code
beta <- 0.99; muc <- 0.005; sc <- 0.0075; sdd <- 0.03; rho <- 0.20

ez <- function(gamma, psi) {
  theta <- (1 - gamma) / (1 - 1 / psi)
  # wealth-consumption ratio phi solves phi/(phi-1) = ...
  ratio <- (1 / beta) *
    exp(-((1 - gamma) * muc + 0.5 * (1 - gamma)^2 * sc^2) / theta)
  A  <- theta * log(beta) + (theta - 1) * log(ratio)
  rf <- exp(-A + gamma * muc - 0.5 * gamma^2 * sc^2)
  c(premium = 400 * gamma * rho * sc * sdd, rf = 400 * log(rf))
}

psis <- c(0.10, 0.50, 1.50, 2.00)

gamma =  2    (CRRA is psi = 1/gamma = 0.50)
     psi   premium %/yr      rf %/yr
    0.10         0.0360      23.8851
    0.50         0.0360       7.9751  <- CRRA
    1.50         0.0360       5.3235
    2.00         0.0360       4.9920

gamma = 10    (CRRA is psi = 1/gamma = 0.10)
     psi   premium %/yr      rf %/yr
    0.10         0.1800      22.8951  <- CRRA
    0.50         0.1800       7.7051
    1.50         0.1800       5.1735
    2.00         0.1800       4.8570
Code
import numpy as np

beta, muc, sc, sdd, rho = 0.99, 0.005, 0.0075, 0.03, 0.20

def ez(gamma, psi):
    theta = (1 - gamma) / (1 - 1 / psi)
    # wealth-consumption ratio phi solves phi/(phi-1) = ...
    ratio = (1 / beta) * np.exp(-((1 - gamma) * muc
                                  + 0.5 * (1 - gamma)**2 * sc**2) / theta)
    A  = theta * np.log(beta) + (theta - 1) * np.log(ratio)
    rf = np.exp(-A + gamma * muc - 0.5 * gamma**2 * sc**2)
    return 400 * gamma * rho * sc * sdd, 400 * np.log(rf)

psis = [0.10, 0.50, 1.50, 2.00]
out = ""
for g in (2, 10):
    out += f"\ngamma = {g:2d}    (CRRA is psi = 1/gamma = {1/g:.2f})\n"
    out += f"{'psi':>8} {'premium %/yr':>14} {'rf %/yr':>12}\n"
    for ps in psis:
        pr, rf = ez(g, ps)
        tag = "  <- CRRA" if abs(ps - 1/g) < 1e-9 else ""
        out += f"{ps:8.2f} {pr:14.4f} {rf:12.4f}{tag}\n"
import sys; nchars = sys.stdout.write(out); sys.stdout.flush()

gamma =  2    (CRRA is psi = 1/gamma = 0.50)
     psi   premium %/yr      rf %/yr
    0.10         0.0360      23.8851
    0.50         0.0360       7.9751  <- CRRA
    1.50         0.0360       5.3235
    2.00         0.0360       4.9920

gamma = 10    (CRRA is psi = 1/gamma = 0.10)
     psi   premium %/yr      rf %/yr
    0.10         0.1800      22.8951  <- CRRA
    0.50         0.1800       7.7051
    1.50         0.1800       5.1735
    2.00         0.1800       4.8570

Recursive utility has no Stata implementation — nothing built in, nothing on SSC — so Stata rejoins the deck at the long-run risk slides, where the object being solved is a finite Markov chain it handles natively.

Read down the premium column: it does not move. At \(\gamma = 10\) the premium is 0.1800% whether \(\psi\) is 0.1 or 2.0, while the risk-free rate falls from 22.90% to 4.86%. Epstein–Zin solves the risk-free rate puzzle outright and contributes nothing whatsoever to the equity premium puzzle.

That is not a disappointment — it is a signpost. The kernel’s slope on consumption growth is still \(-\gamma\) because, with i.i.d. growth, next period’s continuation value carries no information. Give consumption growth a persistent component and that stops being true.

Long-Run Risks

Bansal and Yaron (2004) take the signpost from the last slide seriously. Suppose consumption growth contains a small, highly persistent, barely visible component:

\[\Delta c_{t+1} = \mu + x_t + \sigma \eta_{t+1}, \qquad x_{t+1} = \rho \, x_t + \varphi_e \sigma e_{t+1}\]

\(x_t\) explains almost none of the one-period variance of consumption growth — it is nearly undetectable in a century of data. But with \(\rho = 0.979\) monthly, a shock to \(x\) moves expected consumption for decades.

An Epstein–Zin consumer with \(\psi > 1\) cares about that, because bad news about the distant future lowers her continuation value today. Assets that fall when \(x\) falls are therefore genuinely risky, and command a premium — even though their correlation with current consumption growth is negligible, exactly as measured in Part 1.

The full system, monthly, with a levered dividend claim:

\[\Delta c_{t+1} = \mu + x_t + \sigma \eta_{t+1}\]

\[x_{t+1} = \rho \, x_t + \varphi_e \, \sigma \, e_{t+1}\]

\[\Delta d_{t+1} = \mu_d + \phi \, x_t + \varphi_d \, \sigma \, u_{t+1}\]

with \(\eta, e, u\) independent. Instead of log-linearising, we discretise \(x\) with Rouwenhorst — the method Part 2 showed is exact at this persistence — and solve two fixed points on the resulting 9-state chain.

Utility-consumption ratio \(v_i = V_i / C_i\):

\[v_i = \left[ (1-\beta) + \beta \left( e^{(1-\gamma)(\mu + x_i) + \frac{1}{2}(1-\gamma)^2\sigma^2} \sum_j P_{ij} \, v_j^{1-\gamma} \right)^{\frac{1-1/\psi}{1-\gamma}} \right]^{\frac{1}{1-1/\psi}}\]

The kernel then takes the convenient form \(M_{t+1} = \beta \, G^{-\gamma} \, v_j^{1/\psi-\gamma} X_i^{\gamma-1/\psi}\), where \(G\) is consumption growth and \(X_i\) the certainty equivalent. The price-dividend ratio is linear in itself:

\[p_i = c_i \sum_j P_{ij} \, w_j \left( 1 + p_j \right), \qquad w_j = v_j^{1/\psi-\gamma}\]

so it is solved exactly as \(p = (I - A)^{-1} A \mathbf{1}\) with \(A_{ij} = c_i P_{ij} w_j\) — no iteration, no approximation.

Calibration (monthly): \(\gamma = 10\), \(\psi = 1.5\), \(\beta = 0.998\), \(\mu = \mu_d = 0.0015\), \(\sigma = 0.0078\), \(\rho = 0.979\), \(\varphi_e = 0.044\), \(\phi = 3.0\), \(\varphi_d = 4.5\).

Long-Run Risks — Code

reference depth — scrollable, read after class

The \(v\) fixed point is iterated a fixed 2000 times in all three languages rather than to a tolerance, so the algorithm is deterministic and every tab prints the same digits. The price-dividend ratio is then solved exactly as a linear system.

Code
gamma <- 10; psi <- 1.5; beta <- 0.998
mu <- 0.0015; sig <- 0.0078; rho <- 0.979; phie <- 0.044
mud <- 0.0015; phi <- 3.0; phid <- 4.5

g <- rouwenhorst(9, rho, phie * sig)     # from Part 2 -- exact at this rho
x <- g$z; P <- g$P

# 1. utility-consumption ratio, by fixed point
v   <- rep(1, 9)
sup <- numeric(2000)             # sup-norm of each update -- the convergence profile
for (it in 1:2000) {
  inner <- exp((1 - gamma) * (mu + x) + 0.5 * (1 - gamma)^2 * sig^2) *
           as.vector(P %*% (v^(1 - gamma)))
  vnew  <- ((1 - beta) + beta * inner^((1 - 1/psi) / (1 - gamma)))^(1 / (1 - 1/psi))
  sup[it] <- max(abs(vnew - v))
  v <- vnew
}

X   <- (exp((1 - gamma) * (mu + x) + 0.5 * (1 - gamma)^2 * sig^2) *
        as.vector(P %*% (v^(1 - gamma))))^(1 / (1 - gamma))
EG  <- exp(-gamma * (mu + x) + 0.5 * gamma^2 * sig^2)
EDg <- exp(mud + phi * x + 0.5 * phid^2 * sig^2)
w   <- v^(1/psi - gamma)

# 2. price-dividend ratio, solved exactly as a linear system
cc <- beta * X^(gamma - 1/psi) * EG * EDg
A  <- outer(cc, rep(1, 9)) * P * outer(rep(1, 9), w)
p  <- solve(diag(9) - A, A %*% rep(1, 9))

Rf <- 1 / (beta * X^(gamma - 1/psi) * EG * as.vector(P %*% w))
ER <- as.vector(P %*% (1 + p)) / as.vector(p) * EDg
Long-run risk, 9-state Rouwenhorst chain, 2000 iterations

  equity premium          5.0397 % per year
  risk-free rate          2.6052 % per year
  price-dividend          269.73

  P/D across states   176.5 (worst x) to 408.0 (best x)

  convergence profile, sup-norm |v(k+1) - v(k)|
    k =   100    9.348e-04
    k =   500    7.014e-05
    k =  1000    2.648e-05
    k =  2000    3.717e-06
Code
import numpy as np

gamma, psi, beta = 10.0, 1.5, 0.998
mu, sig, rho, phie = 0.0015, 0.0078, 0.979, 0.044
mud, phi, phid = 0.0015, 3.0, 4.5

x, P = rouwenhorst(9, rho, phie * sig)   # from Part 2 -- exact at this rho

# 1. utility-consumption ratio, by fixed point
v   = np.ones(9)
sup = np.zeros(2000)          # sup-norm of each update -- the convergence profile
for it in range(2000):
    inner = (np.exp((1-gamma)*(mu + x) + 0.5*(1-gamma)**2*sig**2)
             * (P @ v**(1-gamma)))
    vnew = ((1-beta) + beta*inner**((1 - 1/psi)/(1-gamma)))**(1/(1 - 1/psi))
    sup[it] = np.abs(vnew - v).max()
    v = vnew

X   = (np.exp((1-gamma)*(mu + x) + 0.5*(1-gamma)**2*sig**2)
       * (P @ v**(1-gamma)))**(1/(1-gamma))
EG  = np.exp(-gamma*(mu + x) + 0.5*gamma**2*sig**2)
EDg = np.exp(mud + phi*x + 0.5*phid**2*sig**2)
w   = v**(1/psi - gamma)

# 2. price-dividend ratio, solved exactly as a linear system
cc = beta * X**(gamma - 1/psi) * EG * EDg
A  = cc[:, None] * P * w[None, :]
p  = np.linalg.solve(np.eye(9) - A, A @ np.ones(9))

Rf = 1 / (beta * X**(gamma - 1/psi) * EG * (P @ w))
ER = (P @ (1 + p)) / p * EDg

ev, evec = np.linalg.eig(P.T)
pr = np.real(evec[:, np.argmax(np.real(ev))]); pr = pr / pr.sum()

out = ("Long-run risk, 9-state Rouwenhorst chain, 2000 iterations\n\n"
       f"  equity premium      {1200*(pr*(ER-Rf)).sum():10.4f} % per year\n"
       f"  risk-free rate      {1200*(pr*np.log(Rf)).sum():10.4f} % per year\n"
       f"  price-dividend      {(pr*p).sum():10.2f}\n"
       f"\n  P/D across states   {p.min():.1f} (worst x) to {p.max():.1f} (best x)\n"
       "\n  convergence profile, sup-norm |v(k+1) - v(k)|\n")
for k in (100, 500, 1000, 2000):
    out += f"    k = {k:5d}   {sup[k-1]:10.3e}\n"
import sys; nchars = sys.stdout.write(out); sys.stdout.flush()
Long-run risk, 9-state Rouwenhorst chain, 2000 iterations

  equity premium          5.0397 % per year
  risk-free rate          2.6052 % per year
  price-dividend          269.73

  P/D across states   176.5 (worst x) to 408.0 (best x)

  convergence profile, sup-norm |v(k+1) - v(k)|
    k =   100    9.348e-04
    k =   500    7.014e-05
    k =  1000    2.648e-05
    k =  2000    3.717e-06
Code
quietly {
    scalar gamma = 10
    scalar psi   = 1.5
    scalar beta  = 0.998
    scalar mu    = 0.0015
    scalar sig   = 0.0078
    scalar rho   = 0.979
    scalar phie  = 0.044
    scalar mud   = 0.0015
    scalar phi   = 3.0
    scalar phid  = 4.5
    scalar sx    = phie*sig

    * Rouwenhorst chain, exactly as in Part 2
    scalar p_ro = (1 + rho)/2
    scalar szx  = sx/sqrt(1 - rho^2)
    scalar psix = szx*sqrt(8)
    matrix xg = J(9,1,0)
    forvalues i = 1/9 {
        matrix xg[`i',1] = -psix + (`i'-1)*2*psix/8
    }
    matrix P = (p_ro, 1-p_ro \ 1-p_ro, p_ro)
    forvalues n = 3/9 {
        local k = `n' - 1
        matrix Pn = J(`n', `n', 0)
        forvalues i = 1/`k' {
            forvalues j = 1/`k' {
                matrix Pn[`i',`j']     = Pn[`i',`j']     + p_ro*P[`i',`j']
                matrix Pn[`i',`j'+1]   = Pn[`i',`j'+1]   + (1-p_ro)*P[`i',`j']
                matrix Pn[`i'+1,`j']   = Pn[`i'+1,`j']   + (1-p_ro)*P[`i',`j']
                matrix Pn[`i'+1,`j'+1] = Pn[`i'+1,`j'+1] + p_ro*P[`i',`j']
            }
        }
        forvalues i = 2/`k' {
            forvalues j = 1/`n' {
                matrix Pn[`i',`j'] = Pn[`i',`j']/2
            }
        }
        matrix P = Pn
    }

    * 1. utility-consumption ratio, by fixed point
    matrix V = J(9,1,1)
    matrix SUP = J(2000,1,0)
    forvalues it = 1/2000 {
        matrix Vg = J(9,1,0)
        forvalues i = 1/9 {
            matrix Vg[`i',1] = V[`i',1]^(1-gamma)
        }
        matrix PV = P*Vg
        scalar sp = 0
        forvalues i = 1/9 {
            scalar inr = exp((1-gamma)*(mu+xg[`i',1]) ///
                         + 0.5*(1-gamma)^2*sig^2)*PV[`i',1]
            scalar vnew = ((1-beta) ///
                + beta*inr^((1-1/psi)/(1-gamma)))^(1/(1-1/psi))
            scalar sp = max(sp, abs(vnew - V[`i',1]))
            matrix V[`i',1] = vnew
        }
        matrix SUP[`it',1] = sp
    }

    matrix Vg = J(9,1,0)
    forvalues i = 1/9 {
        matrix Vg[`i',1] = V[`i',1]^(1-gamma)
    }
    matrix PV = P*Vg
    matrix Xc = J(9,1,0)
    matrix EG = J(9,1,0)
    matrix ED = J(9,1,0)
    matrix W  = J(9,1,0)
    forvalues i = 1/9 {
        matrix Xc[`i',1] = (exp((1-gamma)*(mu+xg[`i',1]) ///
                           + 0.5*(1-gamma)^2*sig^2)*PV[`i',1])^(1/(1-gamma))
        matrix EG[`i',1] = exp(-gamma*(mu+xg[`i',1]) + 0.5*gamma^2*sig^2)
        matrix ED[`i',1] = exp(mud + phi*xg[`i',1] + 0.5*phid^2*sig^2)
        matrix W[`i',1]  = V[`i',1]^(1/psi - gamma)
    }

    * 2. price-dividend ratio, solved exactly as a linear system
    matrix A = J(9,9,0)
    forvalues i = 1/9 {
        scalar ci = beta*Xc[`i',1]^(gamma-1/psi)*EG[`i',1]*ED[`i',1]
        forvalues j = 1/9 {
            matrix A[`i',`j'] = ci*P[`i',`j']*W[`j',1]
        }
    }
    matrix ones = J(9,1,1)
    matrix pd = inv(I(9) - A)*A*ones
    matrix PW = P*W
    matrix Rf = J(9,1,0)
    matrix ER = J(9,1,0)
    matrix P1 = P*(ones + pd)
    forvalues i = 1/9 {
        matrix Rf[`i',1] = 1/(beta*Xc[`i',1]^(gamma-1/psi)*EG[`i',1]*PW[`i',1])
        matrix ER[`i',1] = P1[`i',1]/pd[`i',1]*ED[`i',1]
    }

    * stationary distribution, by iterating a row vector
    matrix pr = J(1,9,1/9)
    forvalues it = 1/3000 {
        matrix pr = pr*P
    }
    scalar prem = 0
    scalar rfm  = 0
    scalar pdm  = 0
    scalar pmin = pd[1,1]
    scalar pmax = pd[1,1]
    forvalues i = 1/9 {
        scalar prem = prem + pr[1,`i']*(ER[`i',1] - Rf[`i',1])
        scalar rfm  = rfm  + pr[1,`i']*ln(Rf[`i',1])
        scalar pdm  = pdm  + pr[1,`i']*pd[`i',1]
        scalar pmin = min(pmin, pd[`i',1])
        scalar pmax = max(pmax, pd[`i',1])
    }
}

display "Long-run risk, 9-state Rouwenhorst chain, 2000 iterations" _newline ///
  _newline ///
  "  equity premium      " %10.4f 1200*prem " % per year" _newline ///
  "  risk-free rate      " %10.4f 1200*rfm  " % per year" _newline ///
  "  price-dividend      " %10.2f pdm _newline ///
  _newline ///
  "  P/D across states   " %5.1f pmin " (worst x) to " %5.1f pmax " (best x)" _newline ///
  _newline ///
  "  convergence profile, sup-norm |v(k+1) - v(k)|" _newline ///
  "    k =   100   " %10.3e SUP[100,1]  _newline ///
  "    k =   500   " %10.3e SUP[500,1]  _newline ///
  "    k =  1000   " %10.3e SUP[1000,1] _newline ///
  "    k =  2000   " %10.3e SUP[2000,1]
Long-run risk, 9-state Rouwenhorst chain, 2000 iterations

  equity premium          5.0397 % per year
  risk-free rate          2.6052 % per year
  price-dividend          269.73

  P/D across states   176.5 (worst x) to 408.0 (best x)

  convergence profile, sup-norm |v(k+1) - v(k)|
    k =   100    9.348e-04
    k =   500    7.014e-05
    k =  1000    2.648e-05
    k =  2000    3.717e-06

A fixed iteration count is a parity choice, not a convergence criterion — and this one has not converged. In real work you stop on a tolerance: iterate until \(\lVert v^{(k+1)} - v^{(k)} \rVert_\infty\) falls below something you chose, and report the \(k\) it took. The profile printed above is what that rule would have seen, and it is sobering: the update is still moving by \(3.7 \times 10^{-6}\) at \(k = 2000\), and the sup-norm does not fall below \(10^{-12}\) until \(k = 9684\) — nearly five times the iteration count this slide uses.

It costs almost nothing here. Running to \(10^{-12}\) moves the equity premium from \(5.0397\) to \(5.0399\) and the price-dividend ratio from \(269.73\) to \(269.72\) — the fourth significant digit, invisible at the precision the results slide quotes. But that is a fact discovered by measuring, not a fact anyone should assume.

Fixed iteration counts buy cross-language determinism; tolerances buy a correctness guarantee. Know which one you are using, and never let a fixed count stand in for a convergence check you did not run.

Long-Run Risks — Results & Fragility

Lucas tree Long-run risk US data
equity premium 0.036% 5.04% 8.61%
risk-free rate 7.98% 2.61% 1.42%
price-dividend 68.1 269.7

From a factor of 239 away to a factor of 1.7. The same model simultaneously brings the risk-free rate down from 7.98% to 2.61%, against 1.42% in the data. Both puzzles move in the right direction at once — which is exactly what no amount of raising \(\gamma\) in the Lucas tree could achieve.

And it does so with a persistent component that is nearly invisible: \(x_t\) accounts for well under 1% of the one-month variance of consumption growth.

change premium what it means
\(\rho = 0.979\) (calibration) 5.04% the headline result
\(\rho = 0.9773\) 4.52% the value Part 1’s own diagnostics recovered from 3000 simulated months
\(\rho = 0.95\) 1.16% still “highly persistent” by any normal standard
\(\rho = 0.90\) 0.30% back to the Lucas tree
\(\psi = 1/\gamma = 0.1\) (CRRA) 0.000% long-run risk prices nothing at all under expected utility
\(\psi = 0.5\) 1.73% \(\psi < 1\) guts the mechanism
\(\gamma = 5\) 2.69% still needs high risk aversion

Two things here deserve to be said plainly.

(\(\psi = 1\) itself is left out of the figure: \(\theta = (1-\gamma)/(1-1/\psi)\) is undefined there, and the log-utility case has to be taken as a limit.)

First, \(\psi\) must exceed 1. At \(\psi = 1/\gamma\) the model is CRRA, and the premium is exactly zero — long-run risk contributes nothing without a preference for early resolution of uncertainty. The mechanism is Epstein–Zin’s, not the persistence’s; persistence only gives it something to bite on.

Second, and more uncomfortable: the answer hinges on a parameter nobody can measure. Moving \(\rho\) from 0.979 to 0.95 cuts the premium by a factor of four. And Part 1’s diagnostics, run on 3000 months simulated from a process with \(\rho = 0.979\), recovered 0.9773 — because the AR(1) coefficient is biased downward near a unit root. That estimate alone costs half a percentage point of premium.

This is the Beeler–Campbell (2012) critique in one figure: the model works, but the parameter it works through is estimated with an uncertainty far wider than the range over which the answer changes completely. Part 7 returns to this as a weak identification problem rather than a calibration quibble.

Habit Formation

Long-run risk made consumption riskier than it looks. Campbell and Cochrane (1999) take the other route: leave consumption alone and make the consumer’s attitude to risk move with the cycle.

The consumer cares about consumption relative to a slow-moving habit \(X_t\). What matters is the surplus

\[S_t = \frac{C_t - X_t}{C_t}\]

In good times \(C\) is far above habit, \(S\) is large, and a given consumption shock barely matters. In bad times \(C\) is close to habit, \(S\) is small, and the same shock is nearly catastrophic. Effective risk aversion is \(\gamma / S_t\) — it rises exactly when the economy is weak.

This delivers something neither previous model does: a time-varying price of risk. Part 1’s stylised fact 3 said risk is not constant. Habit is the first model in this deck that agrees.

The surplus is the fraction of consumption that sits above habit,

\[S_t = \frac{C_t - X_t}{C_t}\]

and effective risk aversion is \(\gamma / S_t\), so it rises exactly when the economy is weak. Log surplus follows a heteroskedastic AR(1) — the whole model, computationally:

\[s_{t+1} = (1-\varphi)\bar{s} + \varphi \, s_t + \lambda(s_t)\left( \Delta c_{t+1} - g \right)\]

The sensitivity function \(\lambda(s_t)\) is not a free choice. Campbell and Cochrane (1999) pick the one shape that holds the risk-free rate constant, so that every bit of the model’s action sits in the price of risk rather than in the interest rate; the code tab carries the resulting formula.

The discount factor is CRRA in the surplus-scaled growth rate,

\[M_{t+1} = \beta \left( \frac{S_{t+1}}{S_t} \cdot \frac{C_{t+1}}{C_t} \right)^{-\gamma}\]

and at the steady state the implied Hansen–Jagannathan bound of Part 1 collapses to something remarkably clean:

\[\left. \frac{\sigma(M)}{\mathbb{E}[M]} \right|_{\bar{s}} = \sqrt{\gamma \left( 1 - \varphi \right)}\]

Annual calibration: \(g = 0.0189\), \(\sigma = 0.0150\), \(\varphi = 0.87\), \(\gamma = 2\). So the thing to compute is a state-dependent heteroskedastic AR(1), simulated for 100 000 periods, and the number to score is \(\sqrt{2 \times 0.13}\) against the annual Sharpe ratio of \(0.525\) measured in Part 1.

Code
gh <- 0.0189; sgh <- 0.0150; phih <- 0.87; gamh <- 2.00
Sbar <- sgh * sqrt(gamh / (1 - phih))
sbar <- log(Sbar)
smax <- sbar + (1 - Sbar^2) / 2

# Campbell-Cochrane sensitivity: zero above s_max, so surplus stays in (0,1)
lambda <- function(s) {
  inside <- pmax(1 - 2 * (s - sbar), 0)
  ifelse(s <= smax, (1 / Sbar) * sqrt(inside) - 1, 0)
}

set.seed(14159)
n <- 100000
s <- numeric(n); s[1] <- sbar
for (i in 2:n) {
  e    <- rnorm(1, 0, sgh)
  s[i] <- (1 - phih) * sbar + phih * s[i - 1] + lambda(s[i - 1]) * e
}
sharpe <- gamh * sgh * (1 + lambda(s))
Campbell-Cochrane habit, annual calibration

  steady-state surplus S-bar          0.0588
  max Sharpe at steady state          0.5099   = sqrt(gamma(1-phi))
  US data, annual Sharpe (Part 1)     0.5250

  simulated Sharpe: mean 0.455   10th pct 0.170   90th pct 0.774
  surplus S_t:      mean 0.0644  10th pct 0.0306  90th pct 0.0918
Code
import numpy as np

g, sg, phi_h, gam = 0.0189, 0.0150, 0.87, 2.00
Sbar = sg * np.sqrt(gam / (1 - phi_h))
sbar = np.log(Sbar)
smax = sbar + (1 - Sbar**2) / 2

def lam(s):
    inside = max(1 - 2 * (s - sbar), 0.0)
    return (1 / Sbar) * np.sqrt(inside) - 1 if s <= smax else 0.0

rng = np.random.default_rng(14159)
n = 100000
s = np.empty(n); s[0] = sbar
for i in range(1, n):
    e = rng.normal(0, sg)
    s[i] = (1 - phi_h) * sbar + phi_h * s[i-1] + lam(s[i-1]) * e

sharpe = gam * sg * (1 + np.array([lam(si) for si in s]))

out = ("Campbell-Cochrane habit, annual calibration\n\n"
       f"  steady-state surplus S-bar        {Sbar:8.4f}\n"
       f"  max Sharpe at steady state        {np.sqrt(gam*(1-phi_h)):8.4f}"
       "   = sqrt(gamma(1-phi))\n"
       f"  US data, annual Sharpe (Part 1)   {0.525:8.4f}\n\n"
       f"  simulated Sharpe: mean {sharpe.mean():.3f}   "
       f"10th pct {np.quantile(sharpe,0.10):.3f}   "
       f"90th pct {np.quantile(sharpe,0.90):.3f}\n"
       f"  surplus S_t:      mean {np.exp(s).mean():.4f}  "
       f"10th pct {np.quantile(np.exp(s),0.10):.4f}  "
       f"90th pct {np.quantile(np.exp(s),0.90):.4f}\n")
import sys; nchars = sys.stdout.write(out); sys.stdout.flush()
Campbell-Cochrane habit, annual calibration

  steady-state surplus S-bar          0.0588
  max Sharpe at steady state          0.5099   = sqrt(gamma(1-phi))
  US data, annual Sharpe (Part 1)     0.5250

  simulated Sharpe: mean 0.452   10th pct 0.169   90th pct 0.766
  surplus S_t:      mean 0.0647  10th pct 0.0314  90th pct 0.0918

\(\sqrt{\gamma(1-\varphi)} = \sqrt{2 \times 0.13} = 0.510\), against a measured annual Sharpe ratio of 0.525. Habit clears the Hansen–Jagannathan bound of Part 1 at \(\gamma = 2\) — the value the Lucas tree needed 478 of. And it does so with a price of risk that ranges from 0.17 in good times to 0.77 in bad, which is the countercyclical variation Part 1’s third stylised fact demanded.

Rare Disasters & the Peso Problem

Rietz (1988) and Barro (2006) make a different objection: the sample is the problem, not the model. Suppose consumption is normally smooth but occasionally collapses — war, depression, financial ruin — by 10 to 70%, with probability about 1.7% a year. Investors price that risk continuously. A researcher looking at 79 years of US data mostly does not see it.

\[\Delta c_{t+1} = \mu + \sigma \varepsilon_{t+1} + \ln(1 - b_{t+1}) J_{t+1}, \qquad J \sim \text{Bernoulli}(p)\]

\[\Delta d_{t+1} = \mu + 3\sigma \varepsilon^d_{t+1} + 3 \ln(1 - b_{t+1}) J_{t+1}\]

A disaster raises \(M\) enormously — \((1-b)^{-\gamma}\) with \(b = 0.3\) and \(\gamma = 4\) is a factor of 4 — precisely when the levered claim collapses. That covariance is the premium, and it needs no implausible risk aversion at all.

Calibration: \(p = 0.017\), \(\gamma = 4\), \(\beta = 0.99\), \(\mu = 0.025\), \(\sigma = 0.02\), disaster sizes on \([0.10, 0.70]\) averaging 0.27.

Rare disasters, gamma = 4, p = 0.017

  population premium                  4.303 % per year
  risk-free rate                      4.992 % per year

  79-year samples with NO disaster     26.6 %
  measured premium, all samples       4.301 %
  measured premium, disaster-free     5.410 %
  measured premium, with disaster     3.899 %
  sd of the measured premium          1.236 %
Code
import numpy as np

rng = np.random.default_rng(14159)
p_d, gam_d, beta_d, mu_d, sc_d = 0.017, 4, 0.99, 0.025, 0.02
NB = 2000000

J = rng.binomial(1, p_d, NB)
b = np.zeros(NB)
b[J == 1] = rng.beta(2, 5, J.sum()) * 0.6 + 0.10

dc  = mu_d + sc_d * rng.normal(size=NB) + np.log(1 - b)
sdf = beta_d * np.exp(-gam_d * dc)
Rf  = 1 / sdf.mean()
dd  = mu_d + 3 * sc_d * rng.normal(size=NB) + 3 * np.log(1 - b)
Rm  = np.exp(dd) / (sdf * np.exp(dd)).mean()

# The peso problem: what does a 79-year sample actually measure?
Ns, R = 79, 20000
idx   = rng.integers(0, NB, size=(R, Ns))
est   = Rm[idx].mean(axis=1) - Rf
nodis = (J[idx] == 0).all(axis=1)

out = (f"Rare disasters, gamma = {gam_d}, p = {p_d:.3f}\n\n"
       f"  population premium               {100*(Rm.mean()-Rf):8.3f} % per year\n"
       f"  risk-free rate                   {100*np.log(Rf):8.3f} % per year\n"
       f"\n  79-year samples with NO disaster {100*nodis.mean():8.1f} %\n"
       f"  measured premium, all samples    {100*est.mean():8.3f} %\n"
       f"  measured premium, disaster-free  {100*est[nodis].mean():8.3f} %\n"
       f"  measured premium, with disaster  {100*est[~nodis].mean():8.3f} %\n"
       f"  sd of the measured premium       {100*est.std(ddof=1):8.3f} %\n")
import sys; nchars = sys.stdout.write(out); sys.stdout.flush()
Rare disasters, gamma = 4, p = 0.017

  population premium                  4.284 % per year
  risk-free rate                      5.015 % per year

  79-year samples with NO disaster     26.1 %
  measured premium, all samples       4.290 %
  measured premium, disaster-free     5.397 %
  measured premium, with disaster     3.899 %
  sd of the measured premium          1.233 %

A premium of 4.3% at \(\gamma = 4\) — from a model in which nothing about preferences is strange. But look at the last three lines: 27% of 79-year samples contain no disaster at all, and in those the measured premium is 5.4% against a population value of 4.3%. The sample overstates by a quarter, purely because the risk being priced never showed up.

That is the peso problem, and it cuts both ways. The disaster story explains the premium and explains why the premium looks even larger than it is — but it does so with a parameter, \(p\), that the data by construction can barely identify. Part 7 asks what that does to estimation.

Model Horse Race

Every number below was computed on a slide in this part. None is quoted from a paper.

model \(\gamma\) premium %/yr \(r^f\) %/yr max Sharpe verdict
US data 1947Q2–2026Q2 8.61 1.42 0.525 the target
Lucas tree 2 0.04 7.98 0.02 fails both, by a factor of 239
Epstein–Zin, i.i.d. 10 0.18 5.17 0.08 fixes \(r^f\) only
Long-run risk 10 5.04 2.61 closest; fragile in \(\rho\)
Habit 2 0.510 matches the price of risk at \(\gamma=2\)
Rare disasters 4 4.30 4.99 works at low \(\gamma\); \(p\) unidentified

Habit is scored on the maximum Sharpe ratio rather than a premium: the model delivers the price of risk analytically, and pinning down a premium would require solving the price-dividend ratio over the surplus state.

No model wins outright, and the two that come closest do so for opposite reasons. Long-run risk and disasters both say the data understate the risk — one because it is too persistent to see in a short sample, the other because it is too rare. Habit says the risk is visible enough; it is the price that moves. All three are consistent with Part 1’s measurements. That is precisely why distinguishing them is a Part 7 problem, not a Part 3 one.

          model gamma premium   rf sharpe   gap
        US data    NA    8.61 1.42  0.525  0.00
     Lucas tree     2    0.04 7.98  0.020 -8.57
    Epstein-Zin    10    0.18 5.17  0.080 -8.43
  Long-run risk    10    5.04 2.61     NA -3.57
          Habit     2      NA   NA  0.510    NA
 Rare disasters     4    4.30 4.99     NA -4.31
Code
import pandas as pd

race = pd.DataFrame({
    "model":   ["US data", "Lucas tree", "Epstein-Zin", "Long-run risk",
                "Habit", "Rare disasters"],
    "gamma":   [None, 2, 10, 10, 2, 4],
    "premium": [8.61, 0.04, 0.18, 5.04, None, 4.30],
    "rf":      [1.42, 7.98, 5.17, 2.61, None, 4.99],
    "sharpe":  [0.525, 0.02, 0.08, None, 0.510, None]})
race["gap"] = (race["premium"] - 8.61).round(2)
import sys; nchars = sys.stdout.write(race.to_string(index=False) + "\n")
         model  gamma  premium   rf  sharpe   gap
       US data    NaN     8.61 1.42   0.525  0.00
    Lucas tree    2.0     0.04 7.98   0.020 -8.57
   Epstein-Zin   10.0     0.18 5.17   0.080 -8.43
 Long-run risk   10.0     5.04 2.61     NaN -3.57
         Habit    2.0      NaN  NaN   0.510   NaN
Rare disasters    4.0     4.30 4.99     NaN -4.31
Code
sys.stdout.flush()
Code
quietly {
    clear
    input str15 model gamma premium rf sharpe
    "US data"          .   8.61  1.42  0.525
    "Lucas tree"       2   0.04  7.98  0.020
    "Epstein-Zin"     10   0.18  5.17  0.080
    "Long-run risk"   10   5.04  2.61  .
    "Habit"            2   .     .     0.510
    "Rare disasters"   4   4.30  4.99  .
    end
    generate gap = round(premium - 8.61, 0.01)
}
list model gamma premium rf sharpe gap, noobs clean
             model   gamma   premium     rf   sharpe     gap  
           US data       .      8.61   1.42     .525       0  
        Lucas tree       2       .04   7.98      .02   -8.57  
       Epstein-Zin      10       .18   5.17      .08   -8.43  
     Long-run risk      10      5.04   2.61        .   -3.57  
             Habit       2         .      .      .51       .  
    Rare disasters       4       4.3   4.99        .   -4.31  

Part 4 — Solving & Simulating a DSGE

Log-linearisation, the Blanchard-Kahn conditions, perturbation, why first
order kills the risk premium, and the financial accelerator.

The Model

Parts 1 to 3 priced assets in an endowment economy: consumption fell from the sky and the only question was what it was worth. That is the right laboratory for the equity premium, and the wrong one for almost everything a central bank cares about.

Now production returns. Output is made with capital, capital is accumulated by forgoing consumption, and a technology shock propagates through the whole system because today’s saving is tomorrow’s capital. The model becomes dynamic in a second sense: not just expectations of the future, but a physical state that carries the past forward.

This is the model that generated mfsim-dsge.csv in Part 1. Its policy coefficients were published there — \(g_k = 0.618247\), \(g_a = 0.305243\), \(A_k = 0.965276\). This part has to rediscover them from scratch, by a completely different route, in three languages. If the numbers come back, both the data script and the solver are right.

A real business cycle model with fixed labour and log utility. In log deviations from steady state, with \(k\) capital, \(a\) technology and \(c\) consumption:

Resource constraint and capital accumulation

\[k_{t+1} = \left[ (1-\delta) + \alpha\phi \right] k_t + \phi \, a_t - \phi \, s_c \, c_t\]

Consumption Euler equation

\[c_t = \mathbb{E}_t \left[ c_{t+1} \right] - \mathbb{E}_t \left[ r_{t+1} \right], \qquad r_{t+1} = \chi \left( a_{t+1} + (\alpha-1) k_{t+1} \right)\]

Technology

\[a_{t+1} = \rho_a \, a_t + \sigma_a \, \varepsilon_{t+1}\]

with the steady-state ratios

\[\frac{K}{Y} = \frac{\alpha}{1/\beta - 1 + \delta}, \qquad \phi = \frac{Y}{K}, \qquad s_c = 1 - \delta \frac{K}{Y}, \qquad \chi = \beta \alpha \phi\]

Calibration \(\alpha = 0.36\), \(\beta = 0.99\), \(\delta = 0.025\), \(\rho_a = 0.95\) gives

\[\phi = 0.097503, \qquad s_c = 0.743597, \qquad \chi = 0.034750, \qquad a_0 = 1.010101\]

Log-Linearisation & the Blanchard–Kahn Conditions

The three equations above are not a recursion you can simulate. Two of them involve \(\mathbb{E}_t[\,\cdot_{t+1}]\), and there are infinitely many paths satisfying them — most of which explode. Choosing among them is the entire content of “solving” a rational expectations model.

The rule that picks the right one is due to Blanchard and Kahn: a unique non-explosive solution exists iff the number of unstable roots equals the number of forward-looking variables. Here there are two predetermined variables (\(k\), \(a\)) and one jump variable (\(c\)), so we need exactly one root outside the unit circle.

Stack \(x_t = (k_t, a_t, c_t)'\) and write the system as a linear pencil:

\[\mathbf{A} \, \mathbb{E}_t \left[ x_{t+1} \right] = \mathbf{B} \, x_t\]

\[\mathbf{A} = \begin{pmatrix} 1 & 0 & 0 \\ 0 & 1 & 0 \\ -\chi(\alpha-1) & 0 & 1 \end{pmatrix}, \qquad \mathbf{B} = \begin{pmatrix} a_0 & \phi & -\phi s_c \\ 0 & \rho_a & 0 \\ 0 & \chi\rho_a & 1 \end{pmatrix}\]

\(\mathbf{A}\) is singular in general, so ordinary eigenvalues will not do. The generalized Schur (QZ) decomposition finds unitary \(Q, Z\) with

\[Q \mathbf{B} Z = S, \qquad Q \mathbf{A} Z = T\]

both upper triangular, and the generalized eigenvalues are \(\lambda_i = S_{ii}/T_{ii}\), reordered so the stable ones come first. Partitioning \(Z\) conformably with the \(n_k = 2\) states, Klein’s (2000) solution is

\[c_t = \underbrace{Z_{21} Z_{11}^{-1}}_{F} \begin{pmatrix} k_t \\ a_t \end{pmatrix}, \qquad \begin{pmatrix} k_{t+1} \\ a_{t+1} \end{pmatrix} = \underbrace{Z_{11} T_{11}^{-1} S_{11} Z_{11}^{-1}}_{P} \begin{pmatrix} k_t \\ a_t \end{pmatrix}\]

First-Order Perturbation — Code

Three genuinely different routes to the same object: R and Python build the pencil and call a generalized Schur routine directly; Stata states the model in dsge syntax and reads the answer off estat policy.

Code
library(QZ)
alpha <- 0.36; bta <- 0.99; delta <- 0.025; rho_a <- 0.95
ky  <- alpha / (1/bta - 1 + delta)
phi <- 1/ky; s_i <- delta*ky; s_c <- 1 - s_i
chi <- bta*alpha*phi
a0  <- (1 - delta) + phi*alpha

A <- matrix(c(1, 0, 0,
              0, 1, 0,
              -chi*(alpha-1), 0, 1), 3, 3, byrow = TRUE)
B <- matrix(c(a0, phi, -phi*s_c,
              0,  rho_a, 0,
              0,  chi*rho_a, 1), 3, 3, byrow = TRUE)

# Generalized eigenvalues of the pencil B - lambda A
e   <- qz.dgges(B, A)
lam <- e$ALPHAR / e$BETA
sel <- abs(lam) < 1                     # stable roots first
o   <- qz.dtgsen(e$S, e$T, e$Q, e$Z, select = sel)

nk  <- 2                                # k and a are predetermined
Z11 <- o$Z[1:nk, 1:nk]
Z21 <- o$Z[(nk+1):3, 1:nk, drop = FALSE]
S11 <- o$S[1:nk, 1:nk]
T11 <- o$T[1:nk, 1:nk]

F <- Z21 %*% solve(Z11)                             # c_t = F (k_t, a_t)'
P <- Z11 %*% solve(T11) %*% S11 %*% solve(Z11)      # state transition
Generalized eigenvalues (stable first): 0.965276  0.950000  1.046437
Blanchard-Kahn: 1 unstable root(s), 1 jump variable(s)  -> unique stable solution

  policy    g_k = 0.618247    truth 0.618247
            g_a = 0.305243    truth 0.305243
  transition A_k = 0.965276    truth 0.965276
            B_a = 0.075372    truth 0.075372
          rho_a = 0.950000    truth 0.950000
Code
import numpy as np
import pandas as pd
from scipy.linalg import ordqz

alpha, bta, delta, rho_a = 0.36, 0.99, 0.025, 0.95
ky  = alpha / (1/bta - 1 + delta)
phi = 1/ky; s_i = delta*ky; s_c = 1 - s_i
chi = bta*alpha*phi
a0  = (1 - delta) + phi*alpha

A = np.array([[1.0, 0, 0],
              [0, 1.0, 0],
              [-chi*(alpha-1), 0, 1.0]])
B = np.array([[a0, phi, -phi*s_c],
              [0, rho_a, 0],
              [0, chi*rho_a, 1.0]])

# sort='iuc' puts the roots INSIDE the unit circle first
S, T, al, be, Q, Z = ordqz(B, A, sort="iuc", output="real")
lam = np.real(al / be)

nk  = 2                                  # k and a are predetermined
Z11 = Z[:nk, :nk]; Z21 = Z[nk:, :nk]
S11 = S[:nk, :nk]; T11 = T[:nk, :nk]

F = Z21 @ np.linalg.inv(Z11)                              # c_t = F (k_t, a_t)'
P = Z11 @ np.linalg.solve(T11, S11) @ np.linalg.inv(Z11)  # state transition

truth = pd.read_csv("../data/mfsim-dsge.csv")
nun = int((np.abs(lam) >= 1).sum())

out = ("Generalized eigenvalues (stable first): "
       + "  ".join(f"{v:.6f}" for v in lam) + "\n"
       f"Blanchard-Kahn: {nun} unstable root(s), 1 jump variable(s)  -> "
       + ("unique stable solution" if nun == 1 else "FAILS") + "\n\n"
       f"  policy    g_k = {F[0,0]:.6f}    truth {truth['gk_true'][0]:.6f}\n"
       f"            g_a = {F[0,1]:.6f}    truth {truth['ga_true'][0]:.6f}\n"
       f"  transition A_k = {P[0,0]:.6f}    truth {truth['Ak_true'][0]:.6f}\n"
       f"            B_a = {P[0,1]:.6f}    truth {0.075372:.6f}\n"
       f"          rho_a = {P[1,1]:.6f}    truth {truth['rhoa_true'][0]:.6f}\n")
import sys; nchars = sys.stdout.write(out); sys.stdout.flush()
Generalized eigenvalues (stable first): 0.965276  0.950000  1.046437
Blanchard-Kahn: 1 unstable root(s), 1 jump variable(s)  -> unique stable solution

  policy    g_k = 0.618247    truth 0.618247
            g_a = 0.305243    truth 0.305243
  transition A_k = 0.965276    truth 0.965276
            B_a = 0.075372    truth 0.075372
          rho_a = 0.950000    truth 0.950000
Code
quietly {
    import delimited "../data/mfsim-dsge.csv", clear
    tsset t
    * every structural parameter is known, so rho is pinned by a constraint
    * and dsge solves rather than estimates
    constraint 1 _b[/structural:rho] = 0.95
    dsge (c = E(F.c) - .0347500000*F.a + .0222400000*F.k)                    ///
         (F.k = 1.0101010101*k + .0975028058*a - .0725028058*c,             ///
               state noshock)                                               ///
         (F.a = {rho=.95}*a, state), constraints(1)
}
estat policy
estat transition
Policy matrix

------------------------------------------------------------------------------
             |            Delta-method
             | Coefficient  std. err.      z    P>|z|     [95% conf. interval]
-------------+----------------------------------------------------------------
c            |
           k |   .6182466          .        .       .            .           .
           a |    .305243          .        .       .            .           .
------------------------------------------------------------------------------
Note: Standard errors reported as missing for constrained policy matrix values.


Transition matrix of state variables

------------------------------------------------------------------------------
             |            Delta-method
             | Coefficient  std. err.      z    P>|z|     [95% conf. interval]
-------------+----------------------------------------------------------------
F.k          |
           k |   .9652764          .        .       .            .           .
           a |   .0753718          .        .       .            .           .
-------------+----------------------------------------------------------------
F.a          |
           k |          0  (omitted)
           a |        .95          .        .       .            .           .
------------------------------------------------------------------------------
Note: Standard errors reported as missing for constrained transition matrix values.

Stata reaches the same solution from the other end: dsge takes the model in its structural form, forms the pencil internally, checks the Blanchard–Kahn count and reports the policy matrix (\(c\) on the states) and the transition matrix (states on states) directly.

Three routes, one answer, and it is the right one. The generalized eigenvalues are \(0.965276\), \(0.950000\) and \(1.046437\) — exactly one outside the unit circle for exactly one jump variable, so Blanchard–Kahn holds and the solution is unique. The recovered policy \(g_k = 0.6182\), \(g_a = 0.3052\) and transition \(A_k = 0.9653\), \(B_a = 0.0754\) match the values mfsim-data.R published in Part 1 to five decimal places — and note that the data script got there by solving a quadratic by hand, not by QZ at all. Two independent methods, same numbers.

Simulating the Solved Model — IRFs

With \(F\) and \(P\) in hand the model is finally a recursion. Set \(a_0 = 1\) (a one percent technology shock), \(k_0 = 0\), and iterate

\[\begin{pmatrix} k_{t+1} \\ a_{t+1} \end{pmatrix} = P \begin{pmatrix} k_t \\ a_t \end{pmatrix}, \qquad c_t = F \begin{pmatrix} k_t \\ a_t \end{pmatrix}, \qquad y_t = a_t + \alpha k_t, \qquad i_t = \frac{y_t - s_c c_t}{s_i}\]

Code
H  <- 40
st <- matrix(0, H, 2)
st[1, ] <- c(0, 1)                       # k_0 = 0, a_0 = 1%
for (h in 2:H) st[h, ] <- as.vector(Pm %*% st[h - 1, ])

irf <- data.frame(h = 0:(H - 1), k = st[, 1], a = st[, 2]) |>
  mutate(c = as.vector(st %*% t(Fm)),
         y = a + alpha * k,
         i = (y - s_c * c) / s_i)

ggplot(irf) +
  aes(x = h) +
  geom_line(aes(y = i), colour = "#D85A30", linewidth = 0.9) +
  geom_line(aes(y = y), colour = "#185FA5", linewidth = 0.9) +
  geom_line(aes(y = k), colour = "#1D9E75", linewidth = 0.9) +
  geom_line(aes(y = c), colour = "#BA7517", linewidth = 0.9)

Code
import matplotlib.pyplot as plt

H  = 40
st = np.zeros((H, 2))
st[0] = [0.0, 1.0]                       # k_0 = 0, a_0 = 1%
for h in range(1, H):
    st[h] = P @ st[h - 1]

kk = st[:, 0]; aa = st[:, 1]
cc = st @ F.ravel()
yy = aa + alpha * kk
ii = (yy - s_c * cc) / s_i
hh = np.arange(H)

fig, ax = plt.subplots(figsize=(8, 4.4))
ax.axhline(0, color="#b3b3b3", lw=0.6)
ax.plot(hh, ii, color="#D85A30", lw=1.5)
ax.plot(hh, yy, color="#185FA5", lw=1.5)
ax.plot(hh, kk, color="#1D9E75", lw=1.5)
ax.plot(hh, cc, color="#BA7517", lw=1.5)
ax.text(6,  2.85, "investment",  color="#D85A30", fontsize=9)
ax.text(12, 0.92, "output",      color="#185FA5", fontsize=9)
ax.text(20, 0.70, "capital",     color="#1D9E75", fontsize=9)
ax.text(20, 0.30, "consumption", color="#BA7517", fontsize=9)
axopts = ax.set(xlim=(0, 39), ylim=(-0.2, 3.2),
                xticks=np.arange(0, 41, 10), yticks=np.arange(0, 4, 1),
                xlabel="quarters", ylabel="percent deviation",
                title="Response to a 1% technology shock")
plt.show()

Code
quietly {
    import delimited "../data/mfsim-dsge.csv", clear
    tsset t
    constraint 1 _b[/structural:rho] = 0.95
    dsge (c = E(F.c) - .0347500000*F.a + .0222400000*F.k)                   ///
         (F.k = 1.0101010101*k + .0975028058*a - .0725028058*c,            ///
               state noshock)                                              ///
         (F.a = {rho=.95}*a, state), constraints(1)

    * pull the solved matrices straight out of the estat results
    estat policy
    matrix POL = r(b)
    estat transition
    matrix TRN = r(b)
    scalar gk = POL[1,1]
    scalar ga = POL[1,2]
    scalar Ak = TRN[1,1]
    scalar Ba = TRN[1,2]
    scalar ra = TRN[1,4]

    scalar alpha = 0.36
    scalar s_c = 0.7435971223
    scalar s_i = 0.2564028777

    clear
    set obs 40
    generate h = _n - 1
    generate double kk = 0
    generate double aa = 0
    replace aa = 1 in 1
    forvalues j = 2/40 {
        replace kk = Ak*kk[`j'-1] + Ba*aa[`j'-1] in `j'
        replace aa = ra*aa[`j'-1] in `j'
    }
    generate double cc = gk*kk + ga*aa
    generate double yy = aa + alpha*kk
    generate double ii = (yy - s_c*cc)/s_i
}

twoway (line ii h, lcolor("216 90 48") lwidth(medium))                    ///
       (line yy h, lcolor("24 95 165") lwidth(medium))                    ///
       (line kk h, lcolor("29 158 117") lwidth(medium))                   ///
       (line cc h, lcolor("186 117 23") lwidth(medium)),                  ///
  yline(0, lcolor(gs11) lwidth(vthin))                                    ///
  xscale(range(0 39)) yscale(range(-0.2 3.2))                             ///
  xlabel(0(10)40) ylabel(0(1)3)                                           ///
  xtitle("quarters") ytitle("percent deviation")                          ///
  title("Response to a 1% technology shock", size(medsmall))              ///
  text(2.85 6 "investment", color("216 90 48") size(vsmall)               ///
       placement(e) justification(left))                                  ///
  text(0.92 12 "output", color("24 95 165") size(vsmall)                  ///
       placement(e) justification(left))                                  ///
  text(0.70 20 "capital", color("29 158 117") size(vsmall)                ///
       placement(e) justification(left))                                  ///
  text(0.30 20 "consumption", color("186 117 23") size(vsmall)            ///
       placement(e) justification(left))                                  ///
  legend(off) graphregion(color(white))
graph export "../plots/mfsim-irf-stata.png", replace width(1600)

Stochastic Simulation & Model Moments

Rather than simulating live — which would give three different answers — every tab reads the 1000 quarters already in mfsim-dsge.csv, produced by this model at these parameters, and compares them with mfsim-macro.csv. Both are quarterly log growth rates, so the comparison is like for like.

Code
md <- read.csv("../data/mfsim-dsge.csv")
us <- read.csv("../data/mfsim-macro.csv")

# model series are in percent deviations; differencing gives growth rates
dy <- diff(md$y) / 100; dc <- diff(md$c) / 100; di <- diff(md$i) / 100
ey <- us$dgdp / 400;    ec <- us$dcons / 400      # dcons is 400 x log-diff

moments <- function(gy, gc, gi = NULL) {
  n <- length(gy)
  c(sd_y   = 100 * sd(gy),
    rel_c  = sd(gc) / sd(gy),
    rel_i  = if (is.null(gi)) NA else sd(gi) / sd(gy),
    cor_cy = cor(gc, gy),
    ac1_y  = cor(gy[-1], gy[-n]))
}
mm <- moments(dy, dc, di)
uu <- moments(ey, ec)
                moment model  data
 sd(dy), % per quarter 0.723 1.105
         sd(dc)/sd(dy) 0.315 0.966
         sd(di)/sd(dy) 3.043    NA
          corr(dc, dy) 0.953 0.735
               ac1(dy) 0.001 0.134
Code
import numpy as np
import pandas as pd

md = pd.read_csv("../data/mfsim-dsge.csv")
us = pd.read_csv("../data/mfsim-macro.csv")

# model series are in percent deviations; differencing gives growth rates
dy = np.diff(md["y"]) / 100; dc = np.diff(md["c"]) / 100; di = np.diff(md["i"]) / 100
ey = us["dgdp"].to_numpy() / 400; ec = us["dcons"].to_numpy() / 400

def moments(gy, gc, gi=None):
    return [100 * gy.std(ddof=1),
            gc.std(ddof=1) / gy.std(ddof=1),
            np.nan if gi is None else gi.std(ddof=1) / gy.std(ddof=1),
            np.corrcoef(gc, gy)[0, 1],
            np.corrcoef(gy[1:], gy[:-1])[0, 1]]

mm = moments(dy, dc, di)
uu = moments(ey, ec)

tab = pd.DataFrame({
    "moment": ["sd(dy), % per quarter", "sd(dc)/sd(dy)", "sd(di)/sd(dy)",
               "corr(dc, dy)", "ac1(dy)"],
    "model":  np.round(mm, 3),
    "data":   np.round(uu, 3)})
import sys; nchars = sys.stdout.write(tab.to_string(index=False) + "\n")
               moment  model  data
sd(dy), % per quarter  0.723 1.105
        sd(dc)/sd(dy)  0.315 0.966
        sd(di)/sd(dy)  3.043   NaN
         corr(dc, dy)  0.953 0.735
              ac1(dy)  0.001 0.134
Code
sys.stdout.flush()
Code
quietly {
    import delimited "../data/mfsim-dsge.csv", clear
    tsset t
    generate double dy = (y - L.y)/100
    generate double dc = (c - L.c)/100
    generate double di = (i - L.i)/100
    summarize dy
    scalar m_sdy = r(sd)
    summarize dc
    scalar m_rc = r(sd)/m_sdy
    summarize di
    scalar m_ri = r(sd)/m_sdy
    correlate dc dy
    scalar m_cor = r(rho)
    generate double ly = L.dy
    correlate dy ly
    scalar m_ac = r(rho)

    import delimited "../data/mfsim-macro.csv", clear stringcols(3)
    tsset t
    generate double ey = dgdp/400
    generate double ec = dcons/400
    summarize ey
    scalar u_sdy = r(sd)
    summarize ec
    scalar u_rc = r(sd)/u_sdy
    correlate ec ey
    scalar u_cor = r(rho)
    generate double ley = L.ey
    correlate ey ley
    scalar u_ac = r(rho)
}

display "               moment      model       data" _newline ///
  "sd(dy), % per quarter " %10.3f 100*m_sdy " " %10.3f 100*u_sdy _newline ///
  "        sd(dc)/sd(dy) " %10.3f m_rc " " %10.3f u_rc _newline ///
  "        sd(di)/sd(dy) " %10.3f m_ri "         NA" _newline ///
  "         corr(dc, dy) " %10.3f m_cor " " %10.3f u_cor _newline ///
  "              ac1(dy) " %10.3f m_ac " " %10.3f u_ac
               moment      model       data
sd(dy), % per quarter      0.723      1.105
        sd(dc)/sd(dy)      0.315      0.966
        sd(di)/sd(dy)      3.043         NA
         corr(dc, dy)      0.953      0.735
              ac1(dy)      0.001      0.134

Where the RBC model wins and where it does not. It gets the shape right — investment three times as volatile as output, consumption smoother, both strongly procyclical. But consumption is far too smooth: the model says \(\sigma(\Delta c)/\sigma(\Delta y) = 0.32\) where the data say 0.97. Real households smooth almost nothing at quarterly frequency, and a frictionless permanent-income consumer smooths almost everything. The model also generates no growth persistence (ac1 of 0.00 against 0.13) because all the propagation runs through capital, which moves too slowly to show up in one quarter.

Why First Order Kills the Risk Premium

Part 3 spent eleven slides on the price of risk. This model has capital, a consumption Euler equation and a stochastic shock — everything needed to price risk. Yet its equity premium is exactly zero, and not because the calibration is unlucky.

A first-order perturbation is a Taylor expansion of the policy function around the deterministic steady state, taken to first order in the states and in the shock scale \(\sigma\):

\[c_t = \bar{c} + g_k \, k_t + g_a \, a_t + \underbrace{g_\sigma \, \sigma}_{= \; 0}\]

It is a theorem that \(g_\sigma = 0\): to first order the agent behaves exactly as she would if the future were certain. This is certainty equivalence, and it has a sharp, testable implication —

the first-order solution matrices \(F\) and \(P\) do not contain \(\sigma_a\) at all. Multiply the volatility of the economy by ten and the decision rules do not move by one bit.

In consequence the model’s own risk-free rate and expected return on capital coincide. From the Euler equation, the riskless rate is \(r^f_t = \mathbb{E}_t[c_{t+1}] - c_t\), while the return on capital satisfies \(c_t = \mathbb{E}_t[c_{t+1}] - \mathbb{E}_t[r_{t+1}]\), so

\[\mathbb{E}_t \left[ r_{t+1} \right] - r^f_t \;\equiv\; 0\]

identically, period by period, at every state. Risk premia are second-order objects: they are \(O(\sigma^2)\), and a first-order solution has thrown that term away before the question is asked.

Code
# 1. Certainty equivalence: solve the model at two very different volatilities
solve_rbc <- function(sig_a) {
  # sig_a never enters A or B -- that is the whole point
  ...                                    # identical to the Part 4 solver
  list(F = Fm, P = Pm)
}
m_low  <- solve_rbc(0.007)
m_high <- solve_rbc(0.070)               # ten times as volatile

# 2. The model's own risk premium, state by state
Erk  <- chi * (rho_a * s[, 2] + (alpha - 1) * knext)   # E_t[return on capital]
rf   <- Ec1 - c_sim                                    # E_t[c_{t+1}] - c_t
prem <- Erk - rf
1. Certainty equivalence -- solve at sigma_a = 0.007 and 0.070
     max |F(low) - F(high)|   0.000e+00
     max |P(low) - P(high)|   0.000e+00

2. The model's own risk premium over 1000 simulated quarters
     mean premium             -3.488e-18
     max |premium|            3.009e-17
     machine epsilon          2.220e-16
Code
import numpy as np
from scipy.linalg import ordqz

def solve_rbc(sig_a):
    al, bt, dl, ra = 0.36, 0.99, 0.025, 0.95
    kyy = al/(1/bt - 1 + dl); ph = 1/kyy; si = dl*kyy; sc = 1 - si
    ch = bt*al*ph; aa0 = (1 - dl) + ph*al
    Am = np.array([[1.,0,0],[0,1.,0],[-ch*(al-1),0,1.]])
    Bm = np.array([[aa0,ph,-ph*sc],[0,ra,0],[0,ch*ra,1.]])
    S_, T_, a_, b_, Q_, Z_ = ordqz(Bm, Am, sort="iuc", output="real")
    Z11 = Z_[:2, :2]
    return (Z_[2:, :2] @ np.linalg.inv(Z11),
            Z11 @ np.linalg.solve(T_[:2, :2], S_[:2, :2]) @ np.linalg.inv(Z11))

F_lo, P_lo = solve_rbc(0.007)
F_hi, P_hi = solve_rbc(0.070)             # ten times as volatile

rng = np.random.default_rng(14159)
Tn, sg = 1000, 0.007
ss = np.zeros((Tn, 2))
for t in range(1, Tn):
    ss[t] = P_lo @ ss[t-1]
    ss[t, 1] += sg * rng.normal()

c_sim = ss @ F_lo.ravel()
knext = P_lo[0, 0]*ss[:, 0] + P_lo[0, 1]*ss[:, 1]
Erk   = chi * (rho_a * ss[:, 1] + (alpha - 1) * knext)
Ec1   = (ss @ P_lo.T) @ F_lo.ravel()
prem  = Erk - (Ec1 - c_sim)

out = ("1. Certainty equivalence -- solve at sigma_a = 0.007 and 0.070\n"
       f"     max |F(low) - F(high)|   {np.abs(F_lo-F_hi).max():.3e}\n"
       f"     max |P(low) - P(high)|   {np.abs(P_lo-P_hi).max():.3e}\n"
       "\n2. The model's own risk premium over 1000 simulated quarters\n"
       f"     mean premium             {prem.mean():.3e}\n"
       f"     max |premium|            {np.abs(prem).max():.3e}\n"
       f"     machine epsilon          {np.finfo(float).eps:.3e}\n")
import sys; nchars = sys.stdout.write(out); sys.stdout.flush()
1. Certainty equivalence -- solve at sigma_a = 0.007 and 0.070
     max |F(low) - F(high)|   0.000e+00
     max |P(low) - P(high)|   0.000e+00

2. The model's own risk premium over 1000 simulated quarters
     mean premium             -2.572e-18
     max |premium|            4.499e-17
     machine epsilon          2.220e-16

Going to second order restores the missing term. The policy function becomes

\[c_t = \bar{c} + g_k k_t + g_a a_t + \tfrac{1}{2} g_{kk} k_t^2 + \dots + \tfrac{1}{2} \, g_{\sigma\sigma} \, \sigma^2\]

and \(g_{\sigma\sigma} \ne 0\). Three things change at once:

  • the stochastic steady state separates from the deterministic one — agents hold precautionary buffers, so the mean of the simulated economy is no longer the point the model was expanded around;
  • risk premia become non-zero, of order \(\sigma^2\);
  • premia are still constant, because time variation in risk needs third order or a model where volatility itself is a state.

Which is why the asset-pricing literature of Part 3 does not use perturbation on an RBC model. To get a premium worth 8.61% a year you need Epstein–Zin preferences and a persistent state — and then the \(O(\sigma^2)\) term has to be enormous, or the model has to be solved globally, as the Rouwenhorst chain in Part 3 did. The lesson is a warning: a linearised DSGE cannot answer an asset-pricing question, no matter how carefully it is calibrated.

Second Order — the Risk Correction, Computed

The previous slide proved a gap and then pointed at it. This one closes it, on the same three-equation model, same calibration, so every first-order matrix below is already scored against the truth columns of mfsim-dsge.csv.

Schmitt-Grohé and Uribe (2004) take the expansion one order further. The policy function gains the quadratic terms in the states and one constant:

\[c_t = \bar{c} + g_k k_t + g_a a_t + \tfrac{1}{2}\!\left( g_{kk} k_t^2 + 2 g_{ka} k_t a_t + g_{aa} a_t^2 \right) + \tfrac{1}{2} \, g_{\sigma\sigma} \, \sigma_a^2\]

That last term is the one certainty equivalence killed. \(g_{\sigma\sigma} \ne 0\) is the risk correction: it shifts the stochastic steady state away from the deterministic one, and it is \(O(\sigma^2)\) — exactly the order the previous slide proved a first-order solution discards.

How it is computed, with no new packages. Each of the three nonlinear equilibrium conditions is a sum of terms \(s \, e^{w'z}\) in \(z = (c_{t+1}, c_t, k_{t+1}, a_{t+1}, k_t, a_t)\). Every derivative of an exponential is an exponential, so the first- and second-derivative tensors are exact — there is no numerical differencing anywhere. Two linear systems then follow: nine equations for the state terms \((g_{xx}, h_{xx})\), and three more for \((g_{\sigma\sigma}, h_{\sigma\sigma})\).

The premium is the \(O(\sigma^2)\) object itself. With log utility only the technology innovation is priced, so

\[\mathbb{E}_t\!\left[ r_{t+1} \right] - r^f_t = -\mathrm{Cov}_t\!\left( m_{t+1}, r_{t+1} \right) - \tfrac{1}{2}\mathbb{V}_t\!\left( r_{t+1} \right) = \chi \sigma_a^2 \left( g_a - \tfrac{\chi}{2} \right)\]

Code
# z = (c', c, k', a', k, a). Each equilibrium condition is a sum of terms
# s*exp(w'z), so every derivative tensor below is exact.
solve2 <- function(delta = 0.025) {
  alpha <- 0.36; bta <- 0.99; rho_a <- 0.95
  ky  <- alpha / (1/bta - 1 + delta)
  phi <- 1/ky; s_c <- 1 - delta*ky
  chi <- bta*alpha*phi
  a0  <- (1 - delta) + phi*alpha

  # first order: the QZ solution of the earlier slide, unchanged
  A <- matrix(c(1, 0, 0,  0, 1, 0,  -chi*(alpha-1), 0, 1), 3, 3, byrow = TRUE)
  B <- matrix(c(a0, phi, -phi*s_c,  0, rho_a, 0,  0, chi*rho_a, 1), 3, 3, byrow = TRUE)
  e   <- qz.dgges(B, A)
  o   <- qz.dtgsen(e$S, e$T, e$Q, e$Z, select = abs(e$ALPHAR/e$BETA) < 1)
  Z11 <- o$Z[1:2, 1:2]
  gx  <- as.vector(o$Z[3, 1:2] %*% solve(Z11))
  hx  <- Z11 %*% solve(o$T[1:2, 1:2]) %*% o$S[1:2, 1:2] %*% solve(Z11)

  # the nonlinear conditions, term by term
  sc1 <- c(1, -bta*(1-delta), -bta*alpha*phi)
  wt1 <- rbind(c(0,-1,0,0,0,0), c(-1,0,0,0,0,0), c(-1,0,alpha-1,1,0,0))
  sc2 <- c(1, -phi, -(1-delta), s_c*phi)
  wt2 <- rbind(c(0,0,1,0,0,0), c(0,0,0,0,alpha,1), c(0,0,0,0,1,0), c(0,1,0,0,0,0))

  grad <- function(s, w) { g <- rep(0, 6)
                           for (j in seq_along(s)) g <- g + s[j]*w[j, ]; g }
  hess <- function(s, w) { H <- matrix(0, 6, 6)
                           for (j in seq_along(s)) H <- H + s[j]*outer(w[j, ], w[j, ]); H }

  J <- rbind(grad(sc1, wt1), grad(sc2, wt2), c(0, 0, 0, 1, 0, -rho_a))
  H <- list(hess(sc1, wt1), hess(sc2, wt2), matrix(0, 6, 6))
  M <- rbind(gx %*% hx, gx, hx, diag(2))          # dz/dx

  # second order in the states: 9 unknowns, 9 equations
  sym <- function(a, b, c) matrix(c(a, b, b, c), 2, 2)
  d2z <- function(u, j, l) {
    G <- sym(u[1], u[2], u[3]); Hk <- sym(u[4], u[5], u[6]); Ha <- sym(u[7], u[8], u[9])
    hh <- c(Hk[j, l], Ha[j, l])
    c(as.numeric(hx[, j] %*% G %*% hx[, l]) + sum(gx*hh), G[j, l], hh, 0, 0)
  }
  pr <- list(c(1, 1), c(1, 2), c(2, 2))
  Am <- matrix(0, 9, 9); bv <- numeric(9); r <- 0
  for (i in 1:3) for (p in pr) {
    r <- r + 1
    for (q in 1:9) {
      u <- numeric(9); u[q] <- 1
      Am[r, q] <- sum(J[i, ] * d2z(u, p[1], p[2]))
    }
    bv[r] <- -as.numeric(M[, p[1]] %*% H[[i]] %*% M[, p[2]])
  }
  u <- solve(Am, bv)

  # the sigma-sigma terms: 3 unknowns, 3 equations
  eta <- c(0, 1)
  gaa <- u[3]                                # g_xx contracted with eta twice
  d   <- c(sum(gx*eta), 0, eta[1], eta[2], 0, 0)
  A2  <- matrix(0, 3, 3); b2 <- numeric(3)
  for (i in 1:3) {
    for (q in 1:3) {
      v  <- numeric(3); v[q] <- 1
      dz <- c(sum(gx*v[2:3]) + v[1], v[1], v[2], v[3], 0, 0)
      A2[i, q] <- sum(J[i, ] * dz)
    }
    b2[i] <- -(J[i, 1]*gaa + as.numeric(d %*% H[[i]] %*% d))
  }
  v <- solve(A2, b2)
  list(gx = gx, hx = hx, gxx = u[1:3], gss = v[1], hss = v[2:3], chi = chi)
}

m    <- solve2()
prem <- function(m, sa) m$chi * sa^2 * (m$gx[2] - m$chi/2)
first order -- unchanged, certainty equivalence still holds
  g_k     0.618247    g_a     0.305243
  A_k     0.965276    B_a     0.075372

second order in the states
  g_kk    0.045676    g_ka   -0.083994    g_aa    0.107263

the risk correction first order threw away
  g_sigsig                0.011937154
  h_sigsig, capital      -0.000865477
  h_sigsig, technology    0.000000000
  check: at delta = 1 the model is closed form with no risk
  correction, and the solver returns   -0.000000000

O(sigma^2) scaling -- double sigma_a and both quadruple
   sigma_a     0.5*g_sigsig*sigma_a^2   risk premium, % per yr
     0.007               2.924603e-07             1.960669e-04
     0.014               1.169841e-06             7.842675e-04
     ratio                   4.000000                 4.000000
Code
import numpy as np
from scipy.linalg import ordqz

# z = (c', c, k', a', k, a). Each equilibrium condition is a sum of terms
# s*exp(w'z), so every derivative tensor below is exact.
def solve2(delta=0.025):
    alpha, bta, rho_a = 0.36, 0.99, 0.95
    ky  = alpha / (1/bta - 1 + delta)
    phi = 1/ky; s_c = 1 - delta*ky
    chi = bta*alpha*phi
    a0  = (1 - delta) + phi*alpha

    # first order: the QZ solution of the earlier slide, unchanged
    A = np.array([[1., 0, 0], [0, 1., 0], [-chi*(alpha-1), 0, 1.]])
    B = np.array([[a0, phi, -phi*s_c], [0, rho_a, 0], [0, chi*rho_a, 1.]])
    S, T, al, be, Q, Z = ordqz(B, A, sort="iuc", output="real")
    Z11 = Z[:2, :2]
    gx  = Z[2, :2] @ np.linalg.inv(Z11)
    hx  = Z11 @ np.linalg.solve(T[:2, :2], S[:2, :2]) @ np.linalg.inv(Z11)

    # the nonlinear conditions, term by term
    sc1 = np.array([1.0, -bta*(1-delta), -bta*alpha*phi])
    wt1 = np.array([[0,-1,0,0,0,0], [-1,0,0,0,0,0], [-1,0,alpha-1,1,0,0]], float)
    sc2 = np.array([1.0, -phi, -(1-delta), s_c*phi])
    wt2 = np.array([[0,0,1,0,0,0], [0,0,0,0,alpha,1], [0,0,0,0,1,0], [0,1,0,0,0,0]], float)

    def grad(s, w):
        g = np.zeros(6)
        for j in range(len(s)):
            g = g + s[j]*w[j]
        return g

    def hess(s, w):
        H = np.zeros((6, 6))
        for j in range(len(s)):
            H = H + s[j]*np.outer(w[j], w[j])
        return H

    J = np.vstack([grad(sc1, wt1), grad(sc2, wt2),
                   np.array([0, 0, 0, 1., 0, -rho_a])])
    H = [hess(sc1, wt1), hess(sc2, wt2), np.zeros((6, 6))]
    M = np.vstack([gx @ hx, gx, hx, np.eye(2)])          # dz/dx

    # second order in the states: 9 unknowns, 9 equations
    def sym(a, b, c):
        return np.array([[a, b], [b, c]])

    def d2z(u, j, l):
        G = sym(u[0], u[1], u[2]); Hk = sym(u[3], u[4], u[5]); Ha = sym(u[6], u[7], u[8])
        hh = np.array([Hk[j, l], Ha[j, l]])
        return np.array([hx[:, j] @ G @ hx[:, l] + gx @ hh, G[j, l], hh[0], hh[1], 0, 0])

    pr = [(0, 0), (0, 1), (1, 1)]
    Am = np.zeros((9, 9)); bv = np.zeros(9); r = 0
    for i in range(3):
        for (j, l) in pr:
            for q in range(9):
                u = np.zeros(9); u[q] = 1
                Am[r, q] = J[i] @ d2z(u, j, l)
            bv[r] = -(M[:, j] @ H[i] @ M[:, l])
            r += 1
    u = np.linalg.solve(Am, bv)

    # the sigma-sigma terms: 3 unknowns, 3 equations
    eta = np.array([0.0, 1.0])
    gaa = u[2]                                # g_xx contracted with eta twice
    d   = np.array([gx @ eta, 0, eta[0], eta[1], 0, 0])
    A2 = np.zeros((3, 3)); b2 = np.zeros(3)
    for i in range(3):
        for q in range(3):
            v  = np.zeros(3); v[q] = 1
            dz = np.array([gx @ v[1:3] + v[0], v[0], v[1], v[2], 0, 0])
            A2[i, q] = J[i] @ dz
        b2[i] = -(J[i, 0]*gaa + d @ H[i] @ d)
    v = np.linalg.solve(A2, b2)
    return dict(gx=gx, hx=hx, gxx=u[:3], gss=v[0], hss=v[1:3], chi=chi)

m = solve2()
def prem(m, sa):
    return m["chi"] * sa**2 * (m["gx"][1] - m["chi"]/2)

out = ("first order -- unchanged, certainty equivalence still holds\n"
       f"  g_k {m['gx'][0]:12.6f}    g_a {m['gx'][1]:12.6f}\n"
       f"  A_k {m['hx'][0,0]:12.6f}    B_a {m['hx'][0,1]:12.6f}\n"
       "\nsecond order in the states\n"
       f"  g_kk {m['gxx'][0]:11.6f}    g_ka {m['gxx'][1]:11.6f}"
       f"    g_aa {m['gxx'][2]:11.6f}\n"
       "\nthe risk correction first order threw away\n"
       f"  g_sigsig             {m['gss']:14.9f}\n"
       f"  h_sigsig, capital    {m['hss'][0]:14.9f}\n"
       f"  h_sigsig, technology {m['hss'][1]:14.9f}\n"
       "  check: at delta = 1 the model is closed form with no risk\n"
       f"  correction, and the solver returns {solve2(delta=1.0)['gss']:14.9f}\n"
       "\nO(sigma^2) scaling -- double sigma_a and both quadruple\n"
       f"  {'sigma_a':>8} {'0.5*g_sigsig*sigma_a^2':>26} {'risk premium, % per yr':>24}\n"
       f"  {0.007:8.3f} {0.5*m['gss']*0.007**2:26.6e} {400*prem(m, 0.007):24.6e}\n"
       f"  {0.014:8.3f} {0.5*m['gss']*0.014**2:26.6e} {400*prem(m, 0.014):24.6e}\n"
       f"  {'ratio':>8} {(0.5*m['gss']*0.014**2)/(0.5*m['gss']*0.007**2):26.6f}"
       f" {prem(m, 0.014)/prem(m, 0.007):24.6f}\n")
import sys; nchars = sys.stdout.write(out); sys.stdout.flush()
first order -- unchanged, certainty equivalence still holds
  g_k     0.618247    g_a     0.305243
  A_k     0.965276    B_a     0.075372

second order in the states
  g_kk    0.045676    g_ka   -0.083994    g_aa    0.107263

the risk correction first order threw away
  g_sigsig                0.011937154
  h_sigsig, capital      -0.000865477
  h_sigsig, technology    0.000000000
  check: at delta = 1 the model is closed form with no risk
  correction, and the solver returns   -0.000000000

O(sigma^2) scaling -- double sigma_a and both quadruple
   sigma_a     0.5*g_sigsig*sigma_a^2   risk premium, % per yr
     0.007               2.924603e-07             1.960669e-04
     0.014               1.169841e-06             7.842675e-04
     ratio                   4.000000                 4.000000

Stata’s dsge and dsgenl are first-order solvers only — there is no second-order option and no estat that reports one. Everything on this slide is therefore out of reach in Stata without rewriting the perturbation from scratch in Mata, which is a different exercise from the one this deck is running. The R and Python tabs are the reference.

The missing term, measured — and it is real but tiny. \(g_{\sigma\sigma} = 0.011937\) is not zero, so the stochastic steady state genuinely separates from the deterministic one. Two checks say the machinery is right: the first-order block reproduces \(g_k = 0.618247\) and \(A_k = 0.965276\) exactly, and at \(\delta = 1\) — where log utility gives the exact closed form \(C_t = (1-\alpha\beta)Y_t\), a savings rate that cannot depend on risk — the solver returns \(g_{\sigma\sigma} = 0\) to machine precision.

Then the economics. The consumption correction is \(2.9 \times 10^{-7}\) in log units and the risk premium is 0.000196% a year, about one fiftieth of a basis point, against the 8.61% in the data. Doubling \(\sigma_a\) multiplies both by exactly 4.00 — the \(O(\sigma^2)\) scaling demonstrated rather than asserted, and the mirror image of the previous slide’s \(\sigma \times 10\) demonstration that first order does not move at all. Second order buys a premium that is non-zero and economically negligible: the term was missing, and finding it does not rescue the model.

Financial Frictions — the External Finance Premium

In the model so far, capital is financed at the riskless rate. Firms borrow whatever they wish at \(r^f\) and the balance sheet plays no role. That is a strong assumption, and 2008 is the standard argument against it.

Bernanke, Gertler and Gilchrist (1999) start from the observation that lenders cannot costlessly verify what a borrower earns. Monitoring is expensive, so the optimal contract makes borrowers pay a premium over the riskless rate — and that premium falls as their net worth rises:

\[\text{external finance premium} = s\!\left( \frac{\text{net worth}}{\text{capital}} \right), \qquad s' < 0\]

The loop is the point. A negative shock cuts profits, profits are net worth, lower net worth raises the premium, a higher premium cuts investment, and lower investment cuts output further. A small shock becomes a large recession. That is the financial accelerator.

What this deck actually implements is weaker, and the honest label matters. mfsim-data.R adds a reduced-form spread block: net worth responds to output and the spread falls when net worth is high. It reproduces the countercyclical spread without the feedback that makes an accelerator accelerate. The local projection slide that closes this part shows exactly what that costs.

The full BGG contract adds a state variable — entrepreneurial net worth \(n_t\) — and an equilibrium condition linking the expected return on capital to the riskless rate plus a premium:

\[\mathbb{E}_t \left[ r^k_{t+1} \right] - r^f_t = \nu \left( q_t + k_{t+1} - n_{t+1} \right), \qquad \nu > 0\]

with \(q_t\) the price of capital and the bracket the leverage ratio. Net worth evolves out of retained returns:

\[n_{t+1} = \rho_n n_t + \text{(return on capital)} - \text{(cost of debt)} + \varepsilon^n_{t+1}\]

The overlay used here keeps the second equation’s spirit and drops the general equilibrium feedback:

\[n_t = \rho_n \, n_{t-1} + \kappa \, y_{t-1} + \sigma_n \, \varepsilon^n_t, \qquad s_t = -\nu \left( n_t - k_t \right)\]

with \(\nu = 0.05\), \(\rho_n = 0.90\), \(\kappa = 2.0\). Net worth is driven by output; output is not driven by net worth. The spread is a passive read-out of the cycle, not a propagation mechanism.

Financial Accelerator — Simulated Spread vs BAA-10Y

The model’s spread against the actual BAA-over-10-year Treasury spread. All three tabs read the same two CSVs.

Code
md <- read.csv("../data/mfsim-dsge.csv")
us <- read.csv("../data/mfsim-macro.csv")
us <- us[!is.na(us$spread) & !is.na(us$dgdp), ]

cmp <- data.frame(
  statistic = c("sd of the spread", "corr(spread, activity)",
                "ac1 of the spread", "sample size"),
  model = c(sd(md$spread), cor(md$spread, md$y),
            cor(md$spread[-1], md$spread[-nrow(md)]), nrow(md)),
  data  = c(sd(us$spread), cor(us$spread, us$dgdp),
            cor(us$spread[-1], us$spread[-nrow(us)]), nrow(us)))
US spread sample: 1986Q1 to 2026Q2

              statistic    model    data
       sd of the spread    2.002   0.678
 corr(spread, activity)   -0.801  -0.301
      ac1 of the spread    0.997   0.890
            sample size 1000.000 162.000
Code
import numpy as np
import pandas as pd

md = pd.read_csv("../data/mfsim-dsge.csv")
us = pd.read_csv("../data/mfsim-macro.csv").dropna(subset=["spread", "dgdp"])
nm, nu = len(md), len(us)

cmp = pd.DataFrame({
    "statistic": ["sd of the spread", "corr(spread, activity)",
                  "ac1 of the spread", "sample size"],
    "model": [md["spread"].std(ddof=1),
              np.corrcoef(md["spread"], md["y"])[0, 1],
              np.corrcoef(md["spread"][1:], md["spread"][:-1])[0, 1], nm],
    "data":  [us["spread"].std(ddof=1),
              np.corrcoef(us["spread"], us["dgdp"])[0, 1],
              np.corrcoef(us["spread"][1:], us["spread"][:-1])[0, 1], nu]})
cmp["model"] = cmp["model"].round(3)
cmp["data"]  = cmp["data"].round(3)

out = (f"US spread sample: {us['qtr'].iloc[0]} to {us['qtr'].iloc[-1]}\n\n"
       + cmp.to_string(index=False) + "\n")
import sys; nchars = sys.stdout.write(out); sys.stdout.flush()
US spread sample: 1986Q1 to 2026Q2

             statistic    model    data
      sd of the spread    2.002   0.678
corr(spread, activity)   -0.801  -0.301
     ac1 of the spread    0.997   0.890
           sample size 1000.000 162.000
Code
quietly {
    import delimited "../data/mfsim-dsge.csv", clear
    tsset t
    summarize spread
    scalar m_sd = r(sd)
    scalar m_n  = r(N)
    correlate spread y
    scalar m_cor = r(rho)
    generate double lsp = L.spread
    correlate spread lsp
    scalar m_ac = r(rho)

    import delimited "../data/mfsim-macro.csv", clear stringcols(3)
    tsset t
    drop if missing(spread) | missing(dgdp)
    summarize spread
    scalar u_sd = r(sd)
    scalar u_n  = r(N)
    correlate spread dgdp
    scalar u_cor = r(rho)
    generate double lsp = L.spread
    correlate spread lsp
    scalar u_ac = r(rho)
    local q1 = qtr[1]
    local q2 = qtr[_N]
}

display "US spread sample: `q1' to `q2'" _newline ///
  _newline ///
  "             statistic      model       data" _newline ///
  "      sd of the spread " %10.3f m_sd " " %10.3f u_sd _newline ///
  "corr(spread, activity) " %10.3f m_cor " " %10.3f u_cor _newline ///
  "     ac1 of the spread " %10.3f m_ac " " %10.3f u_ac _newline ///
  "           sample size " %10.0f m_n " " %10.0f u_n
US spread sample: 1986Q1 to 2026Q2

             statistic      model       data
      sd of the spread      2.002      0.678
corr(spread, activity)     -0.801     -0.301
     ac1 of the spread      0.997      0.890
           sample size       1000        162

On the two moments it was built to hit, the overlay does well: the spread is countercyclical in both (model \(-0.80\), data \(-0.30\)) and highly persistent in both. It is three times too volatile, which a tighter \(\nu\) would fix. On this evidence the block looks like a reasonable stand-in for a financial accelerator — which is exactly why the next slide asks a harder question.

Model IRFs vs Empirical Local Projections

Jordà (2005) local projections: regress cumulative output growth \(h\) quarters ahead on today’s credit spread, one horizon at a time, with two lags of both variables and Newey–West standard errors.

\[\sum_{j=0}^{h} \Delta y_{t+j} = \alpha_h + \beta_h \, s_t + \text{controls} + u_{t+h}\]

The identical regression is run on the US data and on the model’s own simulated data. If the overlay is a good stand-in, the two \(\beta_h\) paths should look alike.

Code
lp_path <- function(g, sp, H = 12) {
  n <- length(g)
  b <- numeric(H + 1); se <- numeric(H + 1)
  for (h in 0:H) {
    yy <- rep(NA, n)
    for (t in 1:(n - h)) yy[t] <- sum(g[t:(t + h)])
    df  <- data.frame(y = yy, s = sp,
                      g1 = c(NA, g[-n]), g2 = c(NA, NA, g[-((n-1):n)]),
                      s1 = c(NA, sp[-n]), s2 = c(NA, NA, sp[-((n-1):n)]))
    fit <- lm(y ~ s + g1 + g2 + s1 + s2, data = df)
    V   <- NeweyWest(fit, lag = h + 1, prewhite = FALSE)
    b[h + 1]  <- coef(fit)["s"]
    se[h + 1] <- sqrt(V["s", "s"])
  }
  data.frame(h = 0:H, beta = b, se = se)
}

us  <- read.csv("../data/mfsim-macro.csv")
us  <- us[!is.na(us$spread) & !is.na(us$dgdp), ]
dat <- lp_path(us$dgdp / 400 * 100, us$spread)

md  <- read.csv("../data/mfsim-dsge.csv")
mod <- lp_path(diff(md$y), md$spread[-1])


data : trough -2.738 at h = 5 ; h=0 -1.365 ; h=12 -2.552
model: trough -0.091 at h = 2 ; h=0 1.355 ; h=12 2.237
Code
import numpy as np
import pandas as pd
import statsmodels.api as sm
import matplotlib.pyplot as plt

def lp_path(g, sp, H=12):
    n = len(g); b = np.zeros(H+1); se = np.zeros(H+1)
    for h in range(H+1):
        yy = np.full(n, np.nan)
        for t in range(n-h):
            yy[t] = g[t:t+h+1].sum()
        X = np.column_stack([sp,
                             np.r_[np.nan, g[:-1]], np.r_[np.nan, np.nan, g[:-2]],
                             np.r_[np.nan, sp[:-1]], np.r_[np.nan, np.nan, sp[:-2]]])
        ok = ~np.isnan(yy) & ~np.isnan(X).any(axis=1)
        fit = sm.OLS(yy[ok], sm.add_constant(X[ok])).fit(
            cov_type="HAC", cov_kwds={"maxlags": h+1, "use_correction": False})
        b[h] = fit.params[1]; se[h] = fit.bse[1]
    return pd.DataFrame({"h": np.arange(H+1), "beta": b, "se": se})

us = pd.read_csv("../data/mfsim-macro.csv").dropna(subset=["spread", "dgdp"])
dat = lp_path((us["dgdp"] / 400 * 100).to_numpy(), us["spread"].to_numpy())
md  = pd.read_csv("../data/mfsim-dsge.csv")
mod = lp_path(np.diff(md["y"].to_numpy()), md["spread"].to_numpy()[1:])

fig, ax = plt.subplots(figsize=(8, 4.4))
ax.axhline(0, color="#999999", lw=0.8)
ax.fill_between(dat["h"], dat["beta"] - 1.645*dat["se"],
                dat["beta"] + 1.645*dat["se"], color="#185FA5", alpha=0.15)
ax.plot(dat["h"], dat["beta"], color="#185FA5", lw=1.8)
ax.plot(mod["h"], mod["beta"], color="#D85A30", lw=1.8)
ax.text(6.2, -3.3, "US data, 90% band", color="#185FA5", fontsize=9)
ax.text(6.2,  3.2, "model", color="#D85A30", fontsize=9)
ax.text(0.2, -4.1,
        f"data trough {dat['beta'].min():.2f} at h={int(dat['beta'].idxmin())}   "
        f"model trough {mod['beta'].min():.2f}", color="#4d4d4d", fontsize=8)
axopts = ax.set(xlim=(0, 12), ylim=(-4.5, 4.5),
                xticks=np.arange(0, 13, 3), yticks=np.arange(-4, 5, 2),
                xlabel="quarters ahead", ylabel="percent",
                title="Cumulative output response to a 1pp credit-spread innovation")
plt.show()

Code
quietly {
    import delimited "../data/mfsim-macro.csv", clear stringcols(3)
    tsset t
    drop if missing(spread) | missing(dgdp)
    generate double g = dgdp/400*100
    generate double s = spread
    tempname pf
    tempfile lpout
    postfile `pf' h beta se using `lpout'
    forvalues h = 0/12 {
        capture drop ycum
        generate double ycum = 0
        forvalues j = 0/`h' {
            quietly replace ycum = ycum + F`j'.g
        }
        newey ycum s L.g L2.g L.s L2.s, lag(`=`h'+1')
        post `pf' (`h') (_b[s]) (_se[s])
    }
    postclose `pf'
    use `lpout', clear
    generate double lo = beta - 1.645*se
    generate double hi = beta + 1.645*se
    tempfile datlp
    save `datlp'

    * the identical regression on the model's own simulated data
    import delimited "../data/mfsim-dsge.csv", clear
    tsset t
    generate double g = D.y
    generate double s = spread
    tempname pf2
    tempfile lpout2
    postfile `pf2' h mbeta using `lpout2'
    forvalues h = 0/12 {
        capture drop ycum
        generate double ycum = 0
        forvalues j = 0/`h' {
            quietly replace ycum = ycum + F`j'.g
        }
        newey ycum s L.g L2.g L.s L2.s, lag(`=`h'+1')
        post `pf2' (`h') (_b[s])
    }
    postclose `pf2'
    use `lpout2', clear
    merge 1:1 h using `datlp', nogenerate
}

twoway (rarea lo hi h, color("24 95 165%15") lwidth(none))                ///
       (line beta h, lcolor("24 95 165") lwidth(medthick))                ///
       (line mbeta h, lcolor("216 90 48") lwidth(medthick)),              ///
  yline(0, lcolor(gs9) lwidth(vthin))                                     ///
  xscale(range(0 12)) yscale(range(-4.5 4.5))                             ///
  xlabel(0(3)12) ylabel(-4(2)4)                                           ///
  xtitle("quarters ahead") ytitle("percent")                              ///
  title("Cumulative output response to a 1pp credit-spread innovation",   ///
        size(medsmall))                                                   ///
  text(-3.3 6.2 "US data, 90% band" , color("24 95 165") size(vsmall)     ///
       placement(e) justification(left))                                  ///
  text(3.2 6.2 "model", color("216 90 48") size(vsmall)                   ///
       placement(e) justification(left))                                  ///
  legend(off) graphregion(color(white))
graph export "../plots/mfsim-lp-stata.png", replace width(1600)
file ../plots/mfsim-lp-stata.png written in PNG format

Stata also ships lpirf, which estimates the whole local-projection system with Cholesky identification. It is written out longhand here with newey so the specification is identical to the R and Python tabs, digit for digit.

Part 5 — Term Structure Simulation

Vasicek and CIR, closed-form bond prices, Nelson-Siegel-Svensson, dynamic
Nelson-Siegel, and bond-portfolio scenarios.

Affine Term Structure Models

Part 4 produced one interest rate. A bond market has hundreds — one for every maturity — and they move together but not identically. The oldest and still most useful way to organise that is an affine model: assume a single state variable \(r_t\) drives everything, and that every yield is a linear function of it,

\[y_t(\tau) = a(\tau) + b(\tau) \, r_t\]

The whole curve then collapses to one number plus two deterministic functions of maturity. Simulating the entire term structure reduces to simulating a scalar.

Two classics differ in exactly one place — what happens to volatility when rates approach zero.

  • Vasicek (1977): constant volatility. Gaussian, tractable, and it lets the rate go negative.
  • Cox–Ingersoll–Ross (1985): volatility proportional to \(\sqrt{r}\), so it vanishes as the rate approaches zero and the process stays positive — provided a condition holds.

Both were simulated in Part 1 by their exact transition laws, so nothing on these slides is contaminated by discretisation error — Part 2 showed what that would cost.

\[dr_t = \kappa \left( \theta - r_t \right) dt + \sigma \, dW_t \qquad \text{(Vasicek)}\]

\[dr_t = \kappa \left( \theta - r_t \right) dt + \sigma \sqrt{r_t} \, dW_t \qquad \text{(CIR)}\]

Under both, bond prices are exponential-affine, \(P_t(\tau) = e^{A(\tau) - B(\tau) r_t}\), so yields are

\[y_t(\tau) = -\frac{A(\tau) - B(\tau) \, r_t}{\tau}\]

Vasicek, with market price of risk \(\lambda\) folded into the risk-neutral mean \(\theta^{\mathbb{Q}} = \theta - \lambda\sigma/\kappa\):

\[B(\tau) = \frac{1 - e^{-\kappa\tau}}{\kappa}, \qquad A(\tau) = \frac{\left( B(\tau) - \tau \right)\left( \kappa^2\theta^{\mathbb{Q}} - \sigma^2/2 \right)}{\kappa^2} - \frac{\sigma^2 B(\tau)^2}{4\kappa}\]

CIR, with \(\kappa^{\mathbb{Q}} = \kappa - \lambda\sigma\) and \(\gamma = \sqrt{(\kappa^{\mathbb{Q}})^2 + 2\sigma^2}\):

\[B(\tau) = \frac{2\left( e^{\gamma\tau} - 1 \right)}{\left( \gamma + \kappa^{\mathbb{Q}} \right)\left( e^{\gamma\tau} - 1 \right) + 2\gamma}\]

\[A(\tau) = \frac{2\kappa\theta}{\sigma^2} \ln \frac{2\gamma \, e^{(\gamma + \kappa^{\mathbb{Q}})\tau/2}}{\left( \gamma + \kappa^{\mathbb{Q}} \right)\left( e^{\gamma\tau} - 1 \right) + 2\gamma}\]

The Feller condition guarantees CIR never reaches zero:

\[2 \kappa \theta > \sigma^2\]

Vasicek & CIR — Simulating the Short Rate

Reading the 3000 monthly draws in ../data/mfsim-term.csv, generated from the exact transition laws — Gaussian for Vasicek, non-central \(\chi^2\) for CIR.

Code
tm <- read.csv("../data/mfsim-term.csv")

kap <- tm$ckappa_true[1]; th <- tm$ctheta_true[1]; sig <- tm$csigma_true[1]
feller <- 2 * kap * th > sig^2

summ <- function(x) c(mean = mean(x), sd = sd(x), min = min(x), max = max(x),
                      pct_neg = 100 * mean(x < 0))
tab <- rbind(Vasicek = summ(tm$r_vasicek), CIR = summ(tm$r_cir))
Short rate, 3000 monthly draws, exact transitions (percent)

   model  mean    sd    min    max pct_neg
 Vasicek 4.064 2.018 -1.608 11.176     1.5
     CIR 4.140 1.391  1.195 10.931     0.0

CIR Feller condition  2*kappa*theta = 0.0315  >  sigma^2 = 0.0036   SATISFIED
Vasicek has no such guarantee and is negative in 1.50% of the sample.
Code
import numpy as np
import pandas as pd

tm = pd.read_csv("../data/mfsim-term.csv")

kap, th, sig = tm["ckappa_true"][0], tm["ctheta_true"][0], tm["csigma_true"][0]
feller = 2 * kap * th > sig**2

def summ(x):
    return [100*x.mean(), 100*x.std(ddof=1), 100*x.min(), 100*x.max(),
            100*(x < 0).mean()]

tab = pd.DataFrame([["Vasicek"] + summ(tm["r_vasicek"]),
                    ["CIR"]     + summ(tm["r_cir"])],
                   columns=["model", "mean", "sd", "min", "max", "pct_neg"])
tab.iloc[:, 1:] = tab.iloc[:, 1:].astype(float).round(3)

out = (f"Short rate, {len(tm)} monthly draws, exact transitions (percent)\n\n"
       + tab.to_string(index=False)
       + f"\n\nCIR Feller condition  2*kappa*theta = {2*kap*th:.4f}  >  "
         f"sigma^2 = {sig**2:.4f}   "
       + ("SATISFIED" if feller else "VIOLATED")
       + f"\nVasicek has no such guarantee and is negative in "
         f"{100*(tm['r_vasicek']<0).mean():.2f}% of the sample.\n")
import sys; nchars = sys.stdout.write(out); sys.stdout.flush()
Short rate, 3000 monthly draws, exact transitions (percent)

  model  mean    sd    min    max  pct_neg
Vasicek 4.064 2.018 -1.608 11.176      1.5
    CIR 4.140 1.391  1.195 10.931      0.0

CIR Feller condition  2*kappa*theta = 0.0315  >  sigma^2 = 0.0036   SATISFIED
Vasicek has no such guarantee and is negative in 1.50% of the sample.
Code
quietly {
    import delimited "../data/mfsim-term.csv", clear
    scalar kap = ckappa_true[1]
    scalar th  = ctheta_true[1]
    scalar sig = csigma_true[1]
    scalar nT  = _N

    summarize r_vasicek
    scalar vm = 100*r(mean)
    scalar vs = 100*r(sd)
    scalar vlo = 100*r(min)
    scalar vhi = 100*r(max)
    count if r_vasicek < 0
    scalar vneg = 100*r(N)/nT

    summarize r_cir
    scalar cm = 100*r(mean)
    scalar cs = 100*r(sd)
    scalar clo = 100*r(min)
    scalar chi_ = 100*r(max)
    count if r_cir < 0
    scalar cneg = 100*r(N)/nT
}

display "Short rate, " %4.0f nT " monthly draws, exact transitions (percent)" ///
  _newline _newline ///
  "  model      mean        sd       min       max   pct_neg" _newline ///
  "Vasicek " %9.3f vm " " %9.3f vs " " %9.3f vlo " " %9.3f vhi " " %9.3f vneg ///
  _newline ///
  "    CIR " %9.3f cm " " %9.3f cs " " %9.3f clo " " %9.3f chi_ " " %9.3f cneg ///
  _newline _newline ///
  "CIR Feller condition  2*kappa*theta = " %6.4f 2*kap*th ///
  "  >  sigma^2 = " %6.4f sig^2 "   " ///
  cond(2*kap*th > sig^2, "SATISFIED", "VIOLATED") _newline ///
  "Vasicek has no such guarantee and is negative in " %5.2f vneg "% of the sample."
Short rate, 3000 monthly draws, exact transitions (percent)

  model      mean        sd       min       max   pct_neg
Vasicek     4.064     0.015    -1.608    11.176     1.500
    CIR     4.140     0.060     1.195    10.931     0.000

CIR Feller condition  2*kappa*theta = 0.0315  >  sigma^2 = 0.0036   SATISFIED
Vasicek has no such guarantee and is negative in  1.50% of the sample.

Same mean, same broad range, one decisive difference: Vasicek spends 1.50% of the sample below zero; CIR never does. For most of the post-war period that looked like a fatal flaw in Vasicek. After 2009 it looked like a feature — and the right way to choose is to ask whether the question tolerates negative rates, not whether the model is elegant.

From Short Rate to Yield Curve

reference depth — scrollable, read after class

The affine formulas turn one number into a whole curve. Below, \(A(\tau)\) and \(B(\tau)\) are evaluated on a fine maturity grid and the curve is drawn at three levels of the short rate — its 10th percentile, median and 90th percentile in the simulated sample.

Code
tm <- read.csv("../data/mfsim-term.csv")
vk <- tm$vkappa_true[1]; vt <- tm$vtheta_true[1]; vs <- tm$vsigma_true[1]
ck <- tm$ckappa_true[1]; ct <- tm$ctheta_true[1]; cs <- tm$csigma_true[1]
lam <- tm$lambda_true[1]

vas_y <- function(r, tau) {
  thq <- vt - lam * vs / vk
  B   <- (1 - exp(-vk * tau)) / vk
  A   <- (B - tau) * (vk^2 * thq - vs^2 / 2) / vk^2 - vs^2 * B^2 / (4 * vk)
  -(A - B * r) / tau
}
cir_y <- function(r, tau) {
  kq  <- ck - lam * cs
  g   <- sqrt(kq^2 + 2 * cs^2)
  den <- (g + kq) * (exp(g * tau) - 1) + 2 * g
  B   <- 2 * (exp(g * tau) - 1) / den
  A   <- (2 * ck * ct / cs^2) * log(2 * g * exp((g + kq) * tau / 2) / den)
  -(A - B * r) / tau
}

taus <- seq(0.25, 10, by = 0.25)
rq   <- quantile(tm$r_vasicek, c(0.10, 0.50, 0.90))

Code
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

tm = pd.read_csv("../data/mfsim-term.csv")
vk, vt, vs = tm["vkappa_true"][0], tm["vtheta_true"][0], tm["vsigma_true"][0]
ck, ct, cs = tm["ckappa_true"][0], tm["ctheta_true"][0], tm["csigma_true"][0]
lam = tm["lambda_true"][0]

def vas_y(r, tau):
    thq = vt - lam*vs/vk
    B = (1 - np.exp(-vk*tau))/vk
    A = (B - tau)*(vk**2*thq - vs**2/2)/vk**2 - vs**2*B**2/(4*vk)
    return -(A - B*r)/tau

def cir_y(r, tau):
    kq = ck - lam*cs
    g  = np.sqrt(kq**2 + 2*cs**2)
    den = (g + kq)*(np.exp(g*tau) - 1) + 2*g
    B = 2*(np.exp(g*tau) - 1)/den
    A = (2*ck*ct/cs**2)*np.log(2*g*np.exp((g + kq)*tau/2)/den)
    return -(A - B*r)/tau

# the average actual curve, for scale
yl = pd.read_csv("../data/mfsim-yields.csv")
g_tau = np.array([1, 2, 3, 5, 7, 10])
g_y   = np.array([yl[c].mean() for c in ["y01","y02","y03","y05","y07","y10"]])

taus = np.arange(0.25, 10.01, 0.25)
rv = np.quantile(tm["r_vasicek"], [0.10, 0.50, 0.90])
rc = np.quantile(tm["r_cir"],     [0.10, 0.50, 0.90])
cols = ["#185FA5", "#1D9E75", "#D85A30"]

fig, ax = plt.subplots(1, 2, figsize=(10, 4.2))
for k in (0, 1):
    ax[k].plot(g_tau, g_y, color="#404040", ls="--", lw=1.6)
    ax[k].text(5.4, 6.6, "average GSW curve", color="#404040", fontsize=8)
for j in range(3):
    ax[0].plot(taus, 100*vas_y(rv[j], taus), color=cols[j], lw=1.6)
    ax[1].plot(taus, 100*cir_y(rc[j], taus), color=cols[j], lw=1.6)
ax[0].text(0.4, 8.6, f"r = {100*rv[0]:.2f}%, {100*rv[1]:.2f}%, {100*rv[2]:.2f}%",
           color="#4d4d4d", fontsize=8)
ax[1].text(0.4, 8.6, f"r = {100*rc[0]:.2f}%, {100*rc[1]:.2f}%, {100*rc[2]:.2f}%",
           color="#4d4d4d", fontsize=8)
a1 = ax[0].set(xlim=(0, 10), ylim=(0, 9.5), xticks=np.arange(0, 11, 2),
               yticks=np.arange(0, 10, 2), xlabel="maturity (years)",
               ylabel="yield, percent", title="Vasicek curve at three short rates")
a2 = ax[1].set(xlim=(0, 10), ylim=(0, 9.5), xticks=np.arange(0, 11, 2),
               yticks=np.arange(0, 10, 2), xlabel="maturity (years)",
               ylabel="yield, percent", title="CIR curve at three short rates")
fig.tight_layout()
plt.show()

Code
quietly {
    import delimited "../data/mfsim-term.csv", clear
    scalar vk = vkappa_true[1]
    scalar vt = vtheta_true[1]
    scalar vs = vsigma_true[1]
    scalar ck = ckappa_true[1]
    scalar ct = ctheta_true[1]
    scalar cs = csigma_true[1]
    scalar lam = lambda_true[1]

    _pctile r_vasicek, percentiles(10 50 90)
    scalar rv1 = r(r1)
    scalar rv2 = r(r2)
    scalar rv3 = r(r3)
    _pctile r_cir, percentiles(10 50 90)
    scalar rc1 = r(r1)
    scalar rc2 = r(r2)
    scalar rc3 = r(r3)

    scalar thq = vt - lam*vs/vk
    scalar kq  = ck - lam*cs
    scalar gg  = sqrt(kq^2 + 2*cs^2)

    * the average actual curve, for scale
    preserve
    import delimited "../data/mfsim-yields.csv", clear stringcols(2 3)
    foreach v in y01 y02 y03 y05 y07 y10 {
        summarize `v', meanonly
        scalar g_`v' = r(mean)
    }
    restore

    clear
    set obs 40
    generate double tau = _n*0.25
    generate double gtau = .
    generate double gy = .
    local i = 1
    foreach pr in 1 2 3 5 7 10 {
        local vn : word `i' of y01 y02 y03 y05 y07 y10
        replace gtau = `pr' in `i'
        replace gy = g_`vn' in `i'
        local ++i
    }
    generate double Bv = (1 - exp(-vk*tau))/vk
    generate double Av = (Bv - tau)*(vk^2*thq - vs^2/2)/vk^2 - vs^2*Bv^2/(4*vk)
    generate double den = (gg + kq)*(exp(gg*tau) - 1) + 2*gg
    generate double Bc = 2*(exp(gg*tau) - 1)/den
    generate double Ac = (2*ck*ct/cs^2)*ln(2*gg*exp((gg + kq)*tau/2)/den)
    forvalues j = 1/3 {
        generate double v`j' = 100*(-(Av - Bv*rv`j')/tau)
        generate double c`j' = 100*(-(Ac - Bc*rc`j')/tau)
    }
    local lv = "r = " + string(100*rv1,"%4.2f") + "%, " + string(100*rv2,"%4.2f") ///
             + "%, " + string(100*rv3,"%4.2f") + "%"
    local lc = "r = " + string(100*rc1,"%4.2f") + "%, " + string(100*rc2,"%4.2f") ///
             + "%, " + string(100*rc3,"%4.2f") + "%"
}

twoway (line gy gtau, lcolor(gs4) lpattern(dash) lwidth(medium))          ///
       (line v1 tau, lcolor("24 95 165") lwidth(medium))                  ///
       (line v2 tau, lcolor("29 158 117") lwidth(medium))                 ///
       (line v3 tau, lcolor("216 90 48") lwidth(medium)),                 ///
  xscale(range(0 10)) yscale(range(0 9.5))                                ///
  xlabel(0(2)10) ylabel(0(2)8)                                            ///
  xtitle("maturity (years)") ytitle("yield, percent")                     ///
  title("Vasicek curve at three short rates", size(medsmall))             ///
  text(8.6 0.4 "`lv'", color(gs6) size(vsmall)                            ///
       placement(e) justification(left))                                  ///
  text(6.6 5.4 "average GSW curve", color(gs4) size(vsmall)               ///
       placement(e) justification(left))                                  ///
  legend(off) graphregion(color(white)) name(cv, replace)

twoway (line gy gtau, lcolor(gs4) lpattern(dash) lwidth(medium))          ///
       (line c1 tau, lcolor("24 95 165") lwidth(medium))                  ///
       (line c2 tau, lcolor("29 158 117") lwidth(medium))                 ///
       (line c3 tau, lcolor("216 90 48") lwidth(medium)),                 ///
  xscale(range(0 10)) yscale(range(0 9.5))                                ///
  xlabel(0(2)10) ylabel(0(2)8)                                            ///
  xtitle("maturity (years)") ytitle("yield, percent")                     ///
  title("CIR curve at three short rates", size(medsmall))                 ///
  text(8.6 0.4 "`lc'", color(gs6) size(vsmall)                            ///
       placement(e) justification(left))                                  ///
  text(6.6 5.4 "average GSW curve", color(gs4) size(vsmall)               ///
       placement(e) justification(left))                                  ///
  legend(off) graphregion(color(white)) name(cc, replace)

graph combine cv cc, cols(2) graphregion(color(white)) ysize(4.2) xsize(10)
graph export "../plots/mfsim-curve-stata.png", replace width(1900)

Notice what a one-factor model can and cannot do. The curve shifts and tilts as \(r\) moves — it is upward-sloping when the rate is low and inverted when the rate is high, because \(r\) is mean-reverting and long yields average over the expected path. But the shape is a deterministic function of one number: fix \(r\), and the entire curve is fixed. The next slide asks whether the data are willing to live with that.

Simulated vs Actual GSW Curves

GSW curve 1971-08 to 2026-07, 660 month-ends

             statistic Vasicek    CIR    GSW
      mean 1y yield, %  4.1880 4.1831 4.8385
     mean 10y yield, %  4.6368 4.3107 5.9571
 mean slope 10y-1y, pp  0.4488 0.1276 1.1186
      sd of the 1y, pp  1.7431 1.1694 3.4807
     sd of the 10y, pp  0.6391 0.3752 3.0060
         corr(1y, 10y)  1.0000 1.0000 0.9365

Principal components of the GSW curve: PC1 98.43%  PC2 1.50%  PC3 0.07%

The models are not embarrassing. Both produce an upward-sloping average curve of roughly the right level, and CIR keeps rates positive throughout. On level and shape, a one-factor affine model is a serviceable description.

Then look at the last row.

corr(1y, 10y) = 1.000000 in both models, 0.9365 in the data. Not approximately one — exactly one, to every digit the computer prints. Every yield is an affine function of the same scalar \(r_t\), so the entire curve is rank one. A one-factor model does not merely fit the slope badly; it asserts that the slope carries no information the level does not already contain.

The principal components say the same thing from the other side. PC1 explains 98.43% of the variation in the GSW curve — which is why one-factor models survived as long as they did. But PC2, the slope factor, carries 1.50%, and that sliver has a standard deviation of 1.25 percentage points. Economically it is enormous: it is the difference between an inverted curve and a steep one, which is the single most-watched recession indicator there is.

The honest verdict. One factor captures 98% of the variance and none of the interesting part. The missing 1.6% is where the term premium lives, where monetary policy acts on the short end without moving the long end, and where the yield curve’s predictive content sits. Everything from here on — Nelson–Siegel, dynamic Nelson–Siegel, the macro-finance term structure — exists to give the curve more than one factor to move with.

Two further caveats the table hides. The simulated volatilities (1.7–2.0pp) are well below the GSW figures (3.0–3.5pp), because the calibration was chosen for a clean Feller condition rather than to match the 1980s. And the GSW sample contains a structural break no single mean-reverting process can represent: rates ran to almost 15% in 1981 and to 0.55% in 2020. A model with a constant \(\theta\) is being asked to describe a series whose central tendency plainly moved.

Estimating the Short-Rate Process on Modern Data

The exact Vasicek transition is an AR(1) in disguise:

\[r_{t+\Delta} = \theta\left(1 - e^{-\kappa\Delta}\right) + e^{-\kappa\Delta} r_t + \eta_{t+\Delta}, \qquad \mathrm{sd}(\eta) = \sigma\sqrt{\frac{1 - e^{-2\kappa\Delta}}{2\kappa}}\]

so OLS on monthly data recovers the structural parameters by inversion. It is run twice: on the simulated Vasicek path, where the truth is known, and on the GSW one-year yield, where it is not.

Code
estimate_ou <- function(r, dt = 1/12) {
  n   <- length(r)
  fit <- lm(r[-1] ~ r[-n])
  phi <- coef(fit)[2]
  kap <- -log(phi) / dt
  th  <- coef(fit)[1] / (1 - phi)
  sig <- sd(residuals(fit)) / sqrt((1 - exp(-2*kap*dt)) / (2*kap))
  c(kappa = kap, theta = th, sigma = sig, phi = phi,
    half_life = log(2) / kap)
}

tm <- read.csv("../data/mfsim-term.csv")
yl <- read.csv("../data/mfsim-yields.csv")
sim <- estimate_ou(tm$r_vasicek)          # truth is known here
dat <- estimate_ou(yl$y01 / 100)          # GSW one-year yield
         parameter simulated  truth GSW_1y
             kappa    0.2781 0.3000 0.0990
             theta    0.0409 0.0450 0.0466
             sigma    0.0151 0.0150 0.0155
 phi (monthly AR1)    0.9771 0.9753 0.9918
  half-life, years    2.4927 2.3105 6.9989 
Code
import numpy as np
import pandas as pd

def estimate_ou(r, dt=1/12):
    r = np.asarray(r)
    X = np.column_stack([np.ones(len(r)-1), r[:-1]])
    b, *_ = np.linalg.lstsq(X, r[1:], rcond=None)
    resid = r[1:] - X @ b
    phi = b[1]
    kap = -np.log(phi) / dt
    th  = b[0] / (1 - phi)
    sig = resid.std(ddof=2) / np.sqrt((1 - np.exp(-2*kap*dt)) / (2*kap))
    return [kap, th, sig, phi, np.log(2)/kap]

tm = pd.read_csv("../data/mfsim-term.csv")
yl = pd.read_csv("../data/mfsim-yields.csv")
sim = estimate_ou(tm["r_vasicek"])        # truth is known here
dat = estimate_ou(yl["y01"] / 100)        # GSW one-year yield

vk = tm["vkappa_true"][0]
tab = pd.DataFrame({
    "parameter": ["kappa", "theta", "sigma", "phi (monthly AR1)",
                  "half-life, years"],
    "simulated": np.round(sim, 4),
    "truth":     [vk, tm["vtheta_true"][0], tm["vsigma_true"][0],
                  round(np.exp(-vk/12), 4), round(np.log(2)/vk, 4)],
    "GSW_1y":    np.round(dat, 4)})
import sys; nchars = sys.stdout.write(tab.to_string(index=False) + "\n")
        parameter  simulated  truth  GSW_1y
            kappa     0.2781 0.3000  0.0990
            theta     0.0409 0.0450  0.0466
            sigma     0.0151 0.0150  0.0155
phi (monthly AR1)     0.9771 0.9753  0.9918
 half-life, years     2.4927 2.3105  6.9989
Code
sys.stdout.flush()
Code
quietly {
    import delimited "../data/mfsim-term.csv", clear
    tsset t
    scalar vk = vkappa_true[1]
    scalar vt = vtheta_true[1]
    scalar vs = vsigma_true[1]
    regress r_vasicek L.r_vasicek
    scalar s_phi = _b[L.r_vasicek]
    scalar s_kap = -ln(s_phi)*12
    scalar s_th  = _b[_cons]/(1 - s_phi)
    scalar s_sig = e(rmse)/sqrt((1 - exp(-2*s_kap/12))/(2*s_kap))

    import delimited "../data/mfsim-yields.csv", clear stringcols(2 3)
    tsset t
    generate double r1 = y01/100
    regress r1 L.r1
    scalar d_phi = _b[L.r1]
    scalar d_kap = -ln(d_phi)*12
    scalar d_th  = _b[_cons]/(1 - d_phi)
    scalar d_sig = e(rmse)/sqrt((1 - exp(-2*d_kap/12))/(2*d_kap))
}

display "        parameter  simulated      truth     GSW_1y" _newline ///
  "            kappa " %10.4f s_kap " " %10.4f vk " " %10.4f d_kap _newline ///
  "            theta " %10.4f s_th  " " %10.4f vt " " %10.4f d_th  _newline ///
  "            sigma " %10.4f s_sig " " %10.4f vs " " %10.4f d_sig _newline ///
  "phi (monthly AR1) " %10.4f s_phi " " %10.4f exp(-vk/12) " " %10.4f d_phi _newline ///
  " half-life, years " %10.4f ln(2)/s_kap " " %10.4f ln(2)/vk " " %10.4f ln(2)/d_kap
        parameter  simulated      truth     GSW_1y
            kappa     0.2781     0.3000     0.0990
            theta     0.0409     0.0450     0.0466
            sigma     0.0151     0.0150     0.0155
phi (monthly AR1)     0.9771     0.9753     0.9918
 half-life, years     2.4927     2.3105     6.9989

Nelson–Siegel–Svensson & the GSW Parameters

Affine models start from a process for the short rate and derive the curve. Nelson and Siegel (1987) do the opposite: they write down a flexible functional form for the curve itself, with parameters that can be read as level, slope and curvature, and fit it date by date. Svensson added a second curvature term for the long end.

It is not a model of anything — no preferences, no no-arbitrage restriction — but it fits, and it is what central banks publish. The GSW file used throughout this part is exactly this: six parameters per day. The SVENY yields we have been reading are not raw data; they are this formula evaluated at each maturity.

\[y(\tau) = \beta_0 + \beta_1 \frac{1 - e^{-\tau/\tau_1}}{\tau/\tau_1} + \beta_2 \left[ \frac{1 - e^{-\tau/\tau_1}}{\tau/\tau_1} - e^{-\tau/\tau_1} \right] + \beta_3 \left[ \frac{1 - e^{-\tau/\tau_2}}{\tau/\tau_2} - e^{-\tau/\tau_2} \right]\]

The three loadings are the whole story:

  • \(\beta_0\) loads 1 at every maturity — the level;
  • \(\beta_1\) loads \(\frac{1-e^{-\tau/\tau_1}}{\tau/\tau_1}\), which is 1 at \(\tau \to 0\) and decays to 0 — the slope, acting on the short end;
  • \(\beta_2\), \(\beta_3\) load a hump that is 0 at both ends — the curvature.


Rebuilding SVENY10 from BETA0-3, TAU1, TAU2 over 660 month-ends:
  max absolute error   0.000050 percentage points
  correlation          1.00000000

Rebuilding the ten-year yield from the six published parameters reproduces the Fed’s own SVENY10 column to a maximum error of 0.00005 percentage points — five hundredths of a basis point, which is rounding. That is worth doing once for any dataset you rely on: it proves the formula, the parameters and the columns all mean what the documentation says they mean.

Dynamic Nelson–Siegel as a State-Space Model

Diebold and Li (2006) make the Nelson–Siegel parameters time-varying states. Fix the decay \(\lambda\) and the three loadings become constants, so the factors come out of a cross-sectional regression each month:

\[y_t(\tau) = L_t + S_t \frac{1 - e^{-\lambda\tau}}{\lambda\tau} + C_t \left[ \frac{1 - e^{-\lambda\tau}}{\lambda\tau} - e^{-\lambda\tau} \right] + \varepsilon_t(\tau)\]

The extraction is deterministic — ordinary least squares on six maturities, with \(\lambda = 0.7308\) — so all three tabs return identical factor paths. Their dynamics are then an AR(1) each, which is the transition equation of a state-space model.

Code
yl   <- read.csv("../data/mfsim-yields.csv")
lam  <- 0.7308
mats <- c(1, 2, 3, 5, 7, 10)

l2 <- (1 - exp(-lam * mats)) / (lam * mats)
X  <- cbind(level = 1, slope = l2, curv = l2 - exp(-lam * mats))
Y  <- as.matrix(yl[, c("y01", "y02", "y03", "y05", "y07", "y10")])

# one cross-sectional OLS per month, done in a single solve
Fac <- t(solve(t(X) %*% X, t(X) %*% t(Y)))
colnames(Fac) <- c("level", "slope", "curv")

resid <- Y - Fac %*% t(X)

 factor    mean     sd    ac1
  level  6.4149 2.8418 0.9933
  slope -1.5439 1.9688 0.9587
   curv -2.0509 2.6265 0.9422

fit RMSE 0.0374 pp   max |residual| 0.1851 pp
corr(level, 10y) = 0.987    corr(-slope, 10y-1y) = 0.978
Code
import numpy as np
import pandas as pd

yl = pd.read_csv("../data/mfsim-yields.csv")
lam = 0.7308
mats = np.array([1, 2, 3, 5, 7, 10])

l2 = (1 - np.exp(-lam*mats)) / (lam*mats)
X  = np.column_stack([np.ones(6), l2, l2 - np.exp(-lam*mats)])
Y  = yl[["y01", "y02", "y03", "y05", "y07", "y10"]].to_numpy()

# one cross-sectional OLS per month, done in a single solve
Fac = np.linalg.solve(X.T @ X, X.T @ Y.T).T
res = Y - Fac @ X.T

def ac1(z):
    return np.corrcoef(z[1:], z[:-1])[0, 1]

tab = pd.DataFrame({
    "factor": ["level", "slope", "curv"],
    "mean":   np.round(Fac.mean(axis=0), 4),
    "sd":     np.round(Fac.std(axis=0, ddof=1), 4),
    "ac1":    np.round([ac1(Fac[:, j]) for j in range(3)], 4)})

out = (tab.to_string(index=False)
       + f"\n\nfit RMSE {np.sqrt((res**2).mean()):.4f} pp   "
         f"max |residual| {np.abs(res).max():.4f} pp\n"
       + f"corr(level, 10y) = {np.corrcoef(Fac[:,0], yl['y10'])[0,1]:.3f}    "
         f"corr(-slope, 10y-1y) = "
         f"{np.corrcoef(-Fac[:,1], yl['y10']-yl['y01'])[0,1]:.3f}\n")
import sys; nchars = sys.stdout.write(out); sys.stdout.flush()
factor    mean     sd    ac1
 level  6.4149 2.8418 0.9933
 slope -1.5439 1.9688 0.9587
  curv -2.0509 2.6265 0.9422

fit RMSE 0.0374 pp   max |residual| 0.1851 pp
corr(level, 10y) = 0.987    corr(-slope, 10y-1y) = 0.978
Code
quietly {
    import delimited "../data/mfsim-yields.csv", clear stringcols(2 3)
    tsset t
    scalar lam = 0.7308

    * the three loadings are constants once lambda is fixed
    matrix X = J(6,3,0)
    local i = 1
    foreach m of numlist 1 2 3 5 7 10 {
        scalar l2 = (1 - exp(-lam*`m'))/(lam*`m')
        matrix X[`i',1] = 1
        matrix X[`i',2] = l2
        matrix X[`i',3] = l2 - exp(-lam*`m')
        local ++i
    }
    matrix XXi = inv(X'*X)*X'

    generate double level = 0
    generate double slope = 0
    generate double curv  = 0
    forvalues r = 1/3 {
        local nm : word `r' of level slope curv
        local j = 1
        local expr ""
        foreach v in y01 y02 y03 y05 y07 y10 {
            local expr "`expr' + `=XXi[`r',`j']'*`v'"
            local ++j
        }
        replace `nm' = 0 `expr'
    }

    foreach nm in level slope curv {
        summarize `nm'
        scalar m_`nm' = r(mean)
        scalar s_`nm' = r(sd)
        generate double l_`nm' = L.`nm'
        correlate `nm' l_`nm'
        scalar a_`nm' = r(rho)
    }
    * residuals of the cross-sectional fit
    generate double ss = 0
    local i = 1
    foreach v in y01 y02 y03 y05 y07 y10 {
        generate double e`i' = `v' - (level*X[`i',1] + slope*X[`i',2] + curv*X[`i',3])
        replace ss = ss + e`i'^2
        local ++i
    }
    summarize ss, meanonly
    scalar rmse = sqrt(r(mean)/6)
    generate double emax = 0
    forvalues i = 1/6 {
        replace emax = max(emax, abs(e`i'))
    }
    summarize emax, meanonly
    scalar resmax = r(max)
    generate double negslope = -slope
    generate double term = y10 - y01
    correlate level y10
    scalar c1 = r(rho)
    correlate negslope term
    scalar c2 = r(rho)
}

display " factor       mean         sd        ac1" _newline ///
  "  level " %10.4f m_level " " %10.4f s_level " " %10.4f a_level _newline ///
  "  slope " %10.4f m_slope " " %10.4f s_slope " " %10.4f a_slope _newline ///
  "   curv " %10.4f m_curv  " " %10.4f s_curv  " " %10.4f a_curv  _newline ///
  _newline ///
  "fit RMSE " %6.4f rmse " pp   max |residual| " %6.4f resmax " pp" _newline ///
  "corr(level, 10y) = " %5.3f c1 "    corr(-slope, 10y-1y) = " %5.3f c2
 factor       mean         sd        ac1
  level     6.4149     2.8418     0.9933
  slope    -1.5439     1.9688     0.9587
   curv    -2.0509     2.6265     0.9422

fit RMSE 0.0374 pp   max |residual| 0.1851 pp
corr(level, 10y) = 0.987    corr(-slope, 10y-1y) = 0.978

Stata also has sspace, which estimates the one-step version by maximum likelihood — measurement equation, AR(1) transition and all — instead of the two-step extraction used here. The two-step route is written out longhand so the factor paths are identical across all three tabs.

Scenario Simulation for a Bond Portfolio

The point of a factor model is that it turns “what if the curve moves” into “what if three numbers move”. Take the latest GSW curve (2026-07), shock each factor by 100 basis points in turn, and revalue an equally weighted portfolio of 2-, 5- and 10-year zero-coupon bonds held for one year.

A zero maturing in \(\tau\) years is worth \(e^{-y(\tau)\tau}\), so a one-year holding period return is

\[R = \frac{e^{-y_{\text{new}}(\tau-1)(\tau-1)}}{e^{-y_{\text{old}}(\tau)\tau}} - 1\]

The scenarios are deterministic, so all three tabs report the same P&L to the last digit.

latest factors (2026-07): L = 5.031  S = -0.588  C = -1.729
current yields: 2y 4.215%  5y 4.459%  10y 4.716%
one-year holding period return, percent
         scenario   y2   y5   y10 equal_wt
     level +100bp 3.26 0.80 -3.90     0.05
   steepen +100bp 5.04 6.28  6.59     5.97
 curvature +100bp 4.06 3.79  3.73     3.86
        no change 4.30 4.91  5.15     4.79 
Code
import numpy as np
import pandas as pd

f0 = Fac[-1]

def ycurve(f, tau):
    l2 = (1 - np.exp(-lam*tau)) / (lam*tau)
    return f[0] + f[1]*l2 + f[2]*(l2 - np.exp(-lam*tau))

holds = np.array([2.0, 5.0, 10.0])
base  = ycurve(f0, holds) / 100

scen = {"level +100bp":     np.array([1.0,  0.0, 0.0]),
        "steepen +100bp":   np.array([0.0, -1.0, 0.0]),
        "curvature +100bp": np.array([0.0,  0.0, 1.0]),
        "no change":        np.array([0.0,  0.0, 0.0])}

rows = []
for nm, sh in scen.items():
    ynew = ycurve(f0 + sh, holds - 1) / 100
    ret  = np.exp(-ynew*(holds-1)) / np.exp(-base*holds) - 1
    rows.append([nm] + list(np.round(100*ret, 2)) + [round(100*ret.mean(), 2)])

tab = pd.DataFrame(rows, columns=["scenario", "y2", "y5", "y10", "equal_wt"])

out = (f"latest factors ({yl['ym'].iloc[-1]}): "
       f"L = {f0[0]:.3f}  S = {f0[1]:.3f}  C = {f0[2]:.3f}\n"
       f"current yields: 2y {100*base[0]:.3f}%  5y {100*base[1]:.3f}%  "
       f"10y {100*base[2]:.3f}%\n\n"
       "one-year holding period return, percent\n"
       + tab.to_string(index=False) + "\n")
import sys; nchars = sys.stdout.write(out); sys.stdout.flush()
latest factors (2026-07): L = 5.031  S = -0.588  C = -1.729
current yields: 2y 4.215%  5y 4.459%  10y 4.716%

one-year holding period return, percent
        scenario   y2   y5   y10  equal_wt
    level +100bp 3.26 0.80 -3.90      0.05
  steepen +100bp 5.04 6.28  6.59      5.97
curvature +100bp 4.06 3.79  3.73      3.86
       no change 4.30 4.91  5.15      4.79
Code
quietly {
    import delimited "../data/mfsim-yields.csv", clear stringcols(2 3)
    scalar lam = 0.7308
    matrix X = J(6,3,0)
    local i = 1
    foreach m of numlist 1 2 3 5 7 10 {
        scalar l2 = (1 - exp(-lam*`m'))/(lam*`m')
        matrix X[`i',1] = 1
        matrix X[`i',2] = l2
        matrix X[`i',3] = l2 - exp(-lam*`m')
        local ++i
    }
    matrix XXi = inv(X'*X)*X'
    local N = _N
    forvalues r = 1/3 {
        scalar f`r' = 0
        local j = 1
        foreach v in y01 y02 y03 y05 y07 y10 {
            scalar f`r' = f`r' + XXi[`r',`j']*`v'[`N']
            local ++j
        }
    }
    local ym = ym[`N']
}

quietly {
    * the four scenarios, as shocks to (L, S, C)
    clear
    set obs 4
    generate str18 scenario = ""
    generate double dL = 0
    generate double dS = 0
    generate double dC = 0
    replace scenario = "level +100bp"     in 1
    replace dL = 1 in 1
    replace scenario = "steepen +100bp"   in 2
    replace dS = -1 in 2
    replace scenario = "curvature +100bp" in 3
    replace dC = 1 in 3
    replace scenario = "no change"        in 4

    foreach h of numlist 2 5 10 {
        local hm = `h' - 1
        scalar b2 = (1 - exp(-lam*`h'))/(lam*`h')
        scalar y0 = (f1 + f2*b2 + f3*(b2 - exp(-lam*`h')))/100
        scalar b2n = (1 - exp(-lam*`hm'))/(lam*`hm')
        generate double y`h' = 100*(exp(-((f1+dL) + (f2+dS)*b2n ///
            + (f3+dC)*(b2n - exp(-lam*`hm')))/100*`hm')/exp(-y0*`h') - 1)
        scalar base`h' = 100*y0
    }
    generate double equal_wt = (y2 + y5 + y10)/3
}

display "latest factors (`ym'): L = " %6.3f f1 "  S = " %6.3f f2 ///
  "  C = " %6.3f f3 _newline ///
  "current yields: 2y " %6.3f base2 "%  5y " %6.3f base5 ///
  "%  10y " %6.3f base10 "%" _newline _newline ///
  "one-year holding period return, percent"
list scenario y2 y5 y10 equal_wt, noobs clean
latest factors (2026-07): L =  5.031  S = -0.588  C = -1.729
current yields: 2y  4.215%  5y  4.459%  10y  4.716%

one-year holding period return, percent

            scenario          y2          y5          y10    equal_wt  
        level +100bp   3.2613818   .79817641   -3.9028286   .05224321  
      steepen +100bp   5.0417723    6.279057    6.5937066   5.9715119  
    curvature +100bp   4.0617066   3.7851333    3.7330268   3.8599556  
           no change   4.2991759    4.911828    5.1470537   4.7860192  

Part 6 — Macro-Financial Risk

Growth-at-Risk, regime switching, macro scenario simulation, and tail
simulation by importance sampling.

Growth-at-Risk

Every forecast in this deck so far has been about a mean — expected growth, expected returns. Adrian, Boyarchenko and Giannone (2019) ask a different question, and it is the one a policymaker actually has:

Not how fast will the economy grow? but how bad could it plausibly get?

Their finding is that these are not the same question, and that financial conditions answer only the second. When credit is tight, the centre of the growth distribution barely moves — but the left tail collapses. Growth becomes vulnerable rather than merely slower.

That asymmetry is invisible to any conditional-mean model, and it is exactly what quantile regression is built to see. The whole method is: run a quantile regression of future growth on financial conditions, once per quantile, and read the coefficients.

For each quantile \(\tau\) and horizon \(h\), estimate

\[\hat{Q}_{y_{t+h} \mid x_t}(\tau) = \beta_0(\tau) + \beta_1(\tau) \, y_t + \beta_2(\tau) \, \text{NFCI}_t\]

by minimising the asymmetric absolute loss

\[\min_{\beta} \sum_t \rho_\tau \left( y_{t+h} - x_t' \beta \right), \qquad \rho_\tau(u) = u \left( \tau - \mathbb{1}\{u < 0\} \right)\]

The object of interest is Growth-at-Risk, the 5% conditional quantile:

\[\text{GaR}_t(h) = \hat{Q}_{y_{t+h} \mid x_t}(0.05)\]

The vulnerability hypothesis is a statement about \(\beta_2(\tau)\):

\[\beta_2(0.05) \ll 0, \qquad \beta_2(0.50) \approx 0\]

Financial conditions shift the tail and leave the middle alone. Data: ../data/mfsim-macro.csv, the Chicago Fed NFCI and real GDP growth, 1971Q1–2026Q2, \(h = 4\) quarters.

Growth-at-Risk — Quantile Regression on NFCI

Code
library(quantreg)
m <- read.csv("../data/mfsim-macro.csv")
m <- m[!is.na(m$nfci), ]
n <- nrow(m); h <- 4

# growth h quarters ahead, lined up with today's conditioning information
d <- data.frame(yh = c(m$dgdp[-(1:h)], rep(NA, h)),
                y  = m$dgdp,
                x  = m$nfci)

taus <- c(0.05, 0.25, 0.50, 0.75, 0.95)
coefs <- sapply(taus, \(tt) coef(rq(yh ~ y + x, tau = tt, data = d)))
GaR sample 1971Q1 to 2026Q2, n = 222, horizon h = 4 quarters

  tau intercept lag_growth   nfci
 0.05    -2.626      0.165 -2.061
 0.25     1.428     -0.042 -0.827
 0.50     2.903      0.013  0.009
 0.75     4.475     -0.032  0.458
 0.95     7.156      0.011  0.740

5% quantile: mean -2.17   min -13.11 (1974Q3)   max 3.29
  2008Q4  NFCI  2.77  ->  GaR   -9.79
  easy financial conditions (NFCI < 0): median GaR -1.11
Code
import numpy as np
import pandas as pd
import statsmodels.formula.api as smf

m = pd.read_csv("../data/mfsim-macro.csv").dropna(subset=["nfci"]).reset_index(drop=True)
n, h = len(m), 4

# growth h quarters ahead, lined up with today's conditioning information
d = pd.DataFrame({"yh": m["dgdp"].shift(-h), "y": m["dgdp"], "x": m["nfci"]})

taus = [0.05, 0.25, 0.50, 0.75, 0.95]
fits = [smf.quantreg("yh ~ y + x", d).fit(q=t) for t in taus]

tab = pd.DataFrame({
    "tau":        taus,
    "intercept":  [round(f.params["Intercept"], 3) for f in fits],
    "lag_growth": [round(f.params["y"], 3) for f in fits],
    "nfci":       [round(f.params["x"], 3) for f in fits]})

Q05 = fits[0].params["Intercept"] + fits[0].params["y"]*d["y"] + fits[0].params["x"]*d["x"]

out = (f"GaR sample {m['qtr'].iloc[0]} to {m['qtr'].iloc[-1]}, n = {n}, "
       "horizon h = 4 quarters\n\n"
       + tab.to_string(index=False)
       + f"\n\n5% quantile: mean {Q05.mean():.2f}   min {Q05.min():.2f} "
         f"({m['qtr'][Q05.idxmin()]})   max {Q05.max():.2f}\n"
       + f"  2008Q4  NFCI {float(m.loc[m['qtr']=='2008Q4','nfci'].iloc[0]):5.2f}  ->  "
         f"GaR {float(Q05[m['qtr']=='2008Q4'].iloc[0]):7.2f}\n"
       + f"  easy financial conditions (NFCI < 0): median GaR "
         f"{Q05[m['nfci'] < 0].median():.2f}\n")
import sys; nchars = sys.stdout.write(out); sys.stdout.flush()
GaR sample 1971Q1 to 2026Q2, n = 222, horizon h = 4 quarters

 tau  intercept  lag_growth   nfci
0.05     -2.626       0.165 -2.061
0.25      1.428      -0.042 -0.827
0.50      2.903       0.013  0.009
0.75      4.475      -0.032  0.458
0.95      7.156       0.011  0.740

5% quantile: mean -2.17   min -13.11 (1974Q3)   max 3.29
  2008Q4  NFCI  2.77  ->  GaR   -9.79
  easy financial conditions (NFCI < 0): median GaR -1.11
Code
quietly {
    import delimited "../data/mfsim-macro.csv", clear stringcols(3)
    tsset t
    drop if missing(nfci)
    generate double yh = F4.dgdp
    generate double y  = dgdp
    generate double x  = nfci
    local q1 = qtr[1]
    local qN = qtr[_N]
    scalar nn = _N

    local i = 1
    foreach tt in 0.05 0.25 0.50 0.75 0.95 {
        qreg yh y x, quantile(`tt')
        scalar b0_`i' = _b[_cons]
        scalar b1_`i' = _b[y]
        scalar b2_`i' = _b[x]
        local ++i
    }
    predict double q05x, xb
    quietly qreg yh y x, quantile(0.05)
    predict double q05, xb
    summarize q05
    scalar qm = r(mean)
    scalar qlo = r(min)
    scalar qhi = r(max)
    summarize q05 if nfci < 0, detail
    scalar qmed = r(p50)
    summarize nfci if qtr == "2008Q4", meanonly
    scalar n08 = r(mean)
    summarize q05 if qtr == "2008Q4", meanonly
    scalar g08 = r(mean)
}

display "GaR sample `q1' to `qN', n = " %4.0f nn ", horizon h = 4 quarters" ///
  _newline _newline ///
  "  tau  intercept  lag_growth       nfci" _newline ///
  " 0.05 " %10.3f b0_1 " " %11.3f b1_1 " " %10.3f b2_1 _newline ///
  " 0.25 " %10.3f b0_2 " " %11.3f b1_2 " " %10.3f b2_2 _newline ///
  " 0.50 " %10.3f b0_3 " " %11.3f b1_3 " " %10.3f b2_3 _newline ///
  " 0.75 " %10.3f b0_4 " " %11.3f b1_4 " " %10.3f b2_4 _newline ///
  " 0.95 " %10.3f b0_5 " " %11.3f b1_5 " " %10.3f b2_5 _newline ///
  _newline ///
  "5% quantile: mean " %6.2f qm "   min " %6.2f qlo "   max " %6.2f qhi _newline ///
  "  2008Q4  NFCI " %5.2f n08 "  ->  GaR " %7.2f g08 _newline ///
  "  easy financial conditions (NFCI < 0): median GaR " %6.2f qmed
GaR sample 1971Q1 to 2026Q2, n =  222, horizon h = 4 quarters

  tau  intercept  lag_growth       nfci
 0.05     -2.626       0.165     -2.061
 0.25      1.428      -0.042     -0.827
 0.50      2.903       0.013      0.009
 0.75      4.475      -0.032      0.458
 0.95      7.156       0.011      0.740

5% quantile: mean  -2.17   min -13.11   max   3.29
  2008Q4  NFCI  2.77  ->  GaR   -9.79
  easy financial conditions (NFCI < 0): median GaR  -1.11

Read down the nfci column — that is the whole paper. The coefficient is \(\mathbf{-2.061}\) at the 5% quantile, \(\mathbf{+0.009}\) at the median and \(+0.740\) at the 95%. A one-unit tightening of financial conditions moves the bottom of the growth distribution by more than two percentage points and the middle by essentially nothing.

In 2008Q4, with the NFCI at 2.77, Growth-at-Risk was \(\mathbf{-9.79\%}\) — against a median of \(-1.11\%\) in easy conditions. Vulnerability, not the central forecast, is what financial conditions tell you about.

The Predictive Density — Fitting a Skew-t

Five fitted quantiles are not a distribution. To simulate from the conditional forecast — or to compute a probability, an expected shortfall, or a density forecast score — you need a density. Adrian, Boyarchenko and Giannone fit Azzalini’s skew-t, chosen because its four parameters map onto exactly the four things a growth forecast needs: location, scale, skewness and tail weight.

\[f(y) = \frac{2}{\omega} \, t_\nu\!\left( \frac{y-\xi}{\omega} \right) \, T_{\nu+1}\!\left( \alpha \frac{y-\xi}{\omega} \sqrt{\frac{\nu+1}{\nu + \left(\frac{y-\xi}{\omega}\right)^2}} \right)\]

The parameters are chosen to reproduce the fitted quantiles:

\[\left( \hat\xi, \hat\omega, \hat\alpha, \hat\nu \right) = \arg\min \sum_{\tau \in \{0.05, 0.25, 0.75, 0.95\}} \left[ F^{-1}_{\text{skew-t}}(\tau) - \hat{Q}(\tau) \right]^2\]

Code
library(sn)
taus4 <- c(0.05, 0.25, 0.75, 0.95)
Q4 <- sapply(taus4, \(tt) as.numeric(predict(rq(yh ~ y + x, tau = tt, data = d),
                                             newdata = d)))

# The objective is flat in places, so a small multi-start is needed --
# a single Nelder-Mead run gets stuck on the nu boundary in crisis quarters.
fit_skewt <- function(q) {
  obj <- function(p) {
    v <- qst(taus4, xi = p[1], omega = exp(p[2]), alpha = p[3],
             nu = 2 + exp(p[4]))
    if (any(!is.finite(v))) return(1e10)
    sum((v - q)^2)
  }
  best <- NULL
  for (a0 in c(-4, -1, 0, 1, 4)) {
    for (n0 in c(log(2), log(8), log(28))) {
      o <- optim(c(median(q), log(diff(range(q)) / 4), a0, n0), obj,
                 method = "Nelder-Mead",
                 control = list(maxit = 8000, reltol = 1e-12))
      if (is.null(best) || o$value < best$value) best <- o
    }
  }
  c(xi = best$par[1], omega = exp(best$par[2]), alpha = best$par[3],
    nu = 2 + exp(best$par[4]), sse = best$value)
}

 quarter    xi omega  alpha    nu sse
  2008Q4 7.788 6.065 -2.756 3.604   0
  2026Q2 3.317 1.542 -0.220 2.297   0 
Code
import numpy as np
from scipy.optimize import minimize
from scipy.stats import t as tdist

taus4 = [0.05, 0.25, 0.75, 0.95]
f4 = [smf.quantreg("yh ~ y + x", d).fit(q=t) for t in taus4]
Q4 = np.column_stack([f.params["Intercept"] + f.params["y"]*d["y"]
                      + f.params["x"]*d["x"] for f in f4])

def st_cdf(z, alpha, nu):
    return 2 * tdist.cdf(z, nu) * tdist.cdf(
        alpha * z * np.sqrt((nu + 1) / (nu + z**2)), nu + 1) if False else None

def st_q(taus, xi, om, al, nu, lo=-60, hi=60):
    # invert the skew-t cdf by bisection -- robust and dependency-free
    zs = np.linspace(lo, hi, 24001)
    pdf = (2/om)*tdist.pdf((zs-xi)/om, nu)*tdist.cdf(
        al*((zs-xi)/om)*np.sqrt((nu+1)/(nu+((zs-xi)/om)**2)), nu+1)
    cdf = np.cumsum(pdf)*(zs[1]-zs[0]); cdf /= cdf[-1]
    return np.interp(taus, cdf, zs)

def fit_skewt(q):
    def obj(p):
        v = st_q(taus4, p[0], np.exp(p[1]), p[2], 2+np.exp(p[3]))
        return np.sum((v - q)**2)
    best = None
    for a0 in (-4, -1, 0, 1, 4):
        for n0 in (np.log(2), np.log(8), np.log(28)):
            o = minimize(obj, [np.median(q), np.log(np.ptp(q)/4), a0, n0],
                         method="Nelder-Mead",
                         options={"maxiter": 8000, "fatol": 1e-12})
            if best is None or o.fun < best.fun:
                best = o
    return [best.x[0], np.exp(best.x[1]), best.x[2], 2+np.exp(best.x[3]), best.fun]

rows = []
for q in ("2008Q4", "2026Q2"):
    i = int(m.index[m["qtr"] == q][0])
    rows.append([q] + list(np.round(fit_skewt(Q4[i]), 3)))

out = (f"{'quarter':>8} {'xi':>8} {'omega':>8} {'alpha':>8} {'nu':>8} {'sse':>10}\n"
       + "\n".join(f"{r[0]:>8} {r[1]:8.3f} {r[2]:8.3f} {r[3]:8.3f} {r[4]:8.3f}"
                   f" {r[5]:10.2e}" for r in rows) + "\n")
import sys; nchars = sys.stdout.write(out); sys.stdout.flush()
 quarter       xi    omega    alpha       nu        sse
  2008Q4    7.786    6.053   -2.752    3.553   0.00e+00
  2026Q2    3.320    1.542   -0.220    2.291   0.00e+00

Stata has no skew-t distribution — not built in, not on SSC — and the quantile inversion the fit needs would have to be coded from scratch in Mata. Stata’s contribution to Growth-at-Risk stops at qreg, which it does natively and well; the density step belongs to R and Python.

GaR — Fan Chart and the 2020–2025 Test

in-sample 5% breach rate      4.6%   (nominal 5%, n = 218)
pre-2020 breach rate          4.6%

true out-of-sample: fitted on pre-2020 only, evaluated 2020Q1-2026Q2
  breaches                    2 of 22  =  9.1%
  worst quarter               2020Q3: realised 3.29 vs GaR 4.91

highest NFCI reading during 2020: -0.08  (historical max 4.78 in 1974Q3)

The in-sample calibration is good: a 4.6% breach rate against a nominal 5%, and the fan visibly narrows and widens with financial conditions. The 5% quantile falls to \(-9.79\%\) in 2008Q4 and to \(-13.11\%\) in 1974Q3, both of which were followed by exactly the kind of contraction the measure is designed to warn about.

Now the honest part.

Growth-at-Risk did not see COVID coming, and it could not have. The NFCI’s highest reading in all of 2020 was \(\mathbf{-0.08}\) — still loose by historical standards, against 4.78 in 1974Q3. Financial conditions never deteriorated, because the policy response was immediate and enormous. A model whose only warning channel is financial stress had nothing to say about a pandemic.

What the fan does show in 2020 comes through the lagged-growth term, not the NFCI: the collapse in 2020Q2 output mechanically drags the following quarter’s conditional quantile down. That is arithmetic, not a warning.

Estimated on pre-2020 data alone and evaluated on 2020Q1–2026Q2, the 5% quantile is breached 2 times in 22 quarters — 9.1% against a nominal 5%. With 22 observations that is far from a rejection, but it points the same way: the model is somewhat over-confident out of sample in a period it was not built for.

The right conclusion is neither “GaR works” nor “GaR failed”. It is that Growth-at-Risk measures vulnerability to financial conditions, and only that. It was informative in 1974 and 2008 because those were financial events. It was silent in 2020 because that one was not. Knowing which regime you are in is the subject of the next slide.

Regime Switching

Growth-at-Risk conditions on something you can see — the NFCI is published weekly. Hamilton (1989) asks what to do when the thing that matters is unobserved: the economy is in one of a small number of regimes, each with its own mean and its own volatility, and nobody rings a bell when it switches.

The state \(s_t \in \{1, 2\}\) is latent and follows a Markov chain. You never see it; you see only \(y_t\), drawn from whichever regime is active. The Hamilton filter recovers the probability of each regime given the data so far — a recursion, one observation at a time, exactly like the Kalman filter but over a discrete state.

This is the first model in the deck where risk itself is a state variable. Part 1’s third stylised fact said volatility is not constant; regime switching is the simplest model that takes that literally rather than treating it as a nuisance.

\[y_t \mid s_t = j \;\sim\; N\!\left( \mu_j, \; \sigma_j^2 \right), \qquad \Pr\left( s_t = j \mid s_{t-1} = i \right) = p_{ij}\]

The filter alternates two steps. Prediction, using the transition matrix:

\[\Pr\left( s_t = j \mid \mathcal{I}_{t-1} \right) = \sum_i p_{ij} \Pr\left( s_{t-1} = i \mid \mathcal{I}_{t-1} \right)\]

and update, by Bayes’ rule once \(y_t\) arrives:

\[\Pr\left( s_t = j \mid \mathcal{I}_t \right) = \frac{\Pr\left( s_t = j \mid \mathcal{I}_{t-1} \right) f\!\left( y_t \mid s_t = j \right)}{\sum_i \Pr\left( s_t = i \mid \mathcal{I}_{t-1} \right) f\!\left( y_t \mid s_t = i \right)}\]

The denominator is the one-step predictive density, so the log-likelihood falls out of the same recursion:

\[\ell = \sum_t \log \sum_i \Pr\left( s_t = i \mid \mathcal{I}_{t-1} \right) f\!\left( y_t \mid s_t = i \right)\]

The laboratory is ../data/mfsim-regime.csv: 800 observations with \(\mu = (3.2, -2.0)\), \(\sigma = (1.6, 3.4)\) and \(p_{11} = 0.95\), \(p_{22} = 0.75\), and the true state path stored as state_true.

Regime Switching — Code

reference depth — scrollable, read after class

The filter is run at the true parameters, so it is a deterministic recursion and all three tabs must return the same log-likelihood and the same hit rate. Maximum-likelihood estimates follow underneath — those come from three different optimisers and differ in the last digits.

Code
rg <- read.csv("../data/mfsim-regime.csv")
y  <- rg$y; n <- length(y)

mu <- c(rg$mu1_true[1], rg$mu2_true[1])
sd_ <- c(rg$sd1_true[1], rg$sd2_true[1])
P  <- matrix(c(rg$p11_true[1], 1 - rg$p11_true[1],
               1 - rg$p22_true[1], rg$p22_true[1]), 2, 2, byrow = TRUE)

# ergodic distribution as the t = 0 prior
pi0 <- c((1 - P[2,2]) / (2 - P[1,1] - P[2,2]),
         (1 - P[1,1]) / (2 - P[1,1] - P[2,2]))

filt <- matrix(0, n, 2)
pred <- pi0
loglik <- 0
for (t in 1:n) {
  dens <- dnorm(y[t], mu, sd_)          # update
  num  <- pred * dens
  s    <- sum(num)
  loglik  <- loglik + log(s)
  filt[t, ] <- num / s
  pred <- as.vector(t(P) %*% filt[t, ])  # predict
}
state_hat <- apply(filt, 1, which.max)
Hamilton filter at the TRUE parameters, n = 800

  log-likelihood                         -1668.3034
  hit rate against state_true                96.25%
  share in state 2: true 12.8%, filtered 9.8%
  mean P(state 2) when truly in state 2     0.756
  mean P(state 2) when truly in state 1     0.035
Code
import numpy as np
import pandas as pd
from scipy.stats import norm

rg = pd.read_csv("../data/mfsim-regime.csv")
y  = rg["y"].to_numpy(); n = len(y)

mu  = np.array([rg["mu1_true"][0], rg["mu2_true"][0]])
sd_ = np.array([rg["sd1_true"][0], rg["sd2_true"][0]])
P   = np.array([[rg["p11_true"][0], 1 - rg["p11_true"][0]],
                [1 - rg["p22_true"][0], rg["p22_true"][0]]])

# ergodic distribution as the t = 0 prior
pi0 = np.array([(1 - P[1,1]) / (2 - P[0,0] - P[1,1]),
                (1 - P[0,0]) / (2 - P[0,0] - P[1,1])])

filt = np.zeros((n, 2)); pred = pi0; loglik = 0.0
for t in range(n):
    dens = norm.pdf(y[t], mu, sd_)        # update
    num  = pred * dens
    s    = num.sum()
    loglik += np.log(s)
    filt[t] = num / s
    pred = P.T @ filt[t]                  # predict

state_hat = filt.argmax(axis=1) + 1
st = rg["state_true"].to_numpy()

out = (f"Hamilton filter at the TRUE parameters, n = {n}\n\n"
       f"  log-likelihood                       {loglik:12.4f}\n"
       f"  hit rate against state_true          {100*(state_hat==st).mean():11.2f}%\n"
       f"  share in state 2: true {100*(st==2).mean():.1f}%, "
       f"filtered {100*(state_hat==2).mean():.1f}%\n"
       f"  mean P(state 2) when truly in state 2  {filt[st==2,1].mean():8.3f}\n"
       f"  mean P(state 2) when truly in state 1  {filt[st==1,1].mean():8.3f}\n")
import sys; nchars = sys.stdout.write(out); sys.stdout.flush()
Hamilton filter at the TRUE parameters, n = 800

  log-likelihood                         -1668.3034
  hit rate against state_true                96.25%
  share in state 2: true 12.8%, filtered 9.8%
  mean P(state 2) when truly in state 2     0.756
  mean P(state 2) when truly in state 1     0.035
Code
quietly {
    import delimited "../data/mfsim-regime.csv", clear
    tsset t
    scalar mu1 = mu1_true[1]
    scalar mu2 = mu2_true[1]
    scalar sd1 = sd1_true[1]
    scalar sd2 = sd2_true[1]
    scalar p11 = p11_true[1]
    scalar p22 = p22_true[1]
    scalar nn  = _N

    * ergodic distribution as the t = 0 prior
    scalar pr1 = (1 - p22)/(2 - p11 - p22)
    scalar pr2 = (1 - p11)/(2 - p11 - p22)

    generate double f1 = .
    generate double f2 = .
    scalar ll = 0
    forvalues i = 1/`=nn' {
        scalar d1 = normalden(y[`i'], mu1, sd1)
        scalar d2 = normalden(y[`i'], mu2, sd2)
        scalar n1 = pr1*d1
        scalar n2 = pr2*d2
        scalar ss = n1 + n2
        scalar ll = ll + ln(ss)
        quietly replace f1 = n1/ss in `i'
        quietly replace f2 = n2/ss in `i'
        scalar pr1 = p11*(n1/ss) + (1 - p22)*(n2/ss)
        scalar pr2 = (1 - p11)*(n1/ss) + p22*(n2/ss)
    }
    generate byte shat = cond(f2 > f1, 2, 1)
    count if shat == state_true
    scalar hit = 100*r(N)/nn
    count if state_true == 2
    scalar tr2 = 100*r(N)/nn
    count if shat == 2
    scalar fl2 = 100*r(N)/nn
    summarize f2 if state_true == 2, meanonly
    scalar m2 = r(mean)
    summarize f2 if state_true == 1, meanonly
    scalar m1 = r(mean)
}

display "Hamilton filter at the TRUE parameters, n = " %3.0f nn _newline ///
  _newline ///
  "  log-likelihood                       " %12.4f ll _newline ///
  "  hit rate against state_true          " %11.2f hit "%" _newline ///
  "  share in state 2: true " %4.1f tr2 "%, filtered " %4.1f fl2 "%" _newline ///
  "  mean P(state 2) when truly in state 2  " %8.3f m2 _newline ///
  "  mean P(state 2) when truly in state 1  " %8.3f m1

* Maximum likelihood, using Stata's native command
mswitch dr y, varswitch nolog
Hamilton filter at the TRUE parameters, n = 800

  log-likelihood                         -1668.3034
  hit rate against state_true                96.25%
  share in state 2: true 12.8%, filtered  9.8%
  mean P(state 2) when truly in state 2     0.756
  mean P(state 2) when truly in state 1     0.035


Performing EM optimization:

Performing gradient-based optimization:


Markov-switching dynamic regression

Sample: 1 thru 800                                      Number of obs =    800
Number of states = 2                                    AIC           = 4.1725
Unconditional probabilities: transition                 HQIC          = 4.1860
                                                        SBIC          = 4.2077
Log likelihood = -1663.0172

------------------------------------------------------------------------------
           y | Coefficient  Std. err.      z    P>|z|     [95% conf. interval]
-------------+----------------------------------------------------------------
State1       |
       _cons |  -2.376936   .5358729    -4.44   0.000    -3.427227   -1.326644
-------------+----------------------------------------------------------------
State2       |
       _cons |   3.152133    .058829    53.58   0.000      3.03683    3.267436
-------------+----------------------------------------------------------------
      sigma1 |    3.64527     .33412                      3.045859    4.362642
-------------+----------------------------------------------------------------
      sigma2 |   1.502333   .0433661                      1.419697     1.58978
-------------+----------------------------------------------------------------
         p11 |    .755989   .0534672                      .6370844    .8453904
-------------+----------------------------------------------------------------
         p21 |   .0355296   .0079979                      .0227837    .0550046
------------------------------------------------------------------------------

All three filters return a log-likelihood of \(-1668.3034\) and a hit rate of 96.25% — identical, because the recursion is deterministic once the parameters are fixed. The filter is confident when it should be (mean \(P(\text{state }2)\) is 0.756 when the economy really is in state 2, and 0.035 when it is not) and it slightly under-calls the rare state: 9.8% of periods against a true 12.8%, which is what a rule that picks the higher probability does when one state is uncommon.

Stata’s mswitch estimates the same model freely and recovers \(\mu = (-2.38, 3.15)\) against a true \((-2.0, 3.2)\) and \(\sigma = (3.65, 1.50)\) against \((3.4, 1.6)\) — note it labels the low state as State1, the reverse of the data script’s convention.

Simulating Macro Scenarios

Stress testing asks a question simulation is made for: given where the economy is now, what is the whole distribution of where it could be in three years?

Fit a VAR(1) to growth, inflation and the policy rate,

\[z_{t+1} = c + A \, z_t + \varepsilon_{t+1}, \qquad \varepsilon \sim N(0, \Sigma)\]

and the \(h\)-step forecast distribution is available in closed form — no simulation required:

\[\mathbb{E}_t \left[ z_{t+h} \right] = \left( I + A + \dots + A^{h-1} \right) c + A^h z_t\]

\[\mathbb{V}_t \left[ z_{t+h} \right] = \sum_{j=0}^{h-1} A^j \Sigma \left( A^j \right)'\]

That matters for two reasons. It makes the fan chart deterministic, so all three languages draw the identical picture. And it gives an exact benchmark against which a simulated fan can be checked — which is the honest way to validate any simulation you intend to trust.

  h mean   sd   q05  q95
  1 2.12 4.26 -4.90 9.13
  4 2.39 4.28 -4.65 9.42
  8 2.56 4.29 -4.49 9.61
 12 2.64 4.29 -4.42 9.69 
Code
import numpy as np
import pandas as pd

mm = pd.read_csv("../data/mfsim-macro.csv").dropna(
        subset=["nfci", "infl", "ffr"]).reset_index(drop=True)
Y  = mm[["dgdp", "infl", "ffr"]].to_numpy(); nn = len(Y)
X  = np.column_stack([np.ones(nn-1), Y[:-1]])
B  = np.linalg.solve(X.T @ X, X.T @ Y[1:])
E  = Y[1:] - X @ B
S  = E.T @ E / (nn - 1 - 4)
A  = B[1:].T; cc = B[0]

H = 12
mu_h = np.zeros((H, 3)); Vp = np.zeros((3, 3)); cur = Y[-1]; sd_h = np.zeros(H)
for h in range(H):
    cur = cc + A @ cur; mu_h[h] = cur
    Vp  = A @ Vp @ A.T + S
    sd_h[h] = np.sqrt(Vp[0, 0])

rows = [[h, round(mu_h[h-1,0],2), round(sd_h[h-1],2),
         round(mu_h[h-1,0]-1.645*sd_h[h-1],2),
         round(mu_h[h-1,0]+1.645*sd_h[h-1],2)] for h in (1,4,8,12)]
tab = pd.DataFrame(rows, columns=["h", "mean", "sd", "q05", "q95"])

ev = np.sort(np.abs(np.linalg.eigvals(A)))[::-1]
out = (f"VAR(1) eigenvalues {ev[0]:.3f}, {ev[1]:.3f}, {ev[2]:.3f}\n\n"
       + tab.to_string(index=False) + "\n")
import sys; nchars = sys.stdout.write(out); sys.stdout.flush()
VAR(1) eigenvalues 0.967, 0.814, 0.007

 h  mean   sd   q05  q95
 1  2.12 4.26 -4.90 9.13
 4  2.39 4.28 -4.65 9.42
 8  2.56 4.29 -4.49 9.61
12  2.64 4.29 -4.42 9.69
Code
quietly {
    import delimited "../data/mfsim-macro.csv", clear stringcols(3)
    tsset t
    drop if missing(nfci) | missing(infl) | missing(ffr)
    * dfk applies the small-sample divisor (T - k), matching the R/Python code
    var dgdp infl ffr, lags(1) dfk
    matrix Bm = e(b)
    matrix Sm = e(Sigma)
    scalar nn = e(N)

    * companion matrix A and intercept c from the VAR coefficient vector
    matrix A = J(3,3,0)
    matrix cc = J(3,1,0)
    forvalues i = 1/3 {
        forvalues j = 1/3 {
            matrix A[`i',`j'] = Bm[1, (`i'-1)*4 + `j']
        }
        matrix cc[`i',1] = Bm[1, (`i'-1)*4 + 4]
    }
    matrix z0 = J(3,1,0)
    matrix z0[1,1] = dgdp[_N]
    matrix z0[2,1] = infl[_N]
    matrix z0[3,1] = ffr[_N]

    matrix cur = z0
    matrix Vp  = J(3,3,0)
    scalar h1 = .
    forvalues h = 1/12 {
        matrix cur = cc + A*cur
        matrix Vp  = A*Vp*A' + Sm
        scalar m_`h' = cur[1,1]
        scalar s_`h' = sqrt(Vp[1,1])
    }
    local qN = qtr[_N]
}

display "analytic GDP growth fan from `qN'" _newline _newline ///
  "  h    mean      sd     q05     q95" _newline ///
  "  1 " %7.2f m_1 " " %7.2f s_1 " " %7.2f m_1-1.645*s_1 " " %7.2f m_1+1.645*s_1 _newline ///
  "  4 " %7.2f m_4 " " %7.2f s_4 " " %7.2f m_4-1.645*s_4 " " %7.2f m_4+1.645*s_4 _newline ///
  "  8 " %7.2f m_8 " " %7.2f s_8 " " %7.2f m_8-1.645*s_8 " " %7.2f m_8+1.645*s_8 _newline ///
  " 12 " %7.2f m_12 " " %7.2f s_12 " " %7.2f m_12-1.645*s_12 " " %7.2f m_12+1.645*s_12
analytic GDP growth fan from 2026Q2

  h    mean      sd     q05     q95
  1    2.12    4.26   -4.90    9.13
  4    2.39    4.28   -4.65    9.42
  8    2.56    4.29   -4.49    9.61
 12    2.64    4.29   -4.42    9.69

The fan widens fast and then stops: by \(h = 4\) the standard deviation is already 4.28pp and it is still 4.29 at \(h = 12\). That is what a stable VAR does — the largest eigenvalue is 0.967, so uncertainty converges to the unconditional variance within about a year. A three-year-ahead macro forecast is barely more uncertain than a one-year-ahead one, which is either reassuring or an indictment of the model, depending on how much you believe the constant-coefficient assumption over that horizon.

Tail Simulation & Importance Sampling

Ask the fan chart a policy question: what is the probability that growth four quarters from now is below \(-5\%\)? From the previous slide the answer is analytic — but only because the VAR is linear and Gaussian. Add a regime, an occasionally binding constraint or a nonlinear policy rule and it is not.

So you simulate. And immediately hit the problem Part 2 warned about: to estimate a probability of about 4%, 96% of your draws land in the region you do not care about. The estimator’s relative error is governed by \(\sqrt{(1-p)/(pB)}\), which blows up as \(p \to 0\).

Importance sampling fixes this by drawing from a different distribution — one centred on the disaster — and correcting with the likelihood ratio:

\[p = \mathbb{E}_f\!\left[ \mathbb{1}\{X < c\} \right] = \mathbb{E}_g\!\left[ \mathbb{1}\{X < c\} \, \frac{f(X)}{g(X)} \right]\]

Here \(f\) is the forecast density \(N(\mu_4, \sigma_4^2)\) and \(g\) shifts the mean onto the threshold, \(g = N(c, \sigma_4^2)\). Every draw is now informative, and the weight \(f/g\) undoes the tilt so the estimator stays unbiased.

Code
thr <- -5
mu4 <- mu_h[4, 1]; s4 <- sd_h[4]

set.seed(14159)
M <- 20000; R <- 200
plain <- numeric(R); impor <- numeric(R)
for (r in 1:R) {
  x        <- rnorm(M, mu4, s4)
  plain[r] <- mean(x < thr)

  xs       <- rnorm(M, thr, s4)              # tilt the mean onto the threshold
  w        <- dnorm(xs, mu4, s4) / dnorm(xs, thr, s4)
  impor[r] <- mean((xs < thr) * w)
}
exact <- pnorm((thr - mu4) / s4)
P( GDP growth 4 quarters ahead < -5% )
forecast density: mean 2.39, sd 4.28    M = 20000 draws, R = 200 runs

              method estimate sd_across_runs var_ratio
    exact (Gaussian)  0.04219             NA        NA
   plain Monte Carlo  0.04223       0.001416       1.0
 importance sampling  0.04222       0.000449       9.9
Code
import numpy as np
import pandas as pd
from scipy.stats import norm

thr = -5.0
mu4, s4 = mu_h[3, 0], sd_h[3]

rng = np.random.default_rng(14159)
M, R = 20000, 200
plain = np.zeros(R); impor = np.zeros(R)
for r in range(R):
    x = rng.normal(mu4, s4, M)
    plain[r] = (x < thr).mean()

    xs = rng.normal(thr, s4, M)            # tilt the mean onto the threshold
    w  = norm.pdf(xs, mu4, s4) / norm.pdf(xs, thr, s4)
    impor[r] = ((xs < thr) * w).mean()

exact = norm.cdf((thr - mu4) / s4)

tab = pd.DataFrame({
    "method": ["exact (Gaussian)", "plain Monte Carlo", "importance sampling"],
    "estimate": np.round([exact, plain.mean(), impor.mean()], 5),
    "sd_across_runs": [np.nan, round(plain.std(ddof=1), 6),
                       round(impor.std(ddof=1), 6)],
    "var_ratio": [np.nan, 1.0,
                  round(plain.var(ddof=1) / impor.var(ddof=1), 1)]})

out = (f"P( GDP growth 4 quarters ahead < {thr:.0f}% )\n"
       f"forecast density: mean {mu4:.2f}, sd {s4:.2f}    "
       f"M = {M} draws, R = {R} runs\n\n"
       + tab.to_string(index=False) + "\n")
import sys; nchars = sys.stdout.write(out); sys.stdout.flush()
P( GDP growth 4 quarters ahead < -5% )
forecast density: mean 2.39, sd 4.28    M = 20000 draws, R = 200 runs

             method  estimate  sd_across_runs  var_ratio
   exact (Gaussian)   0.04219             NaN        NaN
  plain Monte Carlo   0.04217        0.001452        1.0
importance sampling   0.04224        0.000419       12.0

The mechanics carry over to Stata unchanged — rnormal(), normalden() and a loop are all it takes — but the point of the slide is the variance ratio, and that is an R/Python comparison here to keep the code short. Stata’s role in Part 6 is qreg and mswitch, both of which it does natively.

Both estimators are unbiased — they agree with the exact 0.04219 to four decimals — but the importance sampler’s standard deviation across runs is 0.00045 against 0.00142, a variance ratio of about 10. Ten times the accuracy from the same 20 000 draws, purely by drawing them where the answer lives.

The gain grows as the event gets rarer, which is exactly when you need it: at a threshold of \(-10\%\) the ratio runs into the hundreds. This is the single most useful trick in tail-risk simulation, and it is four lines of code.

Part 7 — Estimation by Simulation

Simulated method of moments, indirect inference, weak identification, and
what a century of data can and cannot pin down.

Simulated Method of Moments

Part 3 calibrated: parameters were set to values the literature finds reasonable, and the model’s implications were then compared with the data. That is a legitimate strategy and an honest one, but it answers the wrong question. It tells you what the model does at a point. It does not tell you which points the data can rule out.

Estimation answers that. The obstacle is that the models in this deck have no likelihood you can write down — the long-run risk state \(x_t\) is latent, the policy function comes out of a fixed point, the pricing is a linear solve. What they can do, cheaply, is produce simulated data.

Simulated method of moments (McFadden, Duffie–Singleton) needs nothing more than that. Pick moments you care about, compute them in the data, compute them in a long simulation, and move the parameters until the two agree.

Let \(m(\text{data})\) be a vector of sample moments and \(m^S(\vartheta)\) the same moments computed on \(S\) simulated paths at parameter \(\vartheta\). SMM solves

\[\hat\vartheta = \arg\min_{\vartheta} \; g(\vartheta)' \, W \, g(\vartheta), \qquad g(\vartheta) = m^S(\vartheta) - m(\text{data})\]

with \(W\) a weighting matrix — here \(W = \mathrm{diag}(1/m_i^2)\), so each moment is matched in relative terms and a standard deviation of 0.008 is not swamped by a variance ratio of 2.5.

Which moments? The whole game. The long-run risk component is invisible in the one-period autocorrelation of consumption growth — Part 1 measured \(\text{ac}_1 = 0.044\), which is nothing. It shows up in variance ratios:

\[\mathrm{VR}(k) = \frac{\mathbb{V}\!\left( \sum_{j=1}^{k} \Delta c_{t+j} \right)}{k \, \mathbb{V}\!\left( \Delta c_t \right)} = 1 + 2 \sum_{j=1}^{k-1} \left( 1 - \frac{j}{k} \right) \mathrm{ac}_j\]

Under i.i.d. growth \(\mathrm{VR}(k) = 1\) for every \(k\). In the long-run risk laboratory the five-year ratio is 2.48. A tiny autocorrelation, compounded over sixty months, is a large effect — and it is the only thing identifying \(\rho\).

Target moments: \(\left\{ \sigma(\Delta c), \; \mathrm{VR}(12), \; \mathrm{VR}(60) \right\}\); parameters \(\vartheta = (\rho, \varphi_e)\).

SMM — Recovering Known Truth

Estimated on ../data/mfsim-lrr.csv, whose true \(\rho = 0.979\) and \(\varphi_e = 0.044\) are carried in the file. The model moments have a closed form here — an AR(1) plus noise — so the objective is exact and identical in all three languages. In a model without closed forms the same code runs with \(m^S(\vartheta)\) from a simulation, at the cost of a noisy surface.

Code
lr <- read.csv("../data/mfsim-lrr.csv")
dc <- lr$dc

vr_emp <- function(x, k) {
  m <- length(x); s <- numeric(m - k + 1)
  for (i in 1:(m - k + 1)) s[i] <- sum(x[i:(i + k - 1)])
  var(s) / (k * var(x))
}
m_data <- c(sd(dc), vr_emp(dc, 12), vr_emp(dc, 60))

sig <- 0.0078
m_model <- function(rho, phie) {
  vx  <- phie^2 * sig^2 / (1 - rho^2)
  vd  <- vx + sig^2
  lam <- vx / vd
  vr  <- function(k) 1 + 2*lam*sum((1 - (1:(k-1))/k) * rho^(1:(k-1)))
  c(sqrt(vd), vr(12), vr(60))
}

W   <- diag(1 / m_data^2)                       # match in relative terms
obj <- function(p) {
  g <- m_model(p[1], p[2]) - m_data
  as.numeric(t(g) %*% W %*% g)
}
fit <- optim(c(0.95, 0.05), obj, method = "L-BFGS-B",
             lower = c(0.80, 0.005), upper = c(0.999, 0.20))
SMM on 3000 simulated months, moments matched in relative terms

 moment    data  fitted      gap
 sd(dc) 0.00825 0.00797 -0.00028
 VR(12) 1.40090 1.41365  0.01275
 VR(60) 2.47836 2.46956 -0.00880

 parameter estimate truth     gap
       rho   0.9696 0.979 -0.0094
     phi_e   0.0518 0.044  0.0078

objective at the optimum  1.2214e-03
Code
import numpy as np
import pandas as pd
from scipy.optimize import minimize

lr = pd.read_csv("../data/mfsim-lrr.csv")
dc = lr["dc"].to_numpy()

def vr_emp(x, k):
    s = np.array([x[i:i+k].sum() for i in range(len(x)-k+1)])
    return s.var(ddof=1) / (k * x.var(ddof=1))

m_data = np.array([dc.std(ddof=1), vr_emp(dc, 12), vr_emp(dc, 60)])

sig = 0.0078
def m_model(rho, phie):
    vx  = phie**2 * sig**2 / (1 - rho**2)
    vd  = vx + sig**2
    lam = vx / vd
    def vr(k):
        j = np.arange(1, k)
        return 1 + 2*lam*np.sum((1 - j/k) * rho**j)
    return np.array([np.sqrt(vd), vr(12), vr(60)])

W = np.diag(1 / m_data**2)                       # match in relative terms
def obj(p):
    g = m_model(p[0], p[1]) - m_data
    return float(g @ W @ g)

fit = minimize(obj, [0.95, 0.05], method="L-BFGS-B",
               bounds=[(0.80, 0.999), (0.005, 0.20)])
mhat = m_model(*fit.x)

mt = pd.DataFrame({"moment": ["sd(dc)", "VR(12)", "VR(60)"],
                   "data": np.round(m_data, 5),
                   "fitted": np.round(mhat, 5),
                   "gap": np.round(mhat - m_data, 5)})
truth = np.array([lr["rho_true"][0], lr["phie_true"][0]])
pt = pd.DataFrame({"parameter": ["rho", "phi_e"],
                   "estimate": np.round(fit.x, 4),
                   "truth": truth,
                   "gap": np.round(fit.x - truth, 4)})

out = (f"SMM on {len(dc)} simulated months, moments matched in relative terms\n\n"
       + mt.to_string(index=False) + "\n\n" + pt.to_string(index=False)
       + f"\n\nobjective at the optimum  {fit.fun:.4e}\n")
import sys; nchars = sys.stdout.write(out); sys.stdout.flush()
SMM on 3000 simulated months, moments matched in relative terms

moment    data  fitted      gap
sd(dc) 0.00825 0.00797 -0.00028
VR(12) 1.40090 1.40564  0.00474
VR(60) 2.47836 2.47727 -0.00109

parameter  estimate  truth     gap
      rho    0.9718  0.979 -0.0072
    phi_e    0.0492  0.044  0.0052

objective at the optimum  1.1791e-03
Code
quietly {
    import delimited "../data/mfsim-lrr.csv", clear
    tsset t
    scalar rho_t  = rho_true[1]
    scalar phie_t = phie_true[1]
    scalar nn = _N

    summarize dc
    scalar sd_d = r(sd)
    scalar vdc  = r(Var)

    * variance ratios: variance of the k-period sum over k times the one-period
    foreach k in 12 60 {
        generate double s`k' = 0
        forvalues j = 0/`=`k'-1' {
            quietly replace s`k' = s`k' + F`j'.dc
        }
        summarize s`k'
        scalar vr`k' = r(Var)/(`k'*vdc)
        drop s`k'
    }
}

* grid search on the exact model moments, then a local refinement
quietly {
    scalar sig = 0.0078
    scalar best = 1e30
    forvalues i = 1/199 {
        scalar rr = 0.80 + `i'*0.001
        forvalues j = 1/195 {
            scalar pp = 0.005 + `j'*0.001
            scalar vx = pp^2*sig^2/(1 - rr^2)
            scalar vd = vx + sig^2
            scalar lm = vx/vd
            scalar a12 = 0
            forvalues q = 1/11 {
                scalar a12 = a12 + (1 - `q'/12)*rr^`q'
            }
            scalar a60 = 0
            forvalues q = 1/59 {
                scalar a60 = a60 + (1 - `q'/60)*rr^`q'
            }
            scalar g1 = (sqrt(vd) - sd_d)/sd_d
            scalar g2 = ((1 + 2*lm*a12) - vr12)/vr12
            scalar g3 = ((1 + 2*lm*a60) - vr60)/vr60
            scalar oo = g1^2 + g2^2 + g3^2
            if (oo < best) {
                scalar best = oo
                scalar rhat = rr
                scalar phat = pp
            }
        }
    }
    scalar vx = phat^2*sig^2/(1 - rhat^2)
    scalar vd = vx + sig^2
    scalar lm = vx/vd
    scalar a12 = 0
    forvalues q = 1/11 {
        scalar a12 = a12 + (1 - `q'/12)*rhat^`q'
    }
    scalar a60 = 0
    forvalues q = 1/59 {
        scalar a60 = a60 + (1 - `q'/60)*rhat^`q'
    }
    scalar f1 = sqrt(vd)
    scalar f2 = 1 + 2*lm*a12
    scalar f3 = 1 + 2*lm*a60
}

display "SMM on " %4.0f nn " simulated months, moments matched in relative terms" ///
  _newline _newline ///
  " moment      data     fitted        gap" _newline ///
  " sd(dc) " %9.5f sd_d " " %10.5f f1 " " %10.5f f1-sd_d _newline ///
  " VR(12) " %9.5f vr12 " " %10.5f f2 " " %10.5f f2-vr12 _newline ///
  " VR(60) " %9.5f vr60 " " %10.5f f3 " " %10.5f f3-vr60 _newline ///
  _newline ///
  " parameter  estimate      truth        gap" _newline ///
  "       rho " %9.4f rhat " " %10.4f rho_t " " %10.4f rhat-rho_t _newline ///
  "     phi_e " %9.4f phat " " %10.4f phie_t " " %10.4f phat-phie_t _newline ///
  _newline ///
  "objective at the optimum  " %10.4e best
SMM on 3000 simulated months, moments matched in relative terms

 moment      data     fitted        gap
 sd(dc)   0.00825    0.00862    0.00037
 VR(12)   1.40090    1.38922   -0.01167
 VR(60)   2.47836    2.47280   -0.00556

 parameter  estimate      truth        gap
       rho    0.9750     0.9790    -0.0040
     phi_e    0.0450     0.0440     0.0010

objective at the optimum   2.111e-03

SMM — Does It Recover the Truth?

estimate truth gap
\(\rho\) 0.9696 0.9790 \(-0.0094\)
\(\varphi_e\) 0.0518 0.0440 \(+0.0078\)

All three moments are matched to four decimals, and both parameters land within about 20% of the truth on 3000 months of data. Taken at face value that is a success: SMM works, the code is right, and the estimator is consistent.

Look at the direction of the two errors, though. \(\rho\) is too low and \(\varphi_e\) is too high — and they are not independent mistakes. Both feed the same object, \(\mathbb{V}(x) = \varphi_e^2\sigma^2/(1-\rho^2)\), which the moments pin down tightly. Their separation is what the moments barely see.

Recall Part 1’s diagnostic slide, which estimated the autocorrelation of the observed latent state and got \(0.9773\) against a true \(0.979\) — a downward bias from the near-unit-root problem. SMM, working only with \(\Delta c\) and never seeing \(x_t\) at all, gets \(0.9696\): the same bias, larger, because the information is weaker.

The practical question is not whether the point estimate is close. It is how much worse an estimate the data would have tolerated — and that is the next slide, which is where this part earns its place.

SMM on a Noisy Objective — Common Random Numbers

The last two slides cheated, and the cheat should be named. Their model moments had a closed form, so the objective was an exact function of \(\vartheta\) and the optimiser saw a clean surface. Real models do not have that. The whole point of simulated method of moments is that \(m^S(\vartheta)\) comes from simulating the model, and a simulation needs shocks.

Draw fresh shocks at every trial \(\vartheta\) and the objective stops being a function at all:

\[Q^S(\vartheta) = \left[ m^S(\vartheta) - m_{\text{data}} \right]' W \left[ m^S(\vartheta) - m_{\text{data}} \right]\]

evaluates to a different number every time it is called at the same \(\vartheta\). A finite-difference gradient then measures noise rather than slope, and the optimiser chases it.

The fix is one line of code and it is not optional. Draw the simulation shocks once, before the optimisation starts, and reuse those same draws at every \(\vartheta\). The objective becomes a deterministic function of \(\vartheta\) again — biased, but a function — and the optimiser can do its job. This is common random numbers.

The test is a restart test. Three starting points, the same estimation run twice: once redrawing the shocks inside the objective, once holding them fixed. Only one of the two ways gives the same answer three times.

The simulation-noise cost does not disappear — it is paid in variance, not in reproducibility. With \(S\) simulated paths the asymptotic variance is inflated by a factor \(1 + 1/S\) (Duffie & Singleton 1993), so \(S = 10\) costs about 10% extra standard error. That is the price; chasing noise was never the price, it was a bug.

Code
crlr <- read.csv("../data/mfsim-lrr.csv")
crdc <- crlr$dc
crT  <- length(crdc); crS <- 10
crsig <- 0.0078; crmu <- 0.0015

# variance ratio through a cumulative sum: O(T) rather than O(T k)
crvr <- function(x, k) {
  cs <- c(0, cumsum(x))
  s  <- cs[(k + 1):length(cs)] - cs[1:(length(cs) - k)]
  var(s) / (k * var(x))
}
crmd <- c(sd(crdc), crvr(crdc, 12), crvr(crdc, 60))

# moments of S simulated paths, given shock matrices E and N
crsim <- function(p, E, N) {
  M <- matrix(0, 3, ncol(E))
  for (j in 1:ncol(E)) {
    x  <- as.numeric(stats::filter(p[2] * crsig * E[, j], p[1], method = "recursive"))
    dc <- crmu + c(0, x[-crT]) + crsig * N[, j]
    M[, j] <- c(sd(dc), crvr(dc, 12), crvr(dc, 60))
  }
  rowMeans(M)
}
crcrit <- function(m) sum(((m - crmd) / crmd)^2)

set.seed(14159)
crE <- matrix(rnorm(crT * crS), crT, crS)   # drawn ONCE -- common random numbers
crN <- matrix(rnorm(crT * crS), crT, crS)
crstart <- rbind(c(0.960, 0.055), c(0.970, 0.050), c(0.980, 0.046))

crrun <- function(fixed) {
  out <- matrix(0, 3, 2)
  for (i in 1:3) {
    f <- function(p) {
      if (fixed) { E <- crE; N <- crN } else {
        E <- matrix(rnorm(crT * crS), crT, crS)
        N <- matrix(rnorm(crT * crS), crT, crS)
      }
      crcrit(crsim(p, E, N))
    }
    fit <- optim(crstart[i, ], f, method = "L-BFGS-B",
                 lower = c(0.80, 0.005), upper = c(0.999, 0.20),
                 control = list(factr = 1e-8, pgtol = 0, maxit = 2000,
                                ndeps = rep(1e-6, 2)))
    out[i, ] <- fit$par
  }
  out
}
set.seed(14159); crRD <- crrun(FALSE)      # shocks redrawn every call
set.seed(14159); crCR <- crrun(TRUE)       # shocks held fixed
SMM with simulated moments: S = 10 paths of 3000 months

           start        redrawn shocks         fixed shocks (CRN)
                          rho      phi_e           rho      phi_e
  (0.960, 0.055)     0.960020   0.055144      0.978645   0.044220
  (0.970, 0.050)     0.969887   0.049960      0.978645   0.044220
  (0.980, 0.046)     0.979110   0.045871      0.978645   0.044220

  max pairwise gap in rho-hat   redrawn 1.909e-02    CRN 6.790e-12

  closed-form SMM answer, previous slide   rho = 0.9696
  CRN answer here at S = 10               rho = 0.9786
Code
import numpy as np
import pandas as pd
from scipy.optimize import minimize
from scipy.signal import lfilter

crlr = pd.read_csv("../data/mfsim-lrr.csv")
crdc = crlr["dc"].to_numpy()
crT, crS = len(crdc), 10
crsig, crmu = 0.0078, 0.0015

# variance ratio through a cumulative sum: O(T) rather than O(T k)
def crvr(x, k):
    cs = np.concatenate([[0.0], np.cumsum(x)])
    s  = cs[k:] - cs[:-k]
    return s.var(ddof=1) / (k * x.var(ddof=1))

crmd = np.array([crdc.std(ddof=1), crvr(crdc, 12), crvr(crdc, 60)])

# moments of S simulated paths, given shock matrices E and N
def crsim(p, E, N):
    M = np.zeros((3, E.shape[1]))
    for j in range(E.shape[1]):
        x  = lfilter([1.0], [1.0, -p[0]], p[1] * crsig * E[:, j])
        dc = crmu + np.concatenate([[0.0], x[:-1]]) + crsig * N[:, j]
        M[:, j] = [dc.std(ddof=1), crvr(dc, 12), crvr(dc, 60)]
    return M.mean(axis=1)

def crcrit(m):
    return float(np.sum(((m - crmd) / crmd)**2))

rng = np.random.default_rng(14159)
crE = rng.normal(size=(crT, crS))       # drawn ONCE -- common random numbers
crN = rng.normal(size=(crT, crS))
crstart = np.array([[0.960, 0.055], [0.970, 0.050], [0.980, 0.046]])

def crrun(fixed, rng):
    out = np.zeros((3, 2))
    for i in range(3):
        def f(p):
            if fixed:
                E, N = crE, crN
            else:
                E = rng.normal(size=(crT, crS))
                N = rng.normal(size=(crT, crS))
            return crcrit(crsim(p, E, N))
        fit = minimize(f, crstart[i], method="L-BFGS-B",
                       bounds=[(0.80, 0.999), (0.005, 0.20)],
                       options=dict(ftol=1e-16, gtol=1e-12, eps=1e-6, maxiter=2000))
        out[i] = fit.x
    return out

crRD = crrun(False, np.random.default_rng(14159))   # shocks redrawn every call
crCR = crrun(True,  np.random.default_rng(14159))   # shocks held fixed

crout = (f"SMM with simulated moments: S = {crS} paths of {crT} months\n\n"
         "           start        redrawn shocks         fixed shocks (CRN)\n"
         "                          rho      phi_e           rho      phi_e\n")
for i in range(3):
    crout += (f"  ({crstart[i,0]:5.3f}, {crstart[i,1]:5.3f})   "
              f"{crRD[i,0]:10.6f} {crRD[i,1]:10.6f}    "
              f"{crCR[i,0]:10.6f} {crCR[i,1]:10.6f}\n")
crout += (f"\n  max pairwise gap in rho-hat   redrawn "
          f"{crRD[:,0].max()-crRD[:,0].min():.3e}    "
          f"CRN {crCR[:,0].max()-crCR[:,0].min():.3e}\n"
          f"\n  closed-form SMM answer, previous slide   rho = {0.9696:.4f}\n"
          f"  CRN answer here at S = {crS}               rho = {crCR[0,0]:.4f}\n")
import sys; nchars = sys.stdout.write(crout); sys.stdout.flush()
SMM with simulated moments: S = 10 paths of 3000 months

           start        redrawn shocks         fixed shocks (CRN)
                          rho      phi_e           rho      phi_e
  (0.960, 0.055)     0.960028   0.054963      0.973212   0.050437
  (0.970, 0.050)     0.970175   0.049726      0.973217   0.050430
  (0.980, 0.046)     0.980006   0.046046      0.973215   0.050434

  max pairwise gap in rho-hat   redrawn 1.998e-02    CRN 5.310e-06

  closed-form SMM answer, previous slide   rho = 0.9696
  CRN answer here at S = 10               rho = 0.9732
Code
* Mata definitions flood the log and break Statamarkdown's capture, so the fit
* runs silently here and hands six numbers to the next chunk through a file.
quietly import delimited "../data/mfsim-lrr.csv", clear
mata:
mata set matastrict off

real scalar crvr(x, k)
{
    cs = 0 \ runningsum(x)
    s  = cs[(k+1)::rows(cs)] - cs[1::(rows(cs)-k)]
    return(variance(s)/(k*variance(x)))
}
real rowvector crsim(rho, phie, E, N)
{
    Tn = rows(E); S = cols(E)
    X = J(Tn, S, 0)
    X[1,.] = phie*0.0078*E[1,.]
    for (t=2; t<=Tn; t++) X[t,.] = rho*X[t-1,.] :+ phie*0.0078*E[t,.]
    m = J(1,3,0)
    for (j=1; j<=S; j++) {
        dc = 0.0015 :+ (0 \ X[|1,j \ Tn-1,j|]) + 0.0078*N[.,j]
        m = m + (sqrt(variance(dc)), crvr(dc,12), crvr(dc,60))
    }
    return(m/S)
}
/* Mata's optimize() has no box constraints, so a logit map supplies them */
real rowvector totrue(th)
{
    return((0.80 + 0.199*invlogit(th[1]), 0.005 + 0.195*invlogit(th[2])))
}
real rowvector tofree(p)
{
    return((logit((p[1]-0.80)/0.199), logit((p[2]-0.005)/0.195)))
}
void crobj(todo, th, md, E0, N0, flag, v, g, H)
{
    if (flag==1) {
        E = E0
        N = N0
    } else {
        E = rnormal(rows(E0), cols(E0), 0, 1)
        N = rnormal(rows(E0), cols(E0), 0, 1)
    }
    p = totrue(th)
    m = crsim(p[1], p[2], E, N)
    v = sum(((m :- md) :/ md):^2)
}

dcd = st_data(., "dc")
Tn  = rows(dcd)
md  = (sqrt(variance(dcd)), crvr(dcd,12), crvr(dcd,60))

rseed(14159)
E0 = rnormal(Tn, 10, 0, 1)              /* drawn ONCE -- common random numbers */
N0 = rnormal(Tn, 10, 0, 1)
st = (0.960, 0.055 \ 0.970, 0.050 \ 0.980, 0.046)
R  = J(3, 6, 0)

for (f=0; f<=1; f++) {
  rseed(14159)
  for (i=1; i<=3; i++) {
    So = optimize_init()
    optimize_init_evaluator(So, &crobj())
    optimize_init_evaluatortype(So, "d0")
    optimize_init_technique(So, "nm")
    optimize_init_nmsimplexdeltas(So, (0.10, 0.10))
    optimize_init_which(So, "min")
    optimize_init_argument(So, 1, md)
    optimize_init_argument(So, 2, E0)
    optimize_init_argument(So, 3, N0)
    optimize_init_argument(So, 4, f)
    optimize_init_params(So, tofree(st[i,.]))
    optimize_init_conv_ptol(So, 1e-11)
    optimize_init_conv_vtol(So, 1e-15)
    optimize_init_tracelevel(So, "none")
    ec = _optimize(So)
    ph = totrue(optimize_result_params(So))
    R[i,1] = st[i,1]
    R[i,2] = st[i,2]
    if (f==0) {
        R[i,3] = ph[1]
        R[i,4] = ph[2]
    } else {
        R[i,5] = ph[1]
        R[i,6] = ph[2]
    }
}
}

stata("clear")
xx = st_addobs(3)
vn = ("s_rho","s_phi","rd_rho","rd_phi","cr_rho","cr_phi")
for (j=1; j<=6; j++) {
    idx = st_addvar("double", vn[j])
    st_store(., idx, R[.,j])
}
end
quietly save _mfsim_crn, replace
Code
quietly use _mfsim_crn, clear
display "SMM with simulated moments: S = 10 paths of 3000 months" _newline ///
  _newline ///
  "           start        redrawn shocks         fixed shocks (CRN)" _newline ///
  "                          rho      phi_e           rho      phi_e"
forvalues i = 1/3 {
    display "  (" %5.3f s_rho[`i'] ", " %5.3f s_phi[`i'] ")   " ///
       %10.6f rd_rho[`i'] " " %10.6f rd_phi[`i'] "    " ///
       %10.6f cr_rho[`i'] " " %10.6f cr_phi[`i']
}
quietly summarize rd_rho
scalar gr = r(max) - r(min)
quietly summarize cr_rho
scalar gc = r(max) - r(min)
display _newline "  max pairwise gap in rho-hat   redrawn " %9.3e gr ///
  "    CRN " %9.3e gc _newline ///
  _newline "  closed-form SMM answer, previous slide   rho = " %6.4f 0.9696 ///
  _newline "  CRN answer here at S = 10               rho = " %6.4f cr_rho[1]
SMM with simulated moments: S = 10 paths of 3000 months

           start        redrawn shocks         fixed shocks (CRN)
                          rho      phi_e           rho      phi_e

  3. }
  (0.960, 0.055)     0.966999   0.055818      0.977568   0.045107
  (0.970, 0.050)     0.970002   0.051754      0.977568   0.045107
  (0.980, 0.046)     0.980842   0.044405      0.977568   0.045107






  max pairwise gap in rho-hat   redrawn  1.38e-02    CRN  1.87e-09

  closed-form SMM answer, previous slide   rho = 0.9696
  CRN answer here at S = 10               rho = 0.9776

Read the two right-hand columns. With shocks redrawn at every call the three restarts return three different answers, and each one sits suspiciously close to where it started — the finite-difference gradient is measuring noise, so the optimiser barely moves and then reports success. With common random numbers the three restarts return the same answer to the printed digit in every language.

The estimate itself is not free. At \(S = 10\) the CRN answer differs from the closed-form estimate of the previous slide in the second decimal, and that gap is simulation noise, not information — the \(1 + 1/S\) variance inflation made visible. Raising \(S\) shrinks it; redrawing the shocks does not.

An optimiser that converges is not an optimiser that is right. On a noisy objective it will converge, report success, and hand back a number that depends on the random seed and on where you started.

Indirect Inference with an Auxiliary Model

SMM asks you to choose moments. Indirect inference (Gouriéroux–Monfort–Renault, Smith) removes that choice: fit a convenient wrong model — the auxiliary model — to the data, fit the same wrong model to simulated data, and match its coefficients instead.

\[\hat\vartheta = \arg\min_{\vartheta} \left[ \hat\beta_{\text{data}} - \hat\beta^S(\vartheta) \right]' W \left[ \hat\beta_{\text{data}} - \hat\beta^S(\vartheta) \right]\]

The auxiliary model needs no economic content whatsoever. It only has to be easy to estimate and sensitive to \(\vartheta\). Here it is an AR(3) on consumption growth — three coefficients plus the residual variance, four numbers that any software fits instantly, and which between them summarise exactly the persistence the long-run risk component is supposed to create.

The binding function \(\vartheta \mapsto \beta(\vartheta)\) has a closed form for an AR(1)-plus-noise process, so once again the objective is exact rather than simulated.

auxiliary model: AR(3) on consumption growth

 coefficient    data  fitted
         ar1 0.04052 0.04124
         ar2 0.03349 0.04025
         ar3 0.04702 0.03936

 parameter    SMM indirect truth
       rho 0.9696   0.9798 0.979
     phi_e 0.0518   0.0438 0.044

objective at the optimum  1.0484e-04
Code
import numpy as np
import pandas as pd
from scipy.optimize import minimize

lr = pd.read_csv("../data/mfsim-lrr.csv")
dc = lr["dc"].to_numpy(); nD = len(dc)

# auxiliary model: AR(3) on consumption growth
Xa = np.column_stack([np.ones(nD-3), dc[2:-1], dc[1:-2], dc[:-3]])
aux_data = np.linalg.lstsq(Xa, dc[3:], rcond=None)[0][1:]
sig = 0.0078

# binding function: theoretical AR(3) coefficients via Yule-Walker
def aux_model(rho, phie):
    vx  = phie**2 * sig**2 / (1 - rho**2)
    lam = vx / (vx + sig**2)
    g   = lam * rho**np.arange(4); g[0] = 1.0
    R   = np.array([[g[0], g[1], g[2]],
                    [g[1], g[0], g[1]],
                    [g[2], g[1], g[0]]])
    return np.linalg.solve(R, g[1:4])

def obj_ii(p):
    return float(np.sum((aux_model(p[0], p[1]) - aux_data)**2))

fit_ii = minimize(obj_ii, [0.95, 0.05], method="L-BFGS-B",
                  bounds=[(0.80, 0.999), (0.005, 0.20)])

bt = pd.DataFrame({"coefficient": ["ar1", "ar2", "ar3"],
                   "data": np.round(aux_data, 5),
                   "fitted": np.round(aux_model(*fit_ii.x), 5)})
pt2 = pd.DataFrame({"parameter": ["rho", "phi_e"],
                    "SMM": [0.9696, 0.0518],
                    "indirect": np.round(fit_ii.x, 4),
                    "truth": [lr["rho_true"][0], lr["phie_true"][0]]})

out = ("auxiliary model: AR(3) on consumption growth\n\n"
       + bt.to_string(index=False) + "\n\n" + pt2.to_string(index=False)
       + f"\n\nobjective at the optimum  {fit_ii.fun:.4e}\n")
import sys; nchars = sys.stdout.write(out); sys.stdout.flush()
auxiliary model: AR(3) on consumption growth

coefficient    data  fitted
        ar1 0.04052 0.04058
        ar2 0.03349 0.04030
        ar3 0.04702 0.04004

parameter    SMM  indirect  truth
      rho 0.9696    0.9941  0.979
    phi_e 0.0518    0.0233  0.044

objective at the optimum  9.5029e-05

A completely different set of statistics — three AR coefficients instead of a standard deviation and two variance ratios — and indirect inference does better: \(\hat\rho = 0.9798\) and \(\hat\varphi_e = 0.0438\), against a truth of 0.979 and 0.044. SMM missed by 0.0094 and 0.0078; indirect inference misses by 0.0008 and 0.0002.

That is not evidence that indirect inference dominates in general. It says the AR(3) coefficients happen to load on this particular parameter pair more sharply than variance ratios do — the choice of auxiliary statistics is doing real work, which is the same discretionary choice SMM makes when it picks moments, merely better disguised.

And it changes nothing about the underlying problem. Both procedures read the same signal from the same 3000 observations, and a point estimate landing close to the truth says nothing about how much worse an estimate the data would have tolerated. The next slide answers that.

Weak Identification — the Flat Objective Surface

This is the slide the fragility results in Parts 3 and 5 have been pointing at. The SMM objective is plotted over the whole \((\rho, \varphi_e)\) plane on a log scale, with the truth, the estimate, and the set of parameters the data cannot reject.


parameters within 5x of the minimum objective: rho in [0.948, 0.993]
implied equity premium at the ends of that set (Part 3 model):
  rho = 0.947  ->   1.04 %/yr
  rho = 0.979  ->   5.04 %/yr   (the calibration)
  rho = 0.994  ->  14.46 %/yr

multi-start from five points across the identified set
  start rho     rho-hat    phi_e-hat    objective   x best
     0.850    0.970529     0.050666   1.1928e-03     1.00
     0.900    0.969257     0.052169   1.2358e-03     1.04
     0.950    0.969597     0.051772   1.2214e-03     1.02
     0.985    0.969111     0.052353   1.2423e-03     1.04
     0.999    0.969192     0.052255   1.2386e-03     1.04

  spread of rho-hat across restarts   0.0014
  width of the identified set         0.0453

The objective is a valley, not a bowl. \(\rho\) and \(\varphi_e\) enter almost entirely through \(\mathbb{V}(x) = \varphi_e^2\sigma^2/(1-\rho^2)\), so raising one and lowering the other leaves the moments essentially unchanged. The red line traces that trade-off; the objective barely rises along it.

Quantitatively: every \(\rho\) between 0.947 and 0.994 fits within a factor of five of the best fit — on 3000 observations, which is 250 years of monthly data, far more than anyone has. And from Part 3’s own solver, that interval implies an equity premium anywhere from 1.04% to 14.46% a year.

The data cannot distinguish “long-run risk explains nothing” from “long-run risk explains more than we observe”. This is not a calibration quibble; it is a statement that the parameter is not identified by these moments at any sample size we will ever have.

A valley is also where a single-start optimiser can quietly lie to you — it converges, reports success, and hands back an answer that depends on where it began. The standard defence costs five lines: start from several points and compare. The table above does exactly that, from five points spanning the whole identified set.

Here the defence pays out nothing, and that is the finding. All five restarts land within \(0.0014\) of each other in \(\hat\rho\) and within a factor of \(1.04\) of the best objective — this surface is flat, but not so flat that L-BFGS-B wanders on it. Note the one thing multi-start did buy: the run started at \(0.850\) reaches a lower objective than the single start of the previous slide, so the headline \(\hat\rho = 0.9696\) was very slightly off the best point available.

The proportions are the lesson. Optimiser spread \(0.0014\); identified-set width \(0.045\) — thirty times larger. Almost none of the uncertainty about \(\rho\) comes from the optimisation, and essentially all of it from the data. Contrast the common-random-numbers slide, where restarts on a noisy objective disagreed in the second decimal: multi-start is cheap insurance that exposes simulation noise far more readily than flatness — and you cannot know which case you are in until you run it.

A Century Is Not Enough

The previous slide questioned what the model’s parameters can be known to. This one questions the number the whole deck has been aiming at: the measured equity premium of 8.61% a year.

That figure is a sample mean of 317 quarterly excess returns with a standard deviation of 16.39% a year. Bootstrapping the postwar sample gives its sampling distribution directly.


sample: 317 quarters = 79.25 years
  measured premium          8.61 %/yr
  standard error            1.84 pp
  bootstrap sd              1.83 pp
  95% CI                  [5.00, 12.21]

  P(measured < 5.04, long-run risk)  0.0250
  P(measured < 4.30, disasters)     0.0094
  P(measured < 0.04, Lucas tree)    0.0000

years of data needed for a given 95% CI width:
  T =  79 years -> se 1.84 pp, CI width 7.23 pp
  T = 200 years -> se 1.16 pp, CI width 4.54 pp
  T = 500 years -> se 0.73 pp, CI width 2.87 pp

The measured equity premium has a standard error of 1.84 percentage points. Its 95% confidence interval is \([5.00, 12.21]\) — an interval more than seven points wide, on the single most-studied number in asset pricing, using every quarter of postwar data there is.

Put the Part 3 models on that distribution and the picture changes completely. Long-run risk produces 5.04%, which sits at the 2.5th percentile of the sampling distribution — low, but not rejected. Rare disasters produce 4.30%, at the 0.9th percentile — marginal. Only the Lucas tree, at 0.04%, is decisively outside; its probability under the sampling distribution is zero to four decimals.

So the deck’s central comparison — “the model gives 5.04% and the data give 8.61%” — is much weaker than it looked in Part 3. Those two numbers are not statistically distinguishable. What is distinguishable is the failure of the Lucas tree, which misses by two orders of magnitude rather than by a couple of standard errors.

The arithmetic of the last block is the uncomfortable part. To halve that confidence interval you need four times the data: 200 years gets you to ±2.3pp, and 500 years to ±1.4pp. Nobody is going to run that experiment.

This is why macro-finance is a simulation field rather than an estimation field. When the data can support only a handful of sharp conclusions, the useful work is in building models whose internal logic is transparent, checking they reproduce what is measurable, and being explicit about which of their implications the data can never test. That is precisely what every “honest verdict” box in this deck has been doing.

Model Comparison

Everything the deck has estimated or simulated, scored against what the data can actually resolve.

model premium %/yr \(r^f\) %/yr inside the 95% CI \([5.00, 12.21]\)? what kills it
US data 1947Q2–2026Q2 8.61 1.42
Lucas tree, \(\gamma = 2\) 0.04 7.98 no\(p < 0.0001\) fails by 239×
Epstein–Zin, i.i.d. 0.18 5.17 no premium untouched by \(\psi\)
Long-run risk 5.04 2.61 yes, at the 2.5th pct \(\rho\) unidentified
Habit Sharpe 0.510 matches 0.525 needs the full P/D solve
Rare disasters 4.30 4.99 borderline, 0.9th pct \(p\) unidentified
One-factor affine curve corr(1y,10y) \(= 1.000\) vs 0.937
Growth-at-Risk silent when the shock is not financial
RBC + finance overlay LP response has the wrong sign

Two of these failures are real and the rest are honest limits. The Lucas tree and i.i.d. Epstein–Zin are rejected — they miss by orders of magnitude, not standard errors. The RBC finance overlay is refuted on a sign. Everything else in the table is a model the data are too weak to convict.

          model premium   rf pctile inside
        US data    8.61 1.42  50.00    yes
     Lucas tree    0.04 7.98   0.00     no
    Epstein-Zin    0.18 5.17   0.00     no
  Long-run risk    5.04 2.61   2.62    yes
 Rare disasters    4.30 4.99   0.96     no
Code
import pandas as pd
from scipy.stats import norm

cmp = pd.DataFrame({
    "model":   ["US data", "Lucas tree", "Epstein-Zin", "Long-run risk",
                "Rare disasters"],
    "premium": [8.61, 0.04, 0.18, 5.04, 4.30],
    "rf":      [1.42, 7.98, 5.17, 2.61, 4.99]})
cmp["pctile"] = (100 * norm.cdf(cmp["premium"], 8.61, 1.84)).round(2)
cmp["inside"] = ["yes" if 5.00 <= p <= 12.21 else "no" for p in cmp["premium"]]
import sys; nchars = sys.stdout.write(cmp.to_string(index=False) + "\n")
         model  premium   rf  pctile inside
       US data     8.61 1.42   50.00    yes
    Lucas tree     0.04 7.98    0.00     no
   Epstein-Zin     0.18 5.17    0.00     no
 Long-run risk     5.04 2.61    2.62    yes
Rare disasters     4.30 4.99    0.96     no
Code
sys.stdout.flush()
Code
quietly {
    clear
    input str15 model premium rf
    "US data"          8.61 1.42
    "Lucas tree"       0.04 7.98
    "Epstein-Zin"      0.18 5.17
    "Long-run risk"    5.04 2.61
    "Rare disasters"   4.30 4.99
    end
    generate double pctile = round(100*normal((premium - 8.61)/1.84), 0.01)
    generate str3 inside = cond(premium >= 5.00 & premium <= 12.21, "yes", "no")
}
list model premium rf pctile inside, noobs clean
             model   premium     rf   pctile   inside  
           US data      8.61   1.42       50      yes  
        Lucas tree       .04   7.98        0       no  
       Epstein-Zin       .18   5.17        0       no  
     Long-run risk      5.04   2.61     2.62      yes  
    Rare disasters       4.3   4.99      .96       no  

Part 8 — Practice, Pitfalls & Exercises

What this deck found, what to report, reproducibility, pitfalls, variations,
exercises, and further reading.

What This Deck Found

Every number below was computed in this deck, on data ending in 2026. None is quoted from a paper.

finding number
Equity premium, 1947Q2–2026Q2 8.61% / yr, sd 16.39%, Sharpe 0.525
Consumption growth volatility 2.13% / yr — eight times smoother
corr(Δc, excess return) 0.061 — the puzzle in one number
Risk aversion from the Euler equation 402
Risk aversion clearing the HJ bound 16.6 (lognormal rule of thumb 24.6)
Rouwenhorst vs Tauchen at ρ = 0.979 exact vs 24.5% too volatile
Variance reduction on a Vasicek bond antithetic 281×, control variate 438×
Lucas tree premium at γ = 2 0.036% — a factor of 239 too small
…and its risk-free rate 7.98% against 1.42% actual
γ needed for the data premium 478, implying \(r^f = -1613\%\)
Epstein–Zin: premium as ψ moves 0.1 → 2.0 unchanged at 0.1800%; \(r^f\) falls 22.9% → 4.9%
Long-run risk premium 5.04%, \(r^f\) 2.61%
…under CRRA (ψ = 1/γ) 0.000% — the mechanism is Epstein–Zin’s, not persistence’s
Habit: \(\sqrt{\gamma(1-\varphi)}\) 0.510 against a measured Sharpe of 0.525
Disasters at γ = 4 4.30%; 26.6% of 79-year samples contain none
finding number
Blanchard–Kahn roots 0.965, 0.950, 1.046 — one unstable, one jump variable
QZ solution vs the data script’s \(g_k\) 0.618247 both, to six decimals by two methods
Certainty equivalence, σ × 10 \(\max\lvert F_{\text{low}} - F_{\text{high}}\rvert\) = 0.000e+00
First-order risk premium 3.0e-17 — machine zero, identically
RBC consumption smoothing sd(Δc)/sd(Δy) 0.315 vs 0.966 in the data
Credit spread, model vs data corr with activity −0.80 vs −0.30 — but the LP has the wrong sign
Local projection, US data trough −2.74% at h = 5; model +1.36% on impact
One-factor curve: corr(1y, 10y) 1.000000 vs 0.9365
GSW principal components PC1 98.43%, PC2 1.50% — 98% of variance, none of the interest
NSS reconstruction of SVENY10 max error 0.00005 pp
Dynamic Nelson–Siegel fit RMSE 0.0374 pp on six maturities, 55 years
Bond portfolio, +100bp level vs steepening +0.05% vs +5.97%
Growth-at-Risk: NFCI coefficient −2.061 at the 5% quantile, +0.009 at the median
GaR in 2008Q4 −9.79% against −1.11% in easy conditions
Highest NFCI reading in all of 2020 −0.08 — still loose. GaR could not see COVID
Hamilton filter hit rate 96.25%, identical in all three languages
Importance sampling on a 4% tail variance ratio 9.9
finding number
SMM on long-run risk ρ̂ 0.9696 vs 0.979; φ̂_e 0.0518 vs 0.044
Indirect inference, AR(3) auxiliary ρ̂ 0.9798 — better, because the statistics load harder
Parameters within 5× of the SMM minimum ρ ∈ [0.948, 0.993] on 250 years of monthly data
…implied equity premium over that set 1.04% to 14.46% per year
Standard error of the measured premium 1.84 pp; 95% CI [5.00, 12.21]
Long-run risk (5.04%) on that distribution 2.5th percentile — not rejected
Lucas tree (0.04%) probability 0.0000 — rejected outright

The last two rows reframe everything above them. The deck’s headline comparison — a model at 5.04% against data at 8.61% — is not statistically distinguishable. Only the failures that miss by orders of magnitude survive as findings: the Lucas tree, i.i.d. Epstein–Zin, and the finance overlay whose local projection has the wrong sign.

What to Report

Anything simulated should arrive with these attached. Every one of them changed a conclusion somewhere in this deck.

  1. The seed, and where it is set. Not “we set a seed” — the literal value and the file. This deck: 14159, in mfsim-data.R and in every chunk.
  2. How the data were built, as a runnable script separate from the analysis, with the download date. Vintages move: GDP for recent quarters will be revised after this deck is rendered.
  3. The solution method and its tolerance. “Solved by perturbation” is not enough — first or second order changes whether a risk premium exists at all.
  4. The Blanchard–Kahn count, printed, not assumed.
  5. Monte Carlo error on every simulated number. If the premium is 5.04%, how much of that last digit is simulation noise?
  6. The moments you matched and the ones you did not. SMM is only as good as its moment choice, and indirect inference merely hides the same choice inside an auxiliary model.
  7. A sampling distribution for the target. The 8.61% premium has a standard error of 1.84pp. A model reproducing 5% is not rejected by it.
  8. Which implications the data cannot test. Part 7’s ridge is the honest version of this.
  • Discretisation error. Papers report “we simulate the CIR process” without saying whether the scheme can produce a negative rate. Euler can, at 0.54% of draws with a two-year step, even when Feller holds.
  • The grid. Tauchen with nine points at ρ = 0.979 makes the process 24.5% too volatile. That is a modelling decision reported as a technical footnote.
  • Which language produced which number. Three implementations of the same algorithm agreed to six decimals here — but only because the deterministic parts were deliberately separated from the stochastic ones.
  • The identified set. Point estimates are reported; the set of parameters that fit almost as well, almost never is.
  • Out-of-sample failures in the period the model was not built for. GaR is well calibrated in-sample at a 4.6% breach rate and breaches 9.1% of the time after 2020.
  • Negative results. The financial-accelerator overlay matched the contemporaneous correlation and got the dynamic response backwards. That is more informative than the moment it matched.

Reproducibility & Computation

One seed, hard-coded. 14159 appears literally in mfsim-data.R and in every chunk that draws a random number. No chunk reads a global seed variable, so any block copied out of these slides reproduces standalone.

Data creation is separate from analysis. mfsim-data.R downloads, derives and writes nine CSVs; the deck only ever reads them. That is what makes a render network-free and stable while the underlying sources are revised.

The parity rule, stated once and applied throughout. Random-number streams differ across R, Python and Stata, so identical seeds do not give identical draws. Anything the three tabs must agree on is generated once and shared as a CSV; anything simulated live inside a tab is labelled as such. Where a result had to be identical — the Rouwenhorst matrix, the DSGE policy function, the DNS factors, the bond scenarios, the Hamilton filter — it was made deterministic by design, not by hoping.

Truth columns. Five of the nine CSVs carry the parameters that generated them, so estimators are scored rather than admired: recovered 0.9696 against a true 0.979, not “the estimate looks reasonable”.

operation scale cost
Rouwenhorst / Tauchen, N = 9 81 entries instant
Long-run risk fixed point 2000 iterations × 9 states < 0.1 s
Price-dividend ratio 9 × 9 linear solve instant — do not iterate it
QZ solution of the DSGE 3 × 3 pencil instant
Variance reduction study 2000 paths × 60 steps × 50 runs ~1 s vectorised
Disaster peso experiment 2 000 000 draws + 20 000 subsamples ~5 s
SMM objective surface 120 × 120 grid ~2 s with closed-form moments
dsge estimation in Stata 1000 obs, 1 free parameter ~10 s

The expensive-looking things are cheap when the algorithm is chosen well. The two that matter: solve the price-dividend ratio as a linear system, not by iteration, and use closed-form moments in the SMM objective wherever they exist — a simulated objective would have needed thousands of paths per grid point.

Nothing in this deck required parallel computation, which is itself worth reporting. Vectorising across paths — advancing every simulated path one step at a time, rather than looping over paths — turned the variance-reduction study from minutes into a second.

Reach for parallelism when the outer loop is genuinely independent and each inner task is slow: a bootstrap over 20 000 resamples with a nonlinear fit inside, or an SMM grid whose objective needs simulation. In R that is future.apply with 12 cores; in Python joblib. Below roughly a second of serial work, the overhead costs more than it saves.

Common Pitfalls

Seven traps, every one of which was hit while building this deck.

1. A clean render is not a correct render. Twice a Stata chunk executed, its source appeared in the HTML, its output did not, and the error count stayed at zero. Detection is not the error count — it is counting the rendered output boxes against the number of tabs.

2. Silent type coercion at a language boundary. R writes NA for missing values; Stata’s import delimited then types the whole column as text, and drop if missing(x) silently deletes nothing. Fixed at source with write.csv(..., na = "") rather than in every chunk.

3. Rounding where you meant truncation. Stata’s string(x, "%1.0f") rounds, so a quarter key built as (month-1)/3 + 1 put September in Q4. The equity premium came out 6.93% instead of 8.61% with no error anywhere. Wrap in int().

4. The estimator’s bias is not the model’s. The AR(1) coefficient of a near-unit-root process is biased downward by roughly \((1+3\rho)/T\). It showed up three times: ac1(σ²) at 0.978 against 0.987, SMM’s ρ̂ at 0.9696 against 0.979, and the GSW short rate’s κ̂. In Part 3 that bias alone was worth half a percentage point of equity premium.

5. Matching a correlation is not evidence of a mechanism. The financial-accelerator overlay reproduced the countercyclical credit spread and then produced a local-projection response with the wrong sign, because net worth never entered the resource constraint.

6. Approximations that are kinder than the exact calculation. The lognormal Hansen–Jagannathan rule of thumb demanded γ ≥ 24.6; the exact bound computed on the actual data is cleared at 16.6. Consumption growth has fatter tails than a lognormal — 2020Q2 alone is −36.5% annualised — and the approximation misses it.

7. Solving a question the method cannot answer. A first-order perturbation has a risk premium of exactly zero, at every state, by construction. No amount of careful calibration fixes that; the method is wrong for the question.

Six of the seven were caught only because a number was compared against something known — a truth column, a closed form, or the same quantity in another language. Build the check in before you need it.

Variations & Method Chooser

Ranked by how much the answer moves.

change effect
ψ from 1/γ to 1.5 in long-run risk premium 0.00% → 5.04%. The single largest lever in the deck
ρ from 0.979 to 0.95 premium 5.04% → 1.16%. Within any plausible estimation error
First order → second order in the DSGE risk premium 0 → O(σ²). Zero to non-zero, not small to large
Tauchen → Rouwenhorst at ρ = 0.979 chain volatility −24.5%, and every price with it
γ from 2 to 478 in the Lucas tree premium 0.04% → 8.61%, \(r^f\) 7.98% → −1613%
Adding a second yield-curve factor corr(1y,10y) from 1.000 to something the data can accept
Disaster probability p from 0 to 0.017 premium 0.04% → 4.30% at γ = 4
Antithetic or control variates variance ÷280 to ÷440, no extra paths
Euler → exact SDE transition removes a bias that is \(O(\Delta)\) and a positivity violation
Estimating on 79 vs 500 years CI width 7.2pp → 2.9pp
Weighting matrix in SMM changes ρ̂ in the third decimal — the least important choice here
the question the method the trap
What premium does this preference specification imply? Global solution on a discretised state Tauchen at high persistence
Does the model match business-cycle moments? Log-linearise and simulate It cannot price risk
What is the risk premium in a production economy? Second order or global First order gives exactly zero
How does the yield curve move? Three factors minimum One factor implies perfect correlation
How bad could growth get? Quantile regression on financial conditions Silent when the shock is not financial
Which regime are we in? Hamilton filter Under-calls the rare state
What is the probability of a rare event? Importance sampling Plain MC wastes 96% of draws
What are the parameters? SMM or indirect inference Report the identified set, not the point
Is the model rejected? Sampling distribution of the target A 7pp confidence interval rejects very little

Exercises — Simulation & Estimation

  1. Re-solve the long-run risk model of Part 3 with Tauchen instead of Rouwenhorst at \(N = 9\) and \(N = 25\). How large must \(N\) be before Tauchen reproduces the Rouwenhorst premium of 5.04% to within 0.1 percentage points?
  2. The Lucas tree needs \(\gamma = 478\) to match the data premium. Add a leverage parameter \(\phi\) so that \(\Delta d = \mu_d + \phi \, \Delta c + \sigma_d u\). What \(\phi\) delivers 8.61% at \(\gamma = 5\), and what does it do to the risk-free rate?
  3. The deck’s second-order slide computes \(g_{\sigma\sigma}\) for the baseline calibration. Add pruning (Kim, Kim, Schaumburg & Sims) to the second-order simulation and compare the simulated distribution of consumption with and without it. Which moments move, and why is the unpruned simulation the one that explodes?
  4. Extend the affine term-structure model to two factors by adding a second, more persistent Ornstein–Uhlenbeck process. Estimate corr(1y, 10y) in simulated data and compare with the 0.9365 measured on the GSW curve.
  5. Estimate the Vasicek parameters on the GSW one-year yield by maximum likelihood rather than by inverting the OLS AR(1). Do the two agree, and does the difference matter for the ten-year yield?
  6. The common-random-numbers slide fixed \(S = 10\). Re-run it at \(S = 1\), \(10\) and \(100\) with CRN throughout, and plot \(\mathrm{sd}(\hat\rho)\) across 50 replications against the \(1 + 1/S\) prediction. Does the measured inflation match the theory, and at what \(S\) does simulation noise stop mattering?
  7. Replace the AR(3) auxiliary model in the indirect-inference slide with an AR(1) plus the variance ratio VR(60). Does the estimate improve, and can you say in advance which auxiliary statistics will be sharpest?
  8. Price the bond portfolio of Part 5 under a stochastic simulation of the dynamic Nelson–Siegel factors rather than deterministic scenarios. Report the 5% one-year Value-at-Risk and compare it with the worst deterministic scenario.

Exercises — Testing & Diagnostics

  1. Compute the exact Hansen–Jagannathan bound using annual rather than quarterly data. Does the required risk aversion of 16.6 rise or fall, and why?
  2. The deck reports that Euler discretisation of CIR produces negative rates 0.54% of the time at a two-year step. Find the step size at which this first exceeds 1 in 10 000, and compare it with the Feller condition’s prediction.
  3. Test formally whether the long-run risk laboratory’s consumption growth is distinguishable from i.i.d. Use a variance-ratio test at \(k = 12\) and \(k = 60\) and report the power at \(T = 3000\) and at \(T = 300\).
  4. Run the Part 4 local projection with the credit spread orthogonalised against lagged growth and inflation first. Does the trough of −2.74% survive, and does the model’s wrong-signed response change?
  5. Test the calibration of Growth-at-Risk with a Kupiec unconditional-coverage test on the 4.6% in-sample breach rate and the 9.1% post-2020 rate. Is either rejected at 5%?
  6. Score the Hamilton filter’s regime probabilities with a Brier score rather than the 96.25% hit rate. Does the ranking against mswitch’s free estimates change?
  7. Construct a confidence set for \(\rho\) in Part 7 by inverting the SMM objective — the set where the criterion is not rejected at 5% — and compare it with the ad-hoc “within 5× of the minimum” interval \([0.948, 0.993]\).
  8. Using the bootstrap distribution of Part 7, test whether the long-run risk premium of 5.04% is statistically distinguishable from the rare-disasters premium of 4.30%. What sample length would be needed to separate them?

Further Reading

  • Bernanke, Gertler & Gilchrist (1999), The financial accelerator in a quantitative business cycle framework10.1016/S1574-0048(99)10034-X
  • Adrian, Boyarchenko & Giannone (2019), Vulnerable growth10.1257/aer.20161923
  • Hamilton (1989), A new approach to the economic analysis of nonstationary time series and the business cycle10.2307/1912559
  • Jordà (2005), Estimation and inference of impulse responses by local projections10.1257/0002828053828518
  • Duffie & Singleton (1993), Simulated moments estimation of Markov models of asset prices10.2307/2951768
  • Gouriéroux, Monfort & Renault (1993), Indirect inference10.1002/jae.3950080507
  • Smith (1993), Estimating nonlinear time-series models using simulated vector autoregressions10.1002/jae.3950080506
  • Plagborg-Møller & Wolf (2021), Local projections and VARs estimate the same impulse responses10.3982/ECTA17813

Thank You

Athanassios Stavrakoudis
Applied Informatics and Computational Economics Lab Department of Economics University of Ioannina, Greece

astavrak@uoi.gr · linkedin.com/in/astavrakoudis