Skip to content
All library documents

Swap Curve Bootstrapping: Compounding Conventions and Tenor Errors

Article Quant Q&A · Author: Jose Pedro Melo

Summary

The document describes troubleshooting a Chilean swap curve built in QuantLib from deposit and swap quotes. The accepted explanation attributes much of the difference from Bloomberg zero rates to the output-rate convention: the code requests simple compounding, while Bloomberg’s quoted zeros use semiannual compounding. Reading zero rates with compounded semiannual conventions produces closer comparisons. This illustrates that a zero rate is not fully specified by its numerical level alone; the compounding basis matters.

A separate response identifies a tenor-unit error in the supplied setup: an 18-month quote is inadvertently treated as an 18-year swap because the code’s branch assigns the wrong tenor unit. The answers do not establish every market convention needed to reproduce Bloomberg’s curve, and the original question also raises index and calendar configuration. The material therefore offers targeted debugging insights, while leaving a complete Chilean curve specification and full validation unresolved.

Key ideas

  • Zero rates depend on their compounding convention, so comparisons require matching the basis used by the reference curve.
  • Semiannual compounded rates are more comparable to the Bloomberg values described than simple-compounded rates.
  • A branch condition can mistakenly treat an 18-month swap quote as an 18-year tenor.
  • Matching conventions addresses only part of curve replication; index, calendar, and other market details may also matter.

Tags

Full text
# Swap Bootstrapping with quantlib


# Swap Bootstrapping with quantlib












I've been trying to get the zero rates of the Chilean Swap curve with Quantlib in Python, but I haven't been able to set up the parameters correctly. This is my code:

```
import QuantLib as ql
import pandas as pd

#Custom Calendar with Chilean Holidays
def create_calendar_chile(start_year,n_years):
    Chile = ql.WeekendsOnly()
    days = [1,14,15,1,21,26,2,16,15,18,19,9,27,1,19,8,17,25,31]
    months = [1,4,4,5,5,6,8,9,9,10,10,11,12,12,12,12]
    name = ['Año Nuevo','Viernes Santo','Sabado Santo','Dia del Trabajo','Dia de las Glorias Navales','San Pedro y San Pablo','Elecciones Primarias','Dia de la Virgen del Carmen','Asuncion de la Virgen','Independencia Nacional','Glorias del Ejercito','Encuentro de dos mundos','Día de las Iglesias Evangélicas y Protestantes','Día de todos los Santos','Elecciones Presidenciales y Parlamentarias','Inmaculada Concepción','Segunda vuelta Presidenciales','Navidad','Feriado Bancario']
    for i in range(n_years+1):
        for x,y in zip(days,months):
            date = ql.Date(x,y,start_year+i)
            Chile.addHoliday(date)

    return Chile

today = ql.Date(25, 10, 2017)
ql.Settings.instance().evaluationDate = today

swap_clp = [2.46, 2.40, 2.40, 2.41, 2.54, 2.68, 3.01, 3.3, 3.53, 3.69, 3.87, 4.02, 4.13, 4.23, 4.38, 4.38, 4.56]
terms = [3,6,9,12,18,2,3,4,5,6,7,8,9,10,12,15,20]
## SWAP Parameters ##
calendar = create_calendar_chile(2001,50)
bussiness_convention = ql.Following
day_count = ql.Actual360()

#Overnigth Rate
TPM = 2.5
depo_helper = [ql.DepositRateHelper(ql.QuoteHandle(ql.SimpleQuote(TPM/100)),ql.Period(1,ql.Days),1,calendar,ql.Unadjusted,False,ql.Actual360())]

#Swap Rates
swap_helpers = []
for i in range(len(terms)):
    if i < 4:
        coupon_frequency = ql.Once
        tenor = ql.Period(terms[i],ql.Months)
        rate = swap_clp[i]
        swap_helpers.append(ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(rate/100.0)),tenor, calendar,coupon_frequency, bussiness_convention,day_count,ql.Euribor3M()))
    else:
        coupon_frequency = ql.Semiannual
        tenor = ql.Period(terms[i],ql.Years)
        rate = swap_clp[i]
        swap_helpers.append(ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(rate/100.0)),tenor, calendar,coupon_frequency, bussiness_convention,day_count,ql.Euribor3M()))

#Yield Curve
rate_helpers = depo_helper + swap_helpers
yieldcurve = ql.PiecewiseLinearZero(today,rate_helpers,day_count)

spots = []
tenors = []
for d in yieldcurve.dates():
    yrs = day_count.yearFraction(today, d)
    compounding = ql.Simple
    freq = ql.Annual
    zero_rate = yieldcurve.zeroRate(yrs, compounding, freq)
    tenors.append(yrs)
    eq_rate = zero_rate.equivalentRate(day_count,compounding,freq,today,d).rate()
    spots.append(100*eq_rate)

datatable = {'Dates':yieldcurve.dates(),'Tenors':tenors,'spots':spots}
df = pd.DataFrame.from_dict(datatable)
```

And I'm getting the following results:

```
>>> df
                 Dates     Tenors     spots
0   October 25th, 2017   0.000000  0.000000
1   October 27th, 2017   0.005556  2.500087
2   January 29th, 2018   0.266667  2.461170
3     April 27th, 2018   0.511111  2.401418
4      July 27th, 2018   0.763889  2.401059
5   October 29th, 2018   1.025000  2.410821
6   October 28th, 2019   2.036111  2.739493
7   October 27th, 2020   3.050000  3.141727
8   October 27th, 2021   4.063889  3.529495
9   October 27th, 2022   5.077778  3.875600
10  October 27th, 2023   6.091667  4.157661
11  October 28th, 2024   7.111111  4.495323
12  October 27th, 2025   8.122222  4.817217
13  October 27th, 2026   9.136111  5.100542
14  October 27th, 2027  10.150000  5.390948
15  October 29th, 2029  12.186111  5.945168
16  October 27th, 2032  15.225000  6.352031
17  October 29th, 2035  18.272222  2.707640
18  October 27th, 2037  20.297222  8.655828
```

The zero rates are wrong according to Bloomberg boostrap (leaving aside smalls differences in the rates and dates). I'm using the Euribor3M index in the Swap Helpers but I assume thats wrong. How could I set up a custom index in python? Also, the zero levels seem to be kind of high compared to the real zeros:

```
Bloomberg:
Date    Days    Term    InstType    Mid Zero
26-10-2017  1   O/N CASH    2,500   2,500
30-01-2018  97  3 MO    CASH    2,460   2,460
30-04-2018  187 6 MO    CASH    2,400   2,400
30-07-2018  278 9 MO    CASH    2,400   2,400
30-10-2018  370 1 YR    CASH    2,410   2,410
30-04-2019  552 18 MO   CASH    2,540   2,540
30-10-2019  735 2 YR    SWAP    2,680   2,684
30-10-2020  1101    3 YR    SWAP    3,010   3,024
29-10-2021  1465    4 YR    SWAP    3,300   3,327
28-10-2022  1829    5 YR    SWAP    3,540   3,582
30-10-2023  2196    6 YR    SWAP    3,700   3,752
30-10-2024  2562    7 YR    SWAP    3,870   3,939
30-10-2025  2927    8 YR    SWAP    4,030   4,119
30-10-2026  3292    9 YR    SWAP    4,140   4,243
29-10-2027  3656    10 YR   SWAP    4,230   4,346
30-10-2032  5484    15 YR   SWAP    4,380   4,502
30-10-2037  7310    20 YR   SWAP    4,560   4,742
```

## Answer by Luigi Ballabio (score 3, accepted)

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

You probably figured this out already, since you mention in the comments that Bloomberg rates are compounded semiannually. However, the higher rates have to do with the compounding convention you're choosing. You're asking for rates with simple compounding, and that overrides the annual frequency you're passing and causes the rates to be higher than you expect. Using `Compounded` and `Semiannual` returns values much closer to the ones you quoted.

## Answer by Jon Manese (score 2)

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

For the 2.707 you get at year 18, there's a bug in your code. The 18 month tenor is loaded as 18 years

```
#Swap Rates
swap_helpers = []
for i in range(len(terms)):
    if i < 4:  # should be i < 5!!! 
        ...
```

Don't know the conventions used for the Bloomberg rates to comment on the difference though.

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.