diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e7eb0a39..d99c901e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Fixes Issue [#1202](https://github.com/PSLmodels/OG-Core/issues/1202): + `get_r_gov` clipped the interest rate on government debt at zero, so a + sovereign that genuinely pays a negative real rate could not be modelled. + The bound is now a parameter, `r_gov_floor`, with a default of 0.0 that + reproduces the previous behaviour exactly. + ## [0.19.1] - 2026-08-10 12:00:00 ### Added diff --git a/ogcore/default_parameters.json b/ogcore/default_parameters.json index 5ab5a94ca..fd61fa86c 100644 --- a/ogcore/default_parameters.json +++ b/ogcore/default_parameters.json @@ -1145,6 +1145,27 @@ } } }, + "r_gov_floor": { + "title": "Floor on the interest rate on government debt", + "section_1": "Fiscal Policy Parameters", + "description": "Lower bound applied to the interest rate on government debt after the wedge is computed. The default of 0.0 reproduces the long-standing behavior. Lower it for a sovereign that pays a negative real rate on its debt.", + "short_description": "Floor on the government interest rate", + "param_notation": "$\\underline{r}_{gov}$", + "notes": "", + "type": "float", + "number_dims": 0, + "value": [ + { + "value": 0.0 + } + ], + "validators": { + "range": { + "min": -0.3, + "max": 0.3 + } + } + }, "r_gov_DY": { "title": "Linear effect of debt to GDP ratio on government interest rate", "section_1": "Fiscal Policy Parameters", diff --git a/ogcore/fiscal.py b/ogcore/fiscal.py index 675a89961..6a3f0087a 100644 --- a/ogcore/fiscal.py +++ b/ogcore/fiscal.py @@ -394,7 +394,7 @@ def get_r_gov(r, DY_ratio, p, method, t=0): .. math:: r_{gov,t} = \max\{(1-\tau_{d,t}r_{t} - \mu_d + \beta_1 \frac{D_t}{Y_t} - + \beta_2 \left(\frac{D_t}{Y_t}\right)^2, 0.0\} + + \beta_2 \left(\frac{D_t}{Y_t}\right)^2, \underline{r}_{gov}\} Args: r (array_like): interest rate on private capital debt over the @@ -416,7 +416,7 @@ def get_r_gov(r, DY_ratio, p, method, t=0): - p.r_gov_shift[t] + p.r_gov_DY * DY_ratio + p.r_gov_DY2 * DY_ratio**2, - 0.00, + p.r_gov_floor, ) else: r_gov = np.maximum( @@ -424,7 +424,7 @@ def get_r_gov(r, DY_ratio, p, method, t=0): - p.r_gov_shift[: p.T] + p.r_gov_DY * DY_ratio[: p.T] + p.r_gov_DY2 * DY_ratio[: p.T] ** 2, - 0.00, + p.r_gov_floor, ) return r_gov diff --git a/tests/test_fiscal.py b/tests/test_fiscal.py index 7d0175663..79d030088 100644 --- a/tests/test_fiscal.py +++ b/tests/test_fiscal.py @@ -292,6 +292,19 @@ def test_get_TR( r_gov1 = 0.02 r_gov2 = 0.01 r_gov3 = 0.0 +# p5/p6: the wedge of p3 (which the default floor clips to zero), with the +# floor lowered so the negative rate comes through, and with the floor set +# above the wedge so it still binds. +p5 = Specifications() +p5.update_specifications( + {"r_gov_scale": [0.5], "r_gov_shift": [0.03], "r_gov_floor": -0.05} +) +r_gov5 = 0.5 * 0.04 - 0.03 # -0.01, no longer clipped +p6 = Specifications() +p6.update_specifications( + {"r_gov_scale": [0.5], "r_gov_shift": [0.03], "r_gov_floor": -0.005} +) +r_gov6 = -0.005 # floor binds p_tpi = Specifications() p_tpi.update_specifications( { @@ -323,6 +336,13 @@ def test_get_TR( ) +p_tpi_floor = Specifications() +p_tpi_floor.update_specifications( + {"r_gov_scale": [1.5], "r_gov_shift": [0.09], "r_gov_floor": -0.05} +) +r_gov_tpi_floor = 1.5 * r_tpi[: p_tpi_floor.T] - 0.09 + + @pytest.mark.parametrize( "r,p,DY_ratio,method,t,r_gov_expected", [ @@ -332,6 +352,9 @@ def test_get_TR( (r_tpi, p_tpi, DY_tpi, "TPI", None, r_gov_tpi), (r, p3, 0, "scalar", 0, r_gov3), (r, p4, 0.5, "scalar", 0, r_gov4), + (r, p5, 0, "scalar", -1, r_gov5), + (r, p6, 0, "scalar", -1, r_gov6), + (r_tpi, p_tpi_floor, DY_tpi, "TPI", None, r_gov_tpi_floor), ], ids=[ "Scale only", @@ -340,6 +363,9 @@ def test_get_TR( "TPI", "scalar", "DY params", + "negative floor lets r_gov < 0", + "floor still binds", + "negative floor, TPI", ], ) def test_get_r_gov(r, p, DY_ratio, method, t, r_gov_expected):