Comparing Curve Building and Instrument Pricing with QuantLib#

This document offers a brief comparison between rateslib and QuantLib for constructing a Curve. In this example we build a Curve whose index is STIBOR-3M. The evaluation includes Curve creation and pricing an IRS using both libraries. Let’s start with QuantLib:

import QuantLib as ql

# Set the evaluation date
ql.Settings.instance().evaluationDate = ql.Date(3,1,2023)

# Define market data for the curve
data = {
    '1Y': 3.45,
    '2Y': 3.4,
    '3Y': 3.37,
    '4Y': 3.33,
    '5Y': 3.2775,
    '6Y': 3.235,
    '7Y': 3.205,
    '8Y': 3.1775,
    '9Y': 3.1525,
    '10Y': 3.1325,
    '12Y': 3.095,
    '15Y': 3.0275,
    '20Y': 2.92,
    '25Y': 2.815,
    '30Y': 2.6925
}

The next step is to define the IRSs for bootstrapping using ql.SwapRateHelper:

# Create a custom ibor index for the floating leg
ibor_index = ql.IborIndex(
    'STIBOR3M',           # Name of the index
    ql.Period('3M'),      # Maturity
    0,                    # Fixing days
    ql.SEKCurrency(),     # Currency
    ql.Sweden(),          # Calendar
    ql.ModifiedFollowing, # Convention
    False,                # EOM convention
    ql.Actual360()        # Daycount
)
# Create the bootstrapping instruments using helpers
swap_helpers = [ql.SwapRateHelper(
   ql.QuoteHandle(ql.SimpleQuote(rate/100.0)),   # Quote
   ql.Period(term),                              # Maturity
   ql.Sweden(),                                  # Calendar
   ql.Annual,                                    # Fixed payments
   ql.ModifiedFollowing,                         # Convention
   ql.Actual360(),                               # Daycount
ibor_index) for term, rate in data.items()]

Finally, the curve is built using ql.PiecewiseLogLinearDiscount:

curve = ql.PiecewiseLogLinearDiscount(0, ql.Sweden(), swap_helpers, ql.Actual360())

The rateslib code below will replicate the Curve creation, but note the difference in handling the nodes (pillar dates) of the Curve:

In [1]: import rateslib as rl

# Define market data for the curve
In [2]: data = {
   ...:     '1Y': 3.45,
   ...:     '2Y': 3.4,
   ...:     '3Y': 3.37,
   ...:     '4Y': 3.33,
   ...:     '5Y': 3.2775,
   ...:     '6Y': 3.235,
   ...:     '7Y': 3.205,
   ...:     '8Y': 3.1775,
   ...:     '9Y': 3.1525,
   ...:     '10Y': 3.1325,
   ...:     '12Y': 3.095,
   ...:     '15Y': 3.0275,
   ...:     '20Y': 2.92,
   ...:     '25Y': 2.815,
   ...:     '30Y': 2.6925
   ...: }
   ...: 

In [3]: curve = rl.Curve(
   ...:    id="curve",                    # Curve ID
   ...:    convention = 'act360',         # Daycount
   ...:    calendar = 'stk',              # Swedish Calendar
   ...:    modifier = 'MF',               # Modified Following
   ...:    interpolation = 'log_linear',  # Interpolation Method
   ...:    nodes={
   ...:       **{rl.dt(2023, 1, 3): 1.0}, # Initial node always starts at 1.0
   ...:       **{rl.add_tenor(rl.dt(2023, 1, 3), tenor, "MF", "stk"): 1.0 for tenor in data.keys()}
   ...:       },
   ...: )
   ...: 

Warning

Note that rateslib will determine the discount factors (DFs) based at the provided input node dates. QuantLib, which uses bootstrapping, sets these dates based on the maturity dates of the Instruments by default to ensure a sound bootstrapping routine. Thus to replicate the result from QuantLib, the function add_tenor() is used to find the adjusted maturity dates for each Instrument and use those values as input to our Curve.

The next step is to create the Instruments and call the Solver:

# Create the instrument attributes for the solver corresponding to our helpers in QuantLib
In [4]: instr_args= dict(
   ...:    effective=rl.dt(2023, 1, 3),
   ...:    frequency="A",
   ...:    calendar="stk",
   ...:    convention="act360",
   ...:    currency="sek",
   ...:    curves="curve",
   ...:    payment_lag=0,
   ...: )
   ...: 

# Solve for the discount factors
In [5]: solver = rl.Solver(
   ...:    curves=[curve],
   ...:    instruments=[rl.IRS(termination=_, **instr_args) for _ in data.keys()],
   ...:    s=[_ for _ in data.values()]
   ...: )
   ...: 
SUCCESS: `func_tol` reached after 7 iterations (levenberg_marquardt), `f_val`: 8.038068273514836e-16, `time`: 0.3533s

In [6]: curve.nodes
Out[6]: 
{datetime.datetime(2023, 1, 3, 0, 0): <Dual: 1.000000, (curve0, curve1, curve2, ...), [1.0, 0.0, 0.0, ...]>,
 datetime.datetime(2024, 1, 3, 0, 0): <Dual: 0.966203, (curve0, curve1, curve2, ...), [0.0, 1.0, 0.0, ...]>,
 datetime.datetime(2025, 1, 3, 0, 0): <Dual: 0.934394, (curve0, curve1, curve2, ...), [0.0, 0.0, 1.0, ...]>,
 datetime.datetime(2026, 1, 5, 0, 0): <Dual: 0.903918, (curve0, curve1, curve2, ...), [0.0, 0.0, 0.0, ...]>,
 datetime.datetime(2027, 1, 4, 0, 0): <Dual: 0.875578, (curve0, curve1, curve2, ...), [0.0, 0.0, 0.0, ...]>,
 datetime.datetime(2028, 1, 3, 0, 0): <Dual: 0.849392, (curve0, curve1, curve2, ...), [0.0, 0.0, 0.0, ...]>,
 datetime.datetime(2029, 1, 3, 0, 0): <Dual: 0.824236, (curve0, curve1, curve2, ...), [0.0, 0.0, 0.0, ...]>,
 datetime.datetime(2030, 1, 3, 0, 0): <Dual: 0.799874, (curve0, curve1, curve2, ...), [0.0, 0.0, 0.0, ...]>,
 datetime.datetime(2031, 1, 3, 0, 0): <Dual: 0.776573, (curve0, curve1, curve2, ...), [0.0, 0.0, 0.0, ...]>,
 datetime.datetime(2032, 1, 5, 0, 0): <Dual: 0.754096, (curve0, curve1, curve2, ...), [0.0, 0.0, 0.0, ...]>,
 datetime.datetime(2033, 1, 3, 0, 0): <Dual: 0.732457, (curve0, curve1, curve2, ...), [0.0, 0.0, 0.0, ...]>,
 datetime.datetime(2035, 1, 3, 0, 0): <Dual: 0.691622, (curve0, curve1, curve2, ...), [0.0, 0.0, 0.0, ...]>,
 datetime.datetime(2038, 1, 4, 0, 0): <Dual: 0.637877, (curve0, curve1, curve2, ...), [0.0, 0.0, 0.0, ...]>,
 datetime.datetime(2043, 1, 5, 0, 0): <Dual: 0.562979, (curve0, curve1, curve2, ...), [0.0, 0.0, 0.0, ...]>,
 datetime.datetime(2048, 1, 3, 0, 0): <Dual: 0.503558, (curve0, curve1, curve2, ...), [0.0, 0.0, 0.0, ...]>,
 datetime.datetime(2053, 1, 3, 0, 0): <Dual: 0.459970, (curve0, curve1, curve2, ...), [0.0, 0.0, 0.0, ...]>}

Finally the result beween the two libraries is summarized in the table below:

Discount Factors from rateslib and QuantLib#

Curve Nodes

RatesLib

QuantLib

Residual

2023-01-03

1

1

0

2024-01-03

0.966203023

0.966203023

-1.34004E-13

2025-01-03

0.93439395

0.93439395

-4.45088E-13

2026-01-05

0.903918458

0.903918458

-1.22391E-12

2027-01-04

0.875578174

0.875578174

-2.18003E-12

2028-01-03

0.849391648

0.849391648

-3.467E-12

2029-01-03

0.824236176

0.824236176

-5.21694E-12

2030-01-03

0.799874114

0.799874114

-7.35501E-12

2031-01-03

0.776572941

0.776572941

-1.003E-11

2032-01-05

0.754095707

0.754095707

-1.34019E-11

2033-01-03

0.732456627

0.732456627

-2.72941E-11

2035-01-03

0.691621953

0.691621953

-7.6532E-11

2038-01-04

0.637877344

0.637877345

-2.43384E-10

2043-01-05

0.562978818

0.562978819

-5.81426E-10

2048-01-03

0.503558382

0.503558381

1.48542E-10

2053-01-03

0.459970336

0.45997033

5.2457E-09

Given that the term structure that have been created by both libraries, the next step is to value an IRS. Starting with QuantLib:

# Link the zero rate curve to be used as forward and discounting
yts = ql.RelinkableYieldTermStructureHandle()
yts.linkTo(curve)
engine = ql.DiscountingSwapEngine(yts)

# Define the maturity of our swap
maturity = ql.Period("2y")
# Create a custom Ibor index for the floating leg
custom_ibor_index = ql.IborIndex(
    "Ibor",
    ql.Period("1Y"),
    0,
    ql.SEKCurrency(),
    ql.Sweden(),
    ql.ModifiedFollowing,
    False,
    ql.Actual360(),
    yts,
)
fixed_rate = 0.03269
forward_start = ql.Period("0D")
# Create the swap using the helper class MakeVanillaSwap
swap = ql.MakeVanillaSwap(
    maturity,
    custom_ibor_index,
    fixed_rate,
    forward_start,
    Nominal=10e7,
    pricingEngine=engine,
    fixedLegDayCount=ql.Actual360(),
)

Above we have specified the attributes of our IRS in QuantLib and now we want to price it and extract the NPVs and the corresponding cashflows:

import pandas as pd

fixed_cashflows = pd.DataFrame(
    [
        {
            "Type": "FixedPeriod",
            "accrualStart": cf.accrualStartDate().ISO(),
            "accrualEnd": cf.accrualEndDate().ISO(),
            "paymentDate": cf.date().ISO(),
            "df": curve.discount(cf.accrualEndDate()),
            "rate": cf.rate(),
            "cashflow": cf.amount(),
            "npv": -curve.discount(cf.accrualEndDate()) * cf.amount(),
        }
        for cf in map(ql.as_fixed_rate_coupon, swap.leg(0))
    ]
)

float_cashflows = pd.DataFrame(
    [
        {
            "Type": "FloatPeriod",
            "accrualStart": cf.accrualStartDate().ISO(),
            "accrualEnd": cf.accrualEndDate().ISO(),
            "paymentDate": cf.date().ISO(),
            "df": curve.discount(cf.accrualEndDate()),
            "rate": cf.rate(),
            "cashflow": cf.amount(),
            "npv": curve.discount(cf.accrualEndDate()) * cf.amount(),
        }
        for cf in map(ql.as_floating_rate_coupon, swap.leg(1))
    ]
)

ql_cashflows = pd.concat([fixed_cashflows, float_cashflows])

This results in the following cashflows:

Cashflows attributes from QuantLib#

Type

accStart

accEnd

df

rate

cashflow

npv

Fixed Fixed Float Float

23-01-03 24-01-03 23-01-03 24-01-03

24-01-03 25-01-03 24-01-03 25-01-03

0.9662030 0.9343939 0.9662030 0.9343939

0.03269 0.03269 0.03450 0.03348

3314402.77 3323483.33 3497916.66 3404246.45

-3202385.98 -3105442.72 3379697.65 3180907.29

Which compared with rateslib

In [7]: irs = rl.IRS(
   ...:     effective=rl.dt(2023, 1, 3),
   ...:     termination="2Y",
   ...:     frequency="A",
   ...:     calendar="stk",
   ...:     currency="sek",
   ...:     fixed_rate=3.269,
   ...:     convention="Act360",
   ...:     notional=100e6,
   ...:     curves=["curve"],
   ...:     payment_lag=0,
   ...:     modifier='F'
   ...: )
   ...: 

In [8]: rl_cashflows = irs.cashflows(curves=[curve])

Results in the following table:

Cashflows attributes from rateslib#

Type

AccStart

Acc End

DF

Rate

Cashflow

NPV

Fixed Fixed Float Float

23-01-03 24-01-03 23-01-03 24-01-03

24-01-03 25-01-03 24-01-03 25-01-03

0.9662030 0.9343939 0.9662030 0.9343939

3.2690 3.2690 3.4500 3.3484

-3314402.77 -3323483.33 3497916.66 3404246.45

-3202385.98 -3105442.72 3379697.65 3180907.29

Which is identical to the QuantLib result. If you’re interested in delving deeper into the calculation of DFs by rateslib and QuantLib, you may find some insights in this blog post.