Bootstrap with QuantLib: Fair Swap or zero NPV

In all brevity

1. What is the termination condition used in QuantLib's curve bootstrapping?
2. Can I modify this setup to my needs, e.g. can I tune this to a higher accuracy?

Background

When bootstrapping discounting and projection curves, a necessary condition is for the implied curves to price the benchmark instruments at market, i.e. to value the instruments that were originally supplied to the curve construction algorithm as close as possible to their input prices. With a curve that uses a local interpolation mechanism (iterative bootstrap), I would assume all benchmark instruments to be re-priced to market prices with minimal error, i.e.

Assume a curve/bootstrap mechanism using local interpolation, e.g. linear interpolation of log discount factors. Given a market quote, and given the curve constructed 'so far', the bootstrap mechanism selects the 'next' reference instrument and updates the curve by shifting a rate at a node point until the reference instrument is priced at market.

My observation and question

I reprice my benchmark instruments on the curve I have just bootstrapped and find that these do not price as close to zero as I would have assumed, see minimal example below:

import QuantLib as ql

today = ql.Date(23,ql.June,2020)
ql.Settings.instance().evaluationDate = today
eonia = ql.Eonia()


Assuming 'flat' OIS quotes in the market at 1% per tenor and collecting the curve construction helpers:

quotes = {str(k)+'Y' : ql.SimpleQuote(0.01) for k in range(1,21)}
ois_helpers = []
for k,v in quotes.items():
ois_helpers.append(ql.OISRateHelper(
settlementDays = 2,
tenor = ql.Period(k),
rate = ql.QuoteHandle(v),
index = eonia,
telescopicValueDates =True))

eonia_curve = ql.PiecewiseLinearZero(2,ql.TARGET(),ois_helpers,ql.Actual365Fixed())
val_curve   = ql.YieldTermStructureHandle(eonia_curve)


Creating another eonia_index, this time with a projection curve attached; and a valuation engine:

eonia_index = ql.Eonia(val_curve)
swap_engine = ql.DiscountingSwapEngine(val_curve)


Now I am setting up the benchmark instruments as 'real' instruments and get their NPVs. Note that I assume a notional of 1 million currs:

print('TENOR \t PV \t fairrate% \t fairrate% + fairspread%')
for p in quotes.keys():
schedule = ql.MakeSchedule(today, today + ql.Period(p), ql.Period('1d'), calendar=ql.TARGET())
fixedRate = quotes[p].value()
ois_swap = ql.OvernightIndexedSwap(
1E6,
schedule,
fixedRate,
ql.Actual360(),
eonia_index)
ois_swap.setPricingEngine(swap_engine)
print(p + "\t" +
str(round(ois_swap.NPV(),2)) + " \t " +
str(round(ois_swap.fairRate()*100,4)) + "\t\t" +


Resulting in

TENOR    NPV     fairrate%   fairrate% + fairspread%
1Y  50.25    0.995      1.0
2Y  100.55   0.995      1.0
3Y  149.95   0.995      1.0
4Y  199.23   0.995      1.0
5Y  247.63   0.995      1.0
6Y  295.67   0.995      1.0
7Y  343.23   0.995      1.0
8Y  390.7    0.995      1.0
9Y  437.44   0.995      1.0
10Y 483.46   0.995      1.0
11Y 529.01   0.995      1.0
12Y 574.48   0.995      1.0
13Y 619.49   0.995      1.0
14Y 663.69   0.995      1.0
15Y 707.68   0.995      1.0
16Y 751.11   0.995      1.0
17Y 794.1    0.995      1.0
18Y 836.66   0.995      1.0
19Y 879.03   0.995      1.0
20Y 920.98   0.995      1.0


Clearly, the implied fair rate is not exactly 1%, but implied fair rate plus implied spread gives the 1%. Also, each swap's NPV is close, but not 'very' close to zero.

I am wondering, whether

1. there's something off in my instrument setup?
2. Which terminal condition QuantLib's bootstrap methodology is applying here, and
3. Whether I can set tighter bounds to that mechanism.

Thank you very much for any input / thoughts / pointers.

My setup

I have built the discount curve following Luigi's and Goutham's QuantLib Python Cookbook as of 2019-JUNE-01; I am using the QuantLib Python SWIG; version 1.19.

The problem is that you are not pricing the same thing, and for two reasons:

1. The vanilla instruments you are pricing should start on spot date and have a maturity with that start as reference
2. The frequency of the fixed leg on the OIS swap should be annual.

If you change you code to:

print('TENOR \t PV \t fairrate% \t fairrate% + fairspread%')
calendar = ql.TARGET()
for p in quotes.keys():
schedule = ql.MakeSchedule(start, calendar.advance(start, ql.Period(p)), ql.Period('1Y'), calendar=calendar)
fixedRate = quotes[p].value()
ois_swap = ql.OvernightIndexedSwap(
1E6,
schedule,
fixedRate,
ql.Actual360(),
eonia_index)

ois_swap.setPricingEngine(swap_engine)
print(p + "\t" +
str(round(ois_swap.NPV(),2)) + " \t \t" +
str(round(ois_swap.fairRate()*100,4)) + "\t\t" +


You'll get the right output.

This can also be checked by extracting the instruments from the helpers:

print('TENOR \t PV \t fairrate% \t fairrate% + fairspread%')
for idx, p in enumerate(quotes.keys()):
ois_swap = ois_helpers[idx].swap()
ois_swap.setPricingEngine(swap_engine)
print(p + "\t" +
str(round(ois_swap.NPV(),2)) + " \t \t" +
str(round(ois_swap.fairRate()*100,4)) + "\t\t" +