Academic Curves#

Rateslib allows the construction of certain Curve classes that replicate those typically designed in academia, being parameter based. The list of such curves implemented to date are as follows:

rateslib.curves.academic.NelsonSiegelCurve(...)

A Nelson-Siegel curve defined by discount factors.

rateslib.curves.academic.NelsonSiegelSvenssonCurve(...)

A Nelson-Siegel-Svensson curve defined by discount factors.

rateslib.curves.academic.SmithWilsonCurve(...)

A Smith-Wilson style Curve defined by discount factors.

These Curves are all _BaseCurve classes so can be used throughout the library to interact with objects such as Instruments, Legs, Periods, etc. They also interit _WithMutability allowing them to interact with a Solver, for calibration.

Example#

Below we take a selection of Swedish government bond yield-to-maturities and calibrate these Curves. First we define the bonds (as priced on 9th Jan 2026). All the bonds are set to start over a year ago so the last coupon date will automatically be at an appropriate roll date.

In [1]: se_gb_args = dict(spec="se_gb", metric="ytm", curves="academic_curve")

In [2]: bonds = [
   ...:     FixedRateBond(dt(2025, 1, 1), dt(2026, 11, 12), fixed_rate=1.0, **se_gb_args),
   ...:     FixedRateBond(dt(2025, 1, 1), dt(2028, 5, 12), fixed_rate=0.75, **se_gb_args),
   ...:     FixedRateBond(dt(2025, 1, 1), dt(2029, 11, 12), fixed_rate=0.75, **se_gb_args),
   ...:     FixedRateBond(dt(2025, 1, 1), dt(2031, 5, 12), fixed_rate=0.125, **se_gb_args),
   ...:     FixedRateBond(dt(2025, 1, 1), dt(2032, 6, 1), fixed_rate=2.25, **se_gb_args),
   ...:     FixedRateBond(dt(2025, 1, 1), dt(2033, 11, 11), fixed_rate=1.75, **se_gb_args),
   ...:     FixedRateBond(dt(2025, 1, 1), dt(2035, 5, 11), fixed_rate=2.25, **se_gb_args),
   ...:     FixedRateBond(dt(2025, 1, 1), dt(2039, 3, 30), fixed_rate=3.5, **se_gb_args),
   ...:     FixedRateBond(dt(2025, 1, 1), dt(2045, 11, 24), fixed_rate=0.5, **se_gb_args),
   ...: ]
   ...: 

In [3]: ytms = [2.018, 2.088, 2.267, 2.42, 2.522, 2.659, 2.798, 2.98, 3.078]

Now we define the Curves. We use the same id each time so that the Solver provides a consistent Curve mapping each time with the above Instruments.

In [4]: ns = NelsonSiegelCurve(
   ...:     dates=(dt(2026, 1, 9), dt(2046, 1, 10)),
   ...:     parameters=(0.01, 0.01, 0.05, 1.0),
   ...:     id="academic_curve",
   ...: )
   ...: 

In [5]: nss = NelsonSiegelSvenssonCurve(
   ...:     dates=(dt(2026, 1, 9), dt(2046, 1, 10)),
   ...:     parameters=(0.01, 0.01, 0.05, 1.0, 0.05, 1.0),
   ...:     id="academic_curve",
   ...: )
   ...: 

In [6]: sw = SmithWilsonCurve(
   ...:     nodes={
   ...:         dt(2026, 1, 9): 0.125,   #  <-- this is the alpha value
   ...:         **{b.leg1.schedule.termination: 0.1 for b in bonds},
   ...:     },
   ...:     ufr=4.2,
   ...:     id="academic_curve",
   ...: )
   ...: 

Next we will design a factory function to calibrate these curves and return each relevant Solver.

In [7]: def solver_factory(curve) -> Solver:
   ...:     return Solver(
   ...:         curves=[curve],
   ...:         instruments=bonds,
   ...:         s=ytms,
   ...:         func_tol=1e-5,
   ...:         conv_tol=1e-6,
   ...:         algorithm="levenberg_marquardt",
   ...:         ini_lambda=(2000, 0.5, 3),
   ...:     )
   ...: 

Notice that we added some convergence parameters that are, otherwise, rarely seen in the documentation. This is becuase these academic curves can be very sensitive to the parameters. These func_tol and conv_tol are far less restrictive than usual and so the iteration is much more likely to stop in and around the neighbourhood of some solution. For failed iterations it is a fair bet that loosening the tolerances in order to stop earlier might solve those problems.

Additionally the ini_lambda parameters have also been changed. Normally the default is (1000, 0.25, 2) which says that the damping parameter starts out at 1000, and after a successful iteration is reduced to 0.25x its value or is doubled after a failed iteration. This provides a reasonable transition from the very slow, but stable, “gradient_descent” algorithm to the much faster, but potentially unstable, “gauss_newton” algorithm. The updated values are more conservative and less likely to lead to instability, they read that the damping parameter starts at 2000 and after a successful iteration is halved, but after a failed iteration will treble.

We can now calibrate the Curves and plot their zero rates.

In [8]: ns_solver = solver_factory(ns)
SUCCESS: `conv_tol` reached after 19 iterations (levenberg_marquardt), `f_val`: 0.007929630704008078, `time`: 0.0708s

In [9]: nss_solver = solver_factory(nss)
SUCCESS: `conv_tol` reached after 26 iterations (levenberg_marquardt), `f_val`: 0.006765836271071866, `time`: 0.1107s

In [10]: sw_solver = solver_factory(sw)
SUCCESS: `func_tol` reached after 30 iterations (levenberg_marquardt), `f_val`: 4.329491986569285e-06, `time`: 0.3193s

In [11]: fig, ax, lines = ns.plot("Z", comparators=[nss, sw], labels=["NS", "NSS", "SW"])

In [12]: ax.scatter(x=[_.leg1.schedule.termination for _ in bonds], y=ytms, c='hotpink')
Out[12]: <matplotlib.collections.PathCollection at 0x13c5e2cf0>

(Source code, png, hires.png, pdf)

_images/c_academic_curves-1_00_00.png