.. _cook-bond_sparse-curve: .. ipython:: python :suppress: from rateslib import * import matplotlib.pyplot as plt from datetime import datetime as dt from datetime import timedelta import numpy as np from pandas import DataFrame, option_context Sparse Bond Curves (NOK, SEK, Corp) *********************************************** This example will discuss curve building standards for bond markets with sparser data points than one might typically require. **Key Points** - Assumes familiarity with the :ref:`Dense Bond Curve Article `. - Matching degrees of freedom. - Inferring shape from exogenous data. - Adding regularizers. .. tabs:: .. tab:: Direct Curve The Norwegian market actually contains a reasonable amount of bond information to construct a *Curve* with a reasonable number of parameters. One might even argue this is more dense, than sparse. Below we construct a *Curve* with 7 degrees of freedom from 15 datapoints. The results show that the *Curve* is a reasonable re-pricer for the prevailing market prices. **Data** This dataset actually includes Norwegian T-Bills but the yields here are bond equivalent YTMs assuming a bond with zero coupon. .. ipython:: python :suppress: bill_date = [ dt(2026, 3, 18), dt(2026, 6, 17), dt(2026, 9, 16), dt(2026, 12, 16), ] bill_price = [ 99.38775, 98.4218, 97.4707, 96.5409, ] bond_date = [ dt(2027, 2, 17), dt(2028, 4, 26), dt(2029, 9, 6), dt(2030, 8, 19), dt(2031, 9, 17), dt(2032, 5, 18), dt(2033, 8, 15), dt(2034, 4, 13), dt(2035, 6, 12), dt(2039, 5, 31), dt(2042, 10, 6), ] bond_price = [ 97.68, 95.92, 92.73, 89.365, 86.315, 89.545, 93.06, 96.59, 96.655, 94.025, 91.915, ] coupons = [ 1.75, 2.0, 1.75, 1.375, 1.25, 2.125, 3.0, 3.625, 3.75, 3.625, 3.5, ] df = DataFrame({ "Maturity": bill_date + bond_date, "Price": bill_price + bond_price, "Coupon": [0.0] * 4 + coupons }) .. ipython:: python df As usual we can add metrics to this table, assuming that for every *Instrument* the current settlement period is regular from the last roll date and is not a stub. .. ipython:: python df["Instrument"] = [ Bill(dt(2025, 1, 1), t, spec="no_gbb", curves="no_gb_curve", metric="ytm") for t in df["Maturity"][:4] ] + [ FixedRateBond(dt(2025, 1, 1), t, fixed_rate=c, spec="no_gb", curves="no_gb_curve", metric="ytm") for (t, c) in zip(df["Maturity"][4:], df["Coupon"][4:]) ] df["Accrued"] = [b.accrued(settlement=dt(2026, 1, 20)) for b in df["Instrument"]] df["YTM"] = [b.ytm(price=p, settlement=dt(2026, 1, 20)) for (b,p) in zip(df["Instrument"], df["Price"])] df["Risk"] = [b.duration(ytm=y, settlement=dt(2026, 1, 20)) for (b, y) in zip(df["Instrument"], df["YTM"])] df **Curve** .. ipython:: python today = dt(2026, 1, 16) curve = Curve( id="no_gb_curve", convention="act365f", calendar="osl", interpolation="spline", nodes={ today: 1.0, **{add_tenor(today, _, "F"): 1.0 for _ in ["1Y", "2Y", "3Y", "5Y","7y", "10Y", "20Y"]}, } ) **Calibration and Solver** .. ipython:: python solver = Solver(curves=[curve], instruments=df["Instrument"], s=df["YTM"]) **Plots and Analysis** The original YTM (pink) from market prices is plotted as well as the YTM returned from the solved *Curve*. .. code-block:: fig, ax, lines = curve.plot("Z") ax.scatter(df["Maturity"], df["YTM"], color="hotpink") ax.scatter(df["Maturity"], [b.rate(solver=solver) for b in df["Instrument"]], color="royalblue", s=5) .. plot:: from rateslib import * import matplotlib.pyplot as plt from datetime import datetime as dt import numpy as np from pandas import DataFrame, option_context bill_date = [ dt(2026, 3, 18), dt(2026, 6, 17), dt(2026, 9, 16), dt(2026, 12, 16), ] bill_price = [ 99.38775, 98.4218, 97.4707, 96.5409, ] bond_date = [ dt(2027, 2, 17), dt(2028, 4, 26), dt(2029, 9, 6), dt(2030, 8, 19), dt(2031, 9, 17), dt(2032, 5, 18), dt(2033, 8, 15), dt(2034, 4, 13), dt(2035, 6, 12), dt(2039, 5, 31), dt(2042, 10, 6), ] bond_price = [ 97.68, 95.92, 92.73, 89.365, 86.315, 89.545, 93.06, 96.59, 96.655, 94.025, 91.915, ] coupons = [ 1.75, 2.0, 1.75, 1.375, 1.25, 2.125, 3.0, 3.625, 3.75, 3.625, 3.5, ] df = DataFrame({ "Maturity": bill_date + bond_date, "Price": bill_price + bond_price, "Coupon": [0.0] * 4 + coupons }) df["Instrument"] = [ Bill(dt(2025, 1, 1), t, spec="no_gbb", curves="no_gb_curve", metric="ytm") for t in df["Maturity"][:4] ] + [ FixedRateBond(dt(2025, 1, 1), t, fixed_rate=c, spec="no_gb", curves="no_gb_curve", metric="ytm") for (t, c) in zip(df["Maturity"][4:], df["Coupon"][4:]) ] df["Accrued"] = [b.accrued(settlement=dt(2026, 1, 20)) for b in df["Instrument"]] df["YTM"] = [b.ytm(price=p, settlement=dt(2026, 1, 20)) for (b,p) in zip(df["Instrument"], df["Price"])] df["Risk"] = [b.duration(ytm=y, settlement=dt(2026, 1, 20)) for (b, y) in zip(df["Instrument"], df["YTM"])] today = dt(2026, 1, 16) curve = Curve( id="no_gb_curve", convention="act365f", calendar="osl", interpolation="spline", nodes={ today: 1.0, **{add_tenor(today, _, "F"): 1.0 for _ in ["1Y", "2Y", "3Y", "5Y","7y", "10Y", "20Y"]}, } ) solver = Solver(curves=[curve], instruments=df["Instrument"], s=df["YTM"]) fig, ax, lines = curve.plot("Z") ax.scatter(df["Maturity"], df["YTM"], color="hotpink") ax.scatter(df["Maturity"], [b.rate(solver=solver) for b in df["Instrument"]], color="royalblue", s=5) plt.show() plt.close() .. tab:: Exogenous Data When there is not enough information to really define a proper *Curve* one must go looking for some extra information to steer the process. Norwegian bonds are very closely related to *NOWA* rates. And that *Curve* can be calibrated, with reasonable detail, to the *IRS* market. This provides a proxy for the shape of any generic Norwegian rates *Curve*. **Data** The following are NOWA *IRS* rates for various tenors. .. ipython:: python :suppress: tenors = ["1m", "2m", "3m", "6m", "9m", "1y", "2y", "3y", "4y", "5y", "6y", "7y", "8y", "9y", "10y", "12y", "15Y", "20y"] rates = [4.0, 4.01, 4.01, 3.985, 3.96, 3.93, 3.79, 3.74, 3.726, 3.726, 3.73, 3.74, 3.75, 3.76, 3.765, 3.76, 3.72, 3.60] df2 = DataFrame({"Tenor": tenors, "Rate": rates}) .. ipython:: python df2 The bond data is the same as the previous tab. .. ipython:: python df **Curve and Calibration** This follows the principles for :ref:`Standard Liquid RFR Curves ` to construct the NOWA curve. .. ipython:: python today = dt(2026, 1, 16) spot = get_calendar("osl").lag_bus_days(today, 2, False) t10 = today + timedelta(days=10) nowa = Curve( nodes={ today: 1.0, **{add_tenor(t10, _, "F"): 1.0 for _ in df2["Tenor"]}, }, id="nowa", convention="act365f", calendar="osl", interpolation="spline", ) .. ipython:: python nw_solver = Solver( curves=[nowa], instruments=[ IRS(spot, _, spec="nok_irs", curves="nowa") for _ in df2["Tenor"] ], s=df2["Rate"], ) **Bond Spread Curve** We now define a **spread** *Curve*, which is the quantity we add to the swap *Curve* in order to create a bond *Curve*. The notion here is to assume that the spread is determinable from only a few parameters. Here we will use only 4 parameters applicable to the 1y, 2y, 7y and 20y node dates. This setup establishes the Norwegian Bond *Curve* as a :class:`~rateslib.curves.CompositeCurve`, effectively adding two *Curves* together. First we design the *spread* curve as described above. .. ipython:: python no_gb_spread = Curve( id="no_gb_spread", convention="act365f", calendar="osl", interpolation="spline", nodes={ today: 1.0, **{add_tenor(t10, _, "F"): 1.0 for _ in ["1y", "2y", "7y", "20y"]} } ) To produce our Norwegian government bond *Curve* we add the *spread Curve* to the base *NOWA Curve*. .. ipython:: python no_gb_curve = CompositeCurve(id="no_gb_curve", curves=[nowa, no_gb_spread]) **Calibration and Solver** This calibration phase is no more difficult than previously. The NOWA curve is already solved so will not be calibrated again. There are only four parameters that this :class:`~rateslib.solver.Solver` will mutate, and those are associated with the spread *Curve*, and then bond *Curve* is dynamically updated since it depends directly to the spreac *Curve*. .. ipython:: python solver = Solver( pre_solvers=[nw_solver], # <- The NOWA curve is contained here as a mapping: no changes curves=[no_gb_spread, no_gb_curve], # <- This defines the variable curves. instruments=df["Instrument"], s=df["YTM"], ) **Analysis** *Rateslib* doesn't naturally do dual axis charts but with a little pyplot magic the below chart can be produced, again showing the real YTMs (pink) and the calculated YTMs (blue) from the generated bond *Curve*. .. code-block:: fig, ax, rlines = no_gb_spread.plot("Z", labels=["NO_GB_SPREAD_ZERO"]) plt.close() # <- get the `rlines` data but do not plot fig, ax, lines = no_gb_curve.plot("Z", comparators=[nowa]) ax.scatter(df["Maturity"], df["YTM"], color="hotpink") ax.scatter(df["Maturity"], [b.rate(solver=solver) for b in df["Instrument"]], color="royalblue", s=5) ax2 = ax.twinx() # <- create a RHS axis and then add the plot from above data line, = ax2.plot(rlines1[0]._x, rlines1[0]._y, c="g") ax.legend([lines[0], lines[1], line], ["NO_GB (zero)", "NOWA (zero)", "Spread (Zero):RHS"], loc='upper left') plt.show() .. plot:: from rateslib import * from matplotlib import pyplot as plt from datetime import timedelta from pandas import DataFrame bill_date = [ dt(2026, 3, 18), dt(2026, 6, 17), dt(2026, 9, 16), dt(2026, 12, 16), ] bill_price = [ 99.38775, 98.4218, 97.4707, 96.5409, ] bond_date = [ dt(2027, 2, 17), dt(2028, 4, 26), dt(2029, 9, 6), dt(2030, 8, 19), dt(2031, 9, 17), dt(2032, 5, 18), dt(2033, 8, 15), dt(2034, 4, 13), dt(2035, 6, 12), dt(2039, 5, 31), dt(2042, 10, 6), ] bond_price = [ 97.68, 95.92, 92.73, 89.365, 86.315, 89.545, 93.06, 96.59, 96.655, 94.025, 91.915, ] coupons = [ 1.75, 2.0, 1.75, 1.375, 1.25, 2.125, 3.0, 3.625, 3.75, 3.625, 3.5, ] df = DataFrame({ "Maturity": bill_date + bond_date, "Price": bill_price + bond_price, "Coupon": [0.0] * 4 + coupons }) df["Instrument"] = [ Bill(dt(2025,1, 1), t, spec="no_gbb", metric="ytm", curves="no_gb_curve") for t in bill_date ] + [ FixedRateBond(dt(2025, 1, 1), t, spec="no_gb", fixed_rate=c, metric="ytm", curves="no_gb_curve") for (t, c) in zip(bond_date, coupons) ] df["YTM"] = [ b.ytm(price=p, settlement=dt(2026, 1, 20)) for (b, p) in zip(df["Instrument"], df["Price"]) ] df["Accrued"] = [ b.accrued(settlement=dt(2026, 1, 20)) for b in df["Instrument"] ] today = dt(2026, 1, 16) spot = get_calendar("osl").lag_bus_days(today, 2, False) t10 = today + timedelta(days=10) tenors = [ "1m", "2m", "3m", "6m", "9m", "1y", "2y", "3y", "4y", "5y", "6y", "7y", "8y", "9y", "10y", "12y", "15Y", "20y", ] rates = [ 4.0, 4.01, 4.01, 3.985, 3.96, 3.93, 3.79, 3.74, 3.726, 3.726, 3.73, 3.74, 3.75, 3.76, 3.765, 3.76, 3.72, 3.60 ] nowa = Curve( interpolation="spline", nodes={ today: 1.0, **{add_tenor(t10, _, "F"):1.0 for _ in tenors}, }, id="nowa", convention="act365f", calendar="osl", ) solver = Solver( curves=[nowa], instruments=[ IRS(spot, _, spec="nok_irs", curves="nowa") for _ in tenors ], s=rates, ) no_gb_spread = Curve( id="no_gb_spread", convention="act365f", calendar="osl", interpolation="spline", nodes={ today: 1.0, **{add_tenor(t10, _, "F"): 1.0 for _ in ["1y", "2y", "7y", "20y"]}, } ) no_gb_curve = CompositeCurve(id="no_gb_curve", curves=[nowa, no_gb_spread]) solver = Solver( pre_solvers=[solver], curves=[no_gb_spread, no_gb_curve], instruments=df["Instrument"], s=df["YTM"], ) fig, ax, rlines = no_gb_spread.plot("Z", labels=["NO_GB_SPREAD_ZERO"]) plt.close() fig, ax, lines = no_gb_curve.plot("Z", comparators=[nowa]) ax.scatter(df["Maturity"], df["YTM"], color="hotpink") ax.scatter(df["Maturity"], [b.rate(solver=solver) for b in df["Instrument"]], color="royalblue", s=5) ax2 = ax.twinx() line, = ax2.plot(rlines[0]._x, rlines[0]._y, c="g") ax.legend([lines[0], lines[1], line], ["NO_GB (zero)", "NOWA (zero)", "Spread (Zero):RHS"], loc='upper left') plt.show() plt.close()