Skip to content

Make the floor on the government interest rate a parameter - #1203

Open
marcelolafleur wants to merge 1 commit into
PSLmodels:masterfrom
marcelolafleur:rgov-floor-parameter
Open

Make the floor on the government interest rate a parameter#1203
marcelolafleur wants to merge 1 commit into
PSLmodels:masterfrom
marcelolafleur:rgov-floor-parameter

Conversation

@marcelolafleur

@marcelolafleur marcelolafleur commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Fixes #1202.

get_r_gov clipped the interest rate on government debt at a hardcoded 0.00, so a sovereign that genuinely pays a negative real rate could not be modelled. Japan's general government net interest implies about −0.6% real; the wedge returns it and the clip discards it.

The bound is now a parameter, r_gov_floor, defaulting to 0.0. Existing calibrations are unaffected — np.maximum(x, 0.0) is exactly what happened before.

wedge returns -0.6023%
  r_gov_floor  0.0 (default)  ->  r_gov  0.0000%
  r_gov_floor -0.05           ->  r_gov -0.6023%

r_gov enters the model linearly everywhere — no division by it, no powers, no logs — with two consumers, debt_service = r_gov * D and r_p = (r_gov*D + r_K*K) / (D + K), whose denominator is strictly positive. A negative value means the government is a net receiver on its debt position.

Tests

Three cases added to the existing test_get_r_gov: a lowered floor lets a negative rate through, the floor still binds when set above the wedge, and the same on the TPI path since both branches changed. The suite's existing r_gov < 0 case already covers the default clipping to zero, so it doubles as the behaviour-preservation check.

The new cases cannot run on masterr_gov_floor is not a valid parameter there, so update_specifications rejects it at collection.

test_fiscal.py and test_parameters.py pass, 45 total.

Note

r_gov_floor is a scalar, while r_gov_scale and r_gov_shift are time-varying. A floor reads as a structural statement rather than a policy path, but say the word if you would rather it matched its neighbours.

cc @jdebacker @rickecon

@marcelolafleur
marcelolafleur marked this pull request as ready for review August 11, 2026 21:48
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 72.74%. Comparing base (2b6a668) to head (a00d591).

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##           master    #1203   +/-   ##
=======================================
  Coverage   72.74%   72.74%           
=======================================
  Files          22       22           
  Lines        5768     5768           
=======================================
  Hits         4196     4196           
  Misses       1572     1572           
Flag Coverage Δ
unittests 72.74% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
ogcore/fiscal.py 97.14% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@arihantlodha-cmd arihantlodha-cmd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one's clean, and it's directly useful for the Japan calibration i've been working on, where general government net interest is genuinely negative, so thanks for it.

Non-blocking notes. The mechanical change is obviously safe: default 0.0 makes np.maximum(x, 0.0) identical to before, the docstring math and the changelog both track the change, and the param_notation is escaped correctly. The tests cover the three cases that matter (the floor lets a negative rate through, the floor still binds above the wedge, and the TPI branch), which is more than I expected for a one-line change.

The one thing I'd sanity-check isn't in this diff: now that r_gov can actually go negative, does anything downstream of get_r_gov quietly assume it's non-negative? Debt service is just r_gov * D so that's fine sign-wise, and the r_p blend should be too, but if any fiscal or household code takes a log/sqrt of a rate or clips on the old assumption that the floor was zero, a negative r_gov would be the first thing to exercise it. If you've already confirmed the budget and r_p handle a negative rate end to end (a Japan run would show it), then this is complete as is.

Nice small fix.

@marcelolafleur

Copy link
Copy Markdown
Contributor Author

Thanks @arihantlodha-cmd. I traced everything downstream of get_r_gov: debt service, the debt path, the r_p blend, and household income are all linear in the rate, and there are no logs, roots, or clips on rates anywhere in the solve. The default floor is 0.0, so nothing changes unless you opt in. The only edge I see is a calibration extreme enough to pull r_p itself negative, which would feed negative capital income into the tax functions, but realistic numbers don't come close. The Japan calibration I sent you uses this change, so you can see it working end to end there.

@arihantlodha-cmd

Copy link
Copy Markdown
Contributor

thanks for tracing it all the way through @marcelolafleur. linear-in-the-rate everywhere with no logs/roots/clips is the reassuring answer, and the r_p-goes-negative case is a fair boundary to flag (negative capital income into the tax functions would be the thing to watch, but as you say realistic numbers don't get close).

and agreed, r_gov_floor is exactly what OG-Japan needs, Japan's general government net interest is genuinely negative, so i'll wire it in on that side once this lands. thanks for doing this one.

@arihantlodha-cmd arihantlodha-cmd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strong support for this. I traced every consumer of r_gov to check the claim that it enters linearly, and it holds:

  • The government budget constraints in fiscal.py use it only as (1 + r_gov) * D (debt accumulation) and r_gov * D (debt service, lines 199 and 252).
  • get_D_ss pins D = debt_ratio_ss * Y, so debt is exogenous to r_gov, and new borrowing keys off the growth rates, not the rate. There is no division by r_gov anywhere in the steady state.
  • The one spot with a division is the household portfolio return in aggregates.get_r_p, r_p = (r_gov * D + r_K * K) / (D + K). r_gov is linear in the numerator and the denominator is total assets, independent of the rate, so a negative r_gov just drags the blended return down, which is the correct behavior. The [-0.3, 0.3] bound on the parameter keeps 1 + r_p comfortably positive even in the limiting case where debt were the whole portfolio, so the Euler equation is never at risk.

So allowing the rate to go negative is safe, and it does exactly the right thing economically: it turns the interest bill into the subsidy that a negative-real-rate sovereign actually receives.

This matters to me directly. I have been building a Japan calibration on top of OG-Core, and the hardcoded zero is a live obstacle: Japan's general government pays a negative real rate on its debt, so clipping r_gov to zero makes the model overstate its debt-service burden and, with the debt ratio pinned, the primary surplus it has to run to support that debt. Making the floor a parameter removes that, and the 0.0 default keeps every existing calibration bit for bit identical.

The tests are well chosen. The "floor still binds" case (p6) is a nice touch, since it checks that the parameter works as a bound in both directions rather than only letting rates through. Reads well to me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sovereign interest rate is clipped at zero, so a negative real rate cannot be modelled

4 participants