Skip to content
All library documents

Fitting Nelson–Siegel Curves and Bootstrapping Bond Zero Rates

Article Quant Q&A · Author: powpow

Summary

The document describes a bond-curve fitting problem using government bond cash flows, dirty prices, and the four-parameter Nelson–Siegel model. The questioner generates cash-flow schedules and year fractions, discounts those flows using model-implied zero rates, and minimizes squared price errors with differential evolution. The reported fit yields implausible parameters, prompting a reply that outlines an alternative route from market yields to a spot curve.

That reply proposes fitting a smooth yield curve to observed bond yields, sampling it at coupon-period maturities, then treating the interpolated yields as par coupons and bootstrapping discount rates from par-priced bonds. It explains the motivation as separating coupon effects from maturity-based rates. However, the exchange provides no worked validation, and the proposed procedure is not a detailed diagnosis of the original optimization. Its conventions and assumptions should be checked against the bond market, cash-flow dates, and pricing model in use.

Key ideas

  • The question fits Nelson–Siegel parameters by minimizing differences between observed and modeled dirty bond prices.
  • Cash-flow dates and year fractions are inputs to discounting each bond’s payments.
  • The reply suggests fitting yields first, constructing par coupon bonds at regular maturities, then bootstrapping spot rates.
  • Bootstrapped zero rates can help compare bonds while accounting for differences in coupon timing.
  • The suggested workflow is not validated in the exchange and requires convention-specific scrutiny.

Tags

Full text
# Applying Differential Evolution to the Nelson Siegel Model in Python


# Applying Differential Evolution to the Nelson Siegel Model in Python












I am trying to create a zero curve from a series of government bonds by minimizing the differences in the dirty prices.

The problem is that there is something wrong with my differential evolution algorithm.

My process is as follows:

- I have utilized quantlib to generate the cash flow schedules and year fractions

- I have then moved on to a function that attempts to discount the cash flows from from the zero rate approximated by the nelson siegel function.

- Then use scipy's differential evolution algorithm to so solve for the 4 parameters

The outputs that I get is far from the ones I would expect. Given, the below data : I'd expect:

- b1 to be closer to the long term rate ~= (0.0267)

- B2 to be closer to the negative of the slope ~= (-0.0255)

Would anyone know where I am going wrong?

Please see code below:

```
import QuantLib as ql
import pandas as pd
import numpy as np
import datetime
import matplotlib.pyplot as plt
from scipy.optimize import differential_evolution
import functools

def text_to_pd(text_input,isfut=0):
    parsed_data = [tmp_list.split('\t') for tmp_list in text_input.splitlines() if len(tmp_list)>0]
    columns = parsed_data.pop(0)
    ds = pd.DataFrame(parsed_data,columns=columns)
    return ds

def gen_cf_schedule(bond,name=None,effective_date=pd.to_datetime("today")):
    
    if name == None: name = 'tmp_bond'
  
    cashflows = pd.DataFrame({
    'date': pd.to_datetime(cf.date().to_date()), 
    name: cf.amount()
    } for cf in bond.cashflows()
    )
    cashflows =cashflows[cashflows.date >= effective_date]
    cashflows = cashflows.set_index('date').groupby('date').sum()
    return cashflows
    

def gen_cf_schedule_many_bonds(list_bonds,list_bond_name):

    if len(list_bonds) != len(list_bond_name):
        print('Error : Bond list and name list are not of same size')

    cf_list = [
        gen_cf_schedule(tmp_bond,tmp_name)
        for tmp_bond,tmp_name in zip(list_bonds,list_bond_name)
    ]

    # below starts from first 2 pairs in list and does an outter join of call cash flows
    df_final = functools.reduce(
        lambda left,right: pd.merge(left,right,how = 'outer',left_index=True, right_index=True), cf_list)
    
    return df_final

data = """
MATURITY    CPN CPN_FREQ    DIRTY_PRICE YLD
15/07/2022  5.75    2   102.8251133 0.114989457
21/11/2022  2.25    2   101.9755    0.350344935
21/04/2023  5.5 2   107.426 0.664767214
21/04/2024  2.75    2   104.4845    1.06749642
21/11/2024  0.25    2   96.9755 1.386223437
21/04/2025  3.25    2   106.31  1.539978867
21/11/2025  0.25    2   94.8665 1.675063719
21/04/2026  4.25    2   111.523 1.715012175
21/09/2026  0.5 2   94.583  1.775096919
21/04/2027  4.75    2   115.9925    1.806204173
21/11/2027  2.75    2   105.409 1.871278842
21/05/2028  2.25    2   102.4795    1.915042694
21/11/2028  2.75    2   105.707 1.94498807
21/04/2029  3.25    2   109.5315    1.972445794
21/11/2029  2.75    2   105.9405    2.007513096
21/05/2030  2.5 2   104.108 2.032546486
21/12/2030  1   2   91.543  2.06749518
21/06/2031  1.5 2   95.3565 2.07373467
21/11/2031  1   2   90.6355 2.089994302
21/05/2032  1.25    2   92.42   2.105042334
21/11/2032  1.75    2   96.825  2.122473219
21/04/2033  4.5 2   125.034 2.116228619
21/06/2035  2.75    2   106.0135    2.2600066
21/04/2037  3.75    2   118.854 2.357477112
21/06/2039  3.25    2   111.195 2.484967213
21/05/2041  2.75    2   103.3825    2.567442991
21/03/2047  3   2   107.288 2.664936079
21/06/2051  1.75    2   81.654  2.667453281

"""

bonds_data = text_to_pd(data)
#Convert string to quantlib date
date_format = '%d/%m/%Y'
lambda_str_to_qlDate = lambda x : ql.DateParser.parseFormatted(x, date_format)
bonds_data = bonds_data.assign(MATURITY = lambda df_: df_['MATURITY'].apply(lambda_str_to_qlDate))

#parse to numeric
cols_to_numeric = [i for i in bonds_data.columns  if i!='MATURITY'  ]
bonds_data[cols_to_numeric] = bonds_data[cols_to_numeric].apply(pd.to_numeric)
coupon_frequency

# bond conventions
# Conventions used for Aus sov bonds 
calendar = ql.Australia()
business_convention = ql.Unadjusted
day_count =  ql.ActualActual(ql.ActualActual.ISMA)
settlement_days = 2
coupon_frequency   = ql.Period(ql.Semiannual)
​effective_date = ql.Date(9,2,2022)
settlement_date  = calendar.advance(effective_date,ql.Period(settlement_days,ql.Days))
ql.Settings.instance().evaluationDate=settlement_date

# to generate bonds
bonds = [    # creating the bond
   ql.FixedRateBond(
        settlement_days,
        calendar,
        100.0,
        calendar.advance(effective_date,ql.Period(-6,ql.Months)), # ensure a previous coupon to calc accrued amount
        maturity,
        coupon_frequency,
        [coupon/100],
        day_count,
        business_convention,
        business_convention,
        100,
        ql.Date(), ql.Date(), # default dates,
        ql.DateGeneration.Backward,
        False, # default, end of month 
        calendar,
        ql.Period(7,ql.Days),
        calendar,
        business_convention
    ) 
    
    for maturity,coupon in zip(bonds_data.MATURITY,bonds_data.CPN)
]

#cashflows:
pd_cf=gen_cf_schedule_many_bonds(bonds,bonds_data.index)

#Now to simply generate t (yearfractions)
tmp_format = '%d-%m-%Y'
list_cf_dates = [
    ql.DateParser.parseFormatted(x.strftime('%d-%m-%Y'), '%d-%m-%Y')
    for x in pd_cf.index
]

year_frac = [day_count.yearFraction(settlement_date,cf_date) for cf_date in list_cf_dates]

dirty_prices = bonds_data.DIRTY_PRICE.to_numpy()
```

The task next is to create the objective function, which is defined below:

```
def NS(x):
    x1,x2,x3,lam1 = x

    tmp_ns = lambda x1,x2,x3,lam1,t : x1 + x2*(lam1/t)*((1-np.exp(-t/lam1))) + x3*((lam1/t)*(1-np.exp(-t/lam1)) -np.exp(-t/lam1)) 
    
    #exp(-t*r)
    neg_t_times_r  = [-t*(tmp_ns(x1,x2,x3,lam1,t)) for t in year_frac ]

    # CF.exp(-t*r)
    cf_discounted = pd_cf.apply(lambda x : x*np.exp(neg_t_times_r),axis=0)
    test_prices = cf_discounted.sum(axis=0)
    test_prices = test_prices.to_numpy()
    
    
    squared_dif = [(a-b)**2 for a,b in zip(dirty_prices,test_prices)]
    sum_squared_diff = sum(squared_dif)
    
    return sum_squared_diff
```

At this stage, I have set the bonds and attempted to run the code as per below, but the fit of the curve is particularly bad and the parameters are far from what I expect them to be.

Note that I have made the constraints exceptionally wide, but even when I narrow them, the results seem to be off.

```
bounds_ns=[(0,0.5),(-0.5,0.5),(-1,1),(0,100)]
result = differential_evolution(NS_Proper,bounds=bounds_ns,maxiter=1000_000).x
print('b_1: {} '.format(result[0]))
print('b_2: {}'.format(result[1]))
print('b_3: {}'.format(result[2]))
print('b_4: {}'.format(result[3]))
```

The result is as below :

b_1: 0.0

b_2: 0.01145940645447193

b_3: 0.07370270399890494

b_4: 22.21196983881775

Even when plotted it is obviously wrong:

```
year_frac_bond_maturity = [day_count.yearFraction(settlement_date,tmp_date) for tmp_date in bonds_data.MATURITY]
tmp_ns = lambda x1,x2,x3,lam1,t : x1 + x2*(lam1/t)*((1-np.exp(-t/lam1))) + x3*((lam1/t)*(1-np.exp(-t/lam1)) -np.exp(-t/lam1)) 
zero_ns = [tmp_ns(*[2.53546089e-04, 1.13003488e-02, 7.32022467e-02, 2.23133786e+01],t) for t in year_frac_bond_maturity]
Fig,ax = plt.subplots()
Fig.set_size_inches((15,5))
plt.plot(year_frac_bond_maturity,zero_ns,label='zero_ns')
plt.plot(year_frac_bond_maturity,bonds_data.YLD/100,label='yield',marker='o',linestyle="None")
plt.legend()
plt.show()
```

## Answer by JPI (score -1)

https://quant.stackexchange.com/a/69867

If we want to create a zero-coupon curve we can take the market yield curve and bootstrap zero-coupon curve. There are a few complexities which make this difficult like non-annual coupons and tenors not falling on exact years but I think I figured a way around it.

I had great difficulty finding the answer to this online after a great deal of looking so I hope this will be useful to others.

Note the difficulty here is dealing with non-annual coupon payments and real-world dates that do not match round years.

What we are essentially doing when creating a zero-coupon curve is creating a curve that gives us a discounting rate for various tenors (maturities) that is not 'dirtied' by the differing coupons of each of the bonds that have been issued. Given that bonds with a higher coupon will have a lower duration (you get your money back quicker so there is less risk) we need to make an adjustment. Zero-coupon curves allow us to assess relative value without the distortion of the coupon's impact on duration.

So in simple terms the steps to take are:

- Get the yield to maturity and tenor (in years) for each bond for the issuer. Interpolate to fit a curve to the points (e.g. Nelson Siegel OLS regression) which will give you the parameters to get the corresponding yield for any/all maturity/tenors.

- Build a full curve of say 30 years at semi-annual increments using the Nelson Siegel formula and the parameters that were optimized above by inputting each time value (0.5, 1, 1.5, ..., 29.5, 30) into the NS formula with the parameters. You now have the 'par curve' for semi-annual maturities going out 30 years.

- The yields at a tenor of 0.5 years calculated above is a zero-coupon rate and your starting point for bootstrapping the zero-coupon curve.

- We then use bootstrapping to construct the zero/spot curve. We use the interpolated yield for each tenor as the ANNUAL COUPON which defines the cash flows before maturity. The price to which we discount each set of cashflows is $100 (or par value). There are plenty of explanations of bootstrapping online but can post here if necessary.

Why is the coupon and price determined like this you ask? because we can say that if the issuer was to issue a new bond today for that tenor they would be issuing at the interpolated yield to maturity and an issue price of $100 (par) (therefore the coupon is equal to the yield).

I hope this makes sense - please ask any questions or let me know where I have gone wrong.

Shown in full with attribution under the source's licence. Licence: CC BY-SA 4.0 (Stack Exchange)

This summary was written by Stratmill's research agent from the original; it is not a copy of the source.