.. _cook-bond-curve: .. ipython:: python :suppress: from rateslib import * import matplotlib.pyplot as plt from datetime import datetime as dt import numpy as np from pandas import DataFrame, option_context Dense Liquid Bond Curves (EUR, GBP, USD) *********************************************** This example will discuss curve building standards for liquid bond markets with many actively traded bonds as data points. **Key Points** - Standard configuration for *Curve* setup. - Consideration for using academic, parameter based *Curves*. .. tabs:: .. tab:: General Setup **Data** We have acquired a list of 69 UK gilt prices from a retail broker with their maturities. The accuracy of these prices may be dubious and we assume that all of them are currently settling within a regular coupon period (not a short or long stub). The data is displayed below: .. ipython:: python :suppress: from pandas import DataFrame coupons = [ 0.125, 1.5, 0.375, 4.125, 3.75, 1.25, 4.25, 0.125, 4.375, 4.5, 1.625, 6, 0.5, 4, 4.125, 0.875, 4.375, 0.375, 4.75, 4.125, 0.25, 4, 1, 4.25, 3.25, 4.125, 0.875, 4.625, 4.25, 4.5, 4.5, 0.625, 4.75, 4.25, 1.75, 3.75, 4.75, 1.125, 4.25, 4.375, 4.25, 5.25, 1.25, 4.5, 4.75, 3.25, 3.5, 0.875, 4.25, 1.5, 1.75, 4.25, 0.625, 1.25, 3.75, 1.5, 3.75, 4.375, 1.625, 4.25, 5.375, 1.75, 4, 0.5, 4, 2.5, 3.5, 1.625, 1.125 ] maturities = [ dt(2026, 1, 30), dt(2026, 7, 22), dt(2026, 10, 22), dt(2027, 1, 29), dt(2027, 3, 7), dt(2027, 7, 22), dt(2027, 12, 7), dt(2028, 1, 31), dt(2028, 3, 7), dt(2028, 6, 7), dt(2028, 10, 22), dt(2028, 12, 7), dt(2029, 1, 31), dt(2029, 5, 22), dt(2029, 7, 22), dt(2029, 10, 22), dt(2030, 3, 7), dt(2030, 10, 22), dt(2030, 12, 7), dt(2031, 3, 7), dt(2031, 7, 31), dt(2031, 10, 22), dt(2032, 1, 31), dt(2032, 6, 7), dt(2033, 1, 31), dt(2033, 3, 7), dt(2033, 7, 31), dt(2034, 1, 31), dt(2034, 7, 31), dt(2034, 9, 7), dt(2035, 3, 7), dt(2035, 7, 31), dt(2035, 10, 22), dt(2036, 3, 7), dt(2037, 9, 7), dt(2038, 1, 29), dt(2038, 12, 7), dt(2039, 1, 31), dt(2039, 9, 7), dt(2040, 1, 31), dt(2040, 12, 7), dt(2041, 1, 31), dt(2041, 10, 22), dt(2042, 12, 7), dt(2043, 10, 22), dt(2044, 1, 22), dt(2045, 1, 22), dt(2046, 1, 31), dt(2046, 12, 7), dt(2047, 7, 22), dt(2049, 1, 22), dt(2049, 12, 7), dt(2050, 10, 22), dt(2051, 7, 31), dt(2052, 7, 22), dt(2053, 7, 31), dt(2053, 10, 22), dt(2054, 7, 31), dt(2054, 10, 22), dt(2055, 12, 7), dt(2056, 1, 31), dt(2057, 7, 22), dt(2060, 1, 22), dt(2061, 10, 22), dt(2063, 10, 22), dt(2065, 7, 22), dt(2068, 7, 22), dt(2071, 10, 22), dt(2073, 10, 22), ] prices = [ 99.9, 98.93, 97.77, 100.43, 100.08, 96.58, 101.16, 93.53, 101.33, 101.76, 94.85, 106.27, 90.98, 100.54, 101.02, 90.14, 101.87, 85.25, 104.03, 100.6, 82.1, 99.84, 83.98, 101.28, 94.39, 99.57, 78.74, 102.27, 99.36, 101.26, 100.72, 70.89, 102.19, 98.02, 74.65, 91.57, 100.58, 65.53, 95.03, 95.79, 93.88, 104.21, 60.58, 95.08, 97.06, 79.17, 81.36, 47.67, 89.5, 53.435, 54.96, 88.36, 37.59, 44.98, 80.21, 46.635, 79.45, 88.62, 47.615, 86.77, 103.91, 47.67, 82.39, 26.43, 81.51, 56.49, 73, 41.1, 32.46, ] df = DataFrame({ "Maturity": maturities, "Coupon": coupons, "Clean Price": prices, }) .. ipython:: python df To this *DataFrame* we can add our *Instrument* object and some easy to calculate metrics: .. ipython:: python df["Bond"] = [ FixedRateBond( effective=dt(2025, 1, 1), termination=t, spec="uk_gb", fixed_rate=c, curves="uk_gb_curve", metric="clean_price" ) for (t, c) in zip(df["Maturity"], df["Coupon"]) ] df["Accrued"] = [b.accrued(settlement=dt(2026, 1, 19)) for b in df["Bond"]] df["YTM"] = [b.ytm(price=p, settlement=dt(2026, 1, 19)) for (b, p) in zip(df["Bond"], df["Clean Price"])] df["Risk"] = [b.duration(ytm=y, settlement=dt(2026, 1, 19)) for (b, y) in zip(df["Bond"], df["YTM"])] .. ipython:: python df **Curves** We will build a :class:`~rateslib.curves.Curve` and, as comparison, an academic :class:`~rateslib.curves.academic.NelsonSiegelCurve`. The choice of the **node dates** on the *Curve* are subjectively chosen suit the local market and available data. .. ipython:: python curve = Curve( id="uk_gb_curve", calendar="ldn", convention="act365f", interpolation="spline", nodes={ dt(2026, 1, 16): 1.0, **{add_tenor(dt(2026, 1, 16), _, "None"): 1.0 for _ in ["4m", "1y", "2y", "3y", "4y", "5y", "7y", "10y", "15y", "20y", "25y", "30y", "40y", "50y"]} } ) ns = NelsonSiegelCurve( id="uk_gb_curve", calendar="ldn", convention="act365f", dates=(dt(2026, 1, 16), dt(2076, 1, 16)), parameters=[0.01, 0.01, 0.01, 0.5], ) **Calibration and Solver** We will setup a *solver factory* in order to create an individual :class:~rateslib.solver.Solver` for each *Curve* passed as an argument - in order to eliminate repeated code. The convergence tolerances here are fairly loose to promote early stopping and avoid failed iterations. .. ipython:: python def solver_factory(c): return Solver( curves=[c], instruments=df["Bond"], s=df["Clean Price"], conv_tol=1e-5, ini_lambda = (10000, 0.5, 3) ) .. ipython:: python c_solver = solver_factory(curve) ns_solver = solver_factory(ns) The plotted *zero rate* curve alongside scattered YTMs is visible below. Additionally, we show the corresponding YTMs for each bond calculated with the *Curve* in matching color. .. code-block:: python fig, ax, lines = curve.plot("Z", comparators=[ns], labels=["Curve", "NS"]) ax.scatter(df["Maturity"], df["YTM"], color="hotpink") ax.scatter(df["Maturity"], [b.rate(solver=c_solver, metric="ytm") for b in df["Bond"]], color="royalblue", s=5) plt.show() .. plot:: from pandas import DataFrame from rateslib import * import matplotlib.pyplot as plt coupons = [ 0.125, 1.5, 0.375, 4.125, 3.75, 1.25, 4.25, 0.125, 4.375, 4.5, 1.625, 6, 0.5, 4, 4.125, 0.875, 4.375, 0.375, 4.75, 4.125, 0.25, 4, 1, 4.25, 3.25, 4.125, 0.875, 4.625, 4.25, 4.5, 4.5, 0.625, 4.75, 4.25, 1.75, 3.75, 4.75, 1.125, 4.25, 4.375, 4.25, 5.25, 1.25, 4.5, 4.75, 3.25, 3.5, 0.875, 4.25, 1.5, 1.75, 4.25, 0.625, 1.25, 3.75, 1.5, 3.75, 4.375, 1.625, 4.25, 5.375, 1.75, 4, 0.5, 4, 2.5, 3.5, 1.625, 1.125 ] maturities = [ dt(2026, 1, 30), dt(2026, 7, 22), dt(2026, 10, 22), dt(2027, 1, 29), dt(2027, 3, 7), dt(2027, 7, 22), dt(2027, 12, 7), dt(2028, 1, 31), dt(2028, 3, 7), dt(2028, 6, 7), dt(2028, 10, 22), dt(2028, 12, 7), dt(2029, 1, 31), dt(2029, 5, 22), dt(2029, 7, 22), dt(2029, 10, 22), dt(2030, 3, 7), dt(2030, 10, 22), dt(2030, 12, 7), dt(2031, 3, 7), dt(2031, 7, 31), dt(2031, 10, 22), dt(2032, 1, 31), dt(2032, 6, 7), dt(2033, 1, 31), dt(2033, 3, 7), dt(2033, 7, 31), dt(2034, 1, 31), dt(2034, 7, 31), dt(2034, 9, 7), dt(2035, 3, 7), dt(2035, 7, 31), dt(2035, 10, 22), dt(2036, 3, 7), dt(2037, 9, 7), dt(2038, 1, 29), dt(2038, 12, 7), dt(2039, 1, 31), dt(2039, 9, 7), dt(2040, 1, 31), dt(2040, 12, 7), dt(2041, 1, 31), dt(2041, 10, 22), dt(2042, 12, 7), dt(2043, 10, 22), dt(2044, 1, 22), dt(2045, 1, 22), dt(2046, 1, 31), dt(2046, 12, 7), dt(2047, 7, 22), dt(2049, 1, 22), dt(2049, 12, 7), dt(2050, 10, 22), dt(2051, 7, 31), dt(2052, 7, 22), dt(2053, 7, 31), dt(2053, 10, 22), dt(2054, 7, 31), dt(2054, 10, 22), dt(2055, 12, 7), dt(2056, 1, 31), dt(2057, 7, 22), dt(2060, 1, 22), dt(2061, 10, 22), dt(2063, 10, 22), dt(2065, 7, 22), dt(2068, 7, 22), dt(2071, 10, 22), dt(2073, 10, 22), ] prices = [ 99.9, 98.93, 97.77, 100.43, 100.08, 96.58, 101.16, 93.53, 101.33, 101.76, 94.85, 106.27, 90.98, 100.54, 101.02, 90.14, 101.87, 85.25, 104.03, 100.6, 82.1, 99.84, 83.98, 101.28, 94.39, 99.57, 78.74, 102.27, 99.36, 101.26, 100.72, 70.89, 102.19, 98.02, 74.65, 91.57, 100.58, 65.53, 95.03, 95.79, 93.88, 104.21, 60.58, 95.08, 97.06, 79.17, 81.36, 47.67, 89.5, 53.435, 54.96, 88.36, 37.59, 44.98, 80.21, 46.635, 79.45, 88.62, 47.615, 86.77, 103.91, 47.67, 82.39, 26.43, 81.51, 56.49, 73, 41.1, 32.46, ] df = DataFrame({ "Maturity": maturities, "Coupon": coupons, "Clean Price": prices, }) df["Bond"] = [ FixedRateBond( effective=dt(2025, 1, 1), termination=t, spec="uk_gb", fixed_rate=c, curves="uk_gb_curve", metric="clean_price" ) for (t, c) in zip(df["Maturity"], df["Coupon"]) ] df["Accrued"] = [b.accrued(settlement=dt(2026, 1, 19)) for b in df["Bond"]] df["YTM"] = [b.ytm(price=p, settlement=dt(2026, 1, 19)) for (b, p) in zip(df["Bond"], df["Clean Price"])] df["Risk"] = [b.duration(ytm=y, settlement=dt(2026, 1, 19)) for (b, y) in zip(df["Bond"], df["YTM"])] curve = Curve( id="uk_gb_curve", calendar="ldn", convention="act365f", interpolation="spline", nodes={ dt(2026, 1, 16): 1.0, **{add_tenor(dt(2026, 1, 16), _, "None"): 1.0 for _ in ["4m", "1y", "2y", "3y", "4y", "5y", "7y", "10y", "15y", "20y", "25y", "30y", "40y", "50y"]} } ) ns = NelsonSiegelCurve( id="uk_gb_curve", calendar="ldn", convention="act365f", dates=(dt(2026, 1, 16), dt(2076, 1, 16)), parameters=[0.01, 0.01, 0.01, 0.5], ) def solver_factory(c): return Solver( curves=[c], instruments=df["Bond"], s=df["Clean Price"], conv_tol=1e-5, ini_lambda = (10000, 0.5, 3), ) c_solver = solver_factory(curve) ns_solver = solver_factory(ns) fig, ax, lines = curve.plot("Z", comparators=[ns], labels=["Curve", "NS"]) ax.scatter(df["Maturity"], df["YTM"], color="hotpink") ax.scatter(df["Maturity"], [b.rate(solver=c_solver, metric="ytm") for b in df["Bond"]], color="royalblue", s=5) plt.show() .. tab:: Solver Weights The previous tab solved *Curves* to minimise the least squares difference of clean prices. To solve instead relative to YTM (which is not an insignificant change) one can either directly incorporate this into the *Instrument* ``metric`` at initialisation, or manually overload it at a :class:`~rateslib.solver.Solver` level. .. code-block:: def solver_factory(c): return Solver( curves=[c], instruments=[(b, {"metric": "ytm"}) for b in df["Bond"]], s=df["YTM"] ) **Alternatively**, one might recognise that solving for YTM squared differences is equivalent to solving price squared difference, where those prices have been scaled according to the *'duration risk'* (:math:`\frac{\partial P}{\partial y}`). Therefore we can also obtain a similar result use the ``weights``. .. code-block:: def solver_factory(c): return Solver( curves=[c], instruments=df["Bond"], s=df["Clean Price"], weights=[1/r**2 for r in df["Risk"]] ) For a little more reading on this topic consider `this link `__. The *solver* ``weights`` might also have a secondary purpose of phasing in or out specific bonds which have unconventional characteristics, such as recently issued or very low free float volume, clauses which do not align with bonds in the rest of the series (CAC vs non-CAC), bonds speacial in the repo market, etc. etc.