Short Rate Models (Part 10: Policy Rules II)

2026-03-17

1 Introduction

The theory notebook showed how a policy rule can be embedded in the term structure. The implementation notebook uses alphaforge to build a monthly panel of Treasury yields, inflation, activity, and the policy-rate proxy, then fits the reduced policy-rule state-space model in the package.

The implementation is deliberately restricted. The state vector is the macro block itself, not a richer latent structural model. That is enough to make the policy-rule channel visible without pretending that we have solved the identification problems in the original literature.

Code
import os
import sys
from pathlib import Path

import numpy as np
import pandas as pd


def locate_workspace() -> Path:
    cwd = Path.cwd().resolve()
    for candidate in [cwd, *cwd.parents]:
        if (candidate / 'alphaforge').exists() and (candidate / 'short-rate-models').exists():
            return candidate
    raise RuntimeError('Could not locate the steveya workspace from the current working directory.')


WORKSPACE = locate_workspace()
sys.path.insert(0, str(WORKSPACE / 'alphaforge'))
sys.path.insert(0, str(WORKSPACE / 'short-rate-models'))

from alphaforge import (
    DataContext,
    DuckDBParquetStore,
    FREDDataSource,
    TradingCalendar,
    build_policy_rule_dataset,
)
from short_rate_models import PolicyRuleTermStructureModel
Code
fred_api_key = os.environ.get('FRED_API_KEY')
if not fred_api_key:
    raise RuntimeError('Set FRED_API_KEY before running this notebook.')

ctx = DataContext(
    sources={'fred': FREDDataSource(api_key=fred_api_key)},
    calendars={'XNYS': TradingCalendar('XNYS', tz='UTC')},
    store=DuckDBParquetStore(root=WORKSPACE / '.alphaforge_store' / 'short_rate_models'),
)

dataset = build_policy_rule_dataset(
    ctx,
    start=pd.Timestamp('1990-01-01', tz='UTC'),
    end=pd.Timestamp('2024-12-31', tz='UTC'),
)

dataset.yields.tail(), dataset.macro.tail()

2 Reduced Fit

The package fit treats the macro block as the observed state. The inflation and activity variables govern the policy target, the policy-rate series anchors the short end, and the Treasury panel determines how those macro states load into yields.

Code
model, fit = PolicyRuleTermStructureModel.fit(
    yields=dataset.yields,
    macro=dataset.macro,
)

fit['filtered_states'].tail()
Code
state_names = ['inflation', 'activity', 'policy_rate']
policy_gap = fit['smoothed_states'].copy()
policy_gap.columns = state_names
policy_gap['policy_target'] = policy_gap.apply(
    lambda row: model.policy_rate_target(state=row.to_numpy(dtype=float)),
    axis=1,
)
policy_gap['policy_gap'] = policy_gap['policy_rate'] - policy_gap['policy_target']
policy_gap.tail()
Code
response = pd.DataFrame(
    {
        'maturity_years': model.yield_maturities,
        'inflation_shock_response': model.term_structure_response(inflation_shock=0.01),
        'activity_shock_response': model.term_structure_response(output_shock=0.01),
        'policy_shock_response': model.term_structure_response(policy_shock=0.01),
    }
)
response

3 Findings

The point of the exercise is not that the policy rule literally explains every yield movement. The point is to separate two objects that are too often merged in informal discussion: the macro information that moves the policy target, and the residual term-structure movements that remain after the macro block has been accounted for.

If the reduced model is doing something sensible, the fitted short end should comove with the policy-rate series, while the longer maturities should respond more gradually through the yield-loading structure.

4 Limitations

This reduced model treats inflation and activity as observed state variables and does not attempt to identify regime shifts, measurement revisions, or the full structural expectations channel. That is exactly why it is a good intermediate rung in the series: it makes the policy-rule channel legible before the macro-finance notebook adds a richer latent structure.