Building EUR OIS and Euribor Curves to Price a Swap
Summary
The document works through building discount and projection curves in QuantLib to price a five-year EUR receiver interest rate swap. The setup uses an ESTR-linked overnight curve for discounting, a six-month Euribor curve for forecasting, and Bloomberg swap data as a comparison. The author asks about the use of Eonia helpers during the ESTR transition, a duplicate-pillar runtime error, and whether the generated schedules align with the intended dates.
The reported error came from a collision at the two-year curve point between an FRA and a swap helper. The answer adjusts the FRA start month to account for its six-month tenor, after which the calculated fair swap rate closely matches the Bloomberg reference. The example offers a practical debugging clue, but its code and calibration depend on specific dates, conventions, inputs, and QuantLib behavior. It does not fully resolve how Eonia should represent ESTR transition mechanics, so that curve choice needs separate validation.
Key ideas
- A discount curve and a projection curve serve different roles when pricing a vanilla swap.
- Curve helpers can fail when multiple instruments map to the same pillar date.
- FRA helper start periods should reflect the FRA tenor to avoid colliding with swap pillars.
- After correcting the overlapping pillar, the reported fair rate is close to the Bloomberg comparison.
- The example leaves Eonia and ESTR transition treatment as an open validation question.
Tags
Full text
# Bootstrapping discount and forward curve (using ESRA) and price a vanilla swap
# Bootstrapping discount and forward curve (using ESRA) and price a vanilla swap
I am just starting to use Quantlib, and want to try and replicate the SWPM-functionality in Bloomberg, and price a vanilla 5Y EUR OIS. Below is the overall swap data used in BBG:
Overall settings
- Valuation (Settlement) Date: 2021-09-20
Curves
- EUR OIS ESTR (discounting), Mid, Piecewise Linear Interpolation
- EUR vs. 6m (projecting), Mid, Piecewise Linear Interpolation
Swap
- Vanilla EUR 5y receiver IRS
- 10mm EUR,
BBG solves the two legs with a NPV = 70,789.04 and a fixed coupon of -0.280922
Now, my code are found below (executable) - but obviously I get an error, or else I would not ask for your help!
Code:
```
import QuantLib as ql
""" General settings """
calendar = ql.TARGET()
todaysDate = ql.Date(13, ql.September, 2021)
ql.Settings.instance().evaluationDate = todaysDate
fixingDays = 5
settlementDate = calendar.advance(todaysDate, fixingDays, ql.Days)
# must be a business day
settlementDate = calendar.adjust(settlementDate)
depositDayCounter = ql.Actual360()
swFixedLegFrequency = ql.Annual
termStructureDayCounter = ql.Actual365Fixed()
print("Today: %s " % todaysDate)
print("Settlement date: %s " % settlementDate)
""" Quotes """
estr_rates = """
1D -0.571
1W -0.571
2W -0.5707
1M -0.571
2M -0.57075
3M -0.571
4M -0.57105
5M -0.5705
6M -0.5703
7M -0.56895
8M -0.56795
9M -0.56695
10M -0.567
11M -0.56525
12M -0.56455
18M -0.55815
2Y -0.55213
3Y -0.51495
4Y -0.47592
5Y -0.4216
6Y -0.37382
7Y -0.31379
8Y -0.2502
9Y -0.18776
10Y -0.12588
11Y -0.06616
12Y -0.00469
15Y 0.13036
20Y 0.24826
25Y 0.27148
30Y 0.25117
40Y 0.19365
50Y 0.14543
"""
euribor_6m_rates = """
6M -0.52
7M -0.512
8M -0.507
9M -0.5
10M -0.492
11M -0.486
12M -0.474
13M -0.472
14M -0.467
15M -0.462
16M -0.455
17M -0.448
18M -0.44
2Y -0.4598
3Y -0.4032
4Y -0.3485
5Y -0.2825
6Y -0.2249
7Y -0.1605
8Y -0.094
9Y -0.029
10Y 0.0328
11Y 0.0918
12Y 0.1468
15Y 0.2778
20Y 0.384
25Y 0.3998
30Y 0.3743
40Y 0.3053
50Y 0.245
"""
euribor_data = {line.split('\t')[0] : float(line.split('\t')[-1]) for line in euribor_6m_rates.splitlines() if line.strip()}
estr_data = {line.split('\t')[0] : float(line.split('\t')[-1]) for line in estr_rates.splitlines() if line.strip()}
# /*********************
# *** RATE HELPERS ***
# *********************/
eonia = ql.Eonia()
helpers = []
for tenor, rate in estr_data.items():
if tenor == '1D':
helpers.append(ql.DepositRateHelper(rate / 100, eonia))
else:
helpers.append( ql.OISRateHelper(2, ql.Period(tenor), ql.QuoteHandle(ql.SimpleQuote(rate/100)), eonia) )
# /*********************
# ** CURVE BUILDING **
# *********************/
# /*********************
# ** ESTR CURVE **
# *********************/
estrTermStructure = ql.PiecewiseLogCubicDiscount(todaysDate, helpers, termStructureDayCounter)
estrTermStructure.enableExtrapolation()
# // the one used for discounting cash flows
discountingTermStructure = ql.RelinkableYieldTermStructureHandle()
# /*********************
# ** EURIBOR 6M **
# *********************/
euribor6M = ql.Euribor6M()
helpers = []
for tenor, rate in euribor_data.items():
if tenor == '6M':
helpers.append( ql.DepositRateHelper(ql.QuoteHandle(ql.SimpleQuote(rate/100)),ql.Period(tenor), 3, calendar, ql.Following, False, depositDayCounter) )
elif 'M' in tenor:
helpers.append( ql.FraRateHelper(ql.QuoteHandle(ql.SimpleQuote(rate/100)),int(tenor[0:tenor.find('M')]), euribor6M) )
else:
helpers.append( ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(rate/100)),ql.Period(tenor), calendar, swFixedLegFrequency, ql.Unadjusted, ql.Thirty360(ql.Thirty360.BondBasis),euribor6M, ql.QuoteHandle(), ql.Period(0, ql.Days), discountingTermStructure) )
euribor6MTermStructure = ql.PiecewiseLogCubicDiscount(settlementDate, helpers, termStructureDayCounter)
# // the one used for forward rate forecasting
forecastingTermStructure = ql.RelinkableYieldTermStructureHandle()
# /*********************
# ** Swap **
# *********************/
nominal = 1000000.0
#fixed leg
fixedLegFrequency = ql.Annual
fixedLegConvention = ql.ModifiedFollowing
fixedLegDayCounter = ql.Thirty360(ql.Thirty360.BondBasis)
fixedRate = 0.007
firstFixDate = ql.Date(20,9,2022)
#floating leg
floatingLegDayCounter = ql.Actual360()
floatingLegFrequency = ql.Semiannual
floatingLegConvention = ql.ModifiedFollowing
euriborIndex = ql.Euribor6M(forecastingTermStructure)
spread = 0.0
lengthInYears = 5
swapType = ql.VanillaSwap.Receiver
maturity = ql.Date(20, ql.September, 2026) #settlementDate + lengthInYears*12
fixedSchedule = ql.Schedule(settlementDate, maturity,
ql.Period(fixedLegFrequency),
calendar, fixedLegConvention,
fixedLegConvention,
ql.DateGeneration.Backward, False, firstFixDate)
"""
list(fixedSchedule)
[Date(20,9,2021), << is this the start of the period?
Date(20,9,2022), << this one should be first payment date
Date(20,9,2023),
Date(20,9,2024),
Date(22,9,2025),
Date(21,9,2026)]
"""
floatSchedule = ql.Schedule(settlementDate, maturity,
ql.Period(floatingLegFrequency),
calendar, floatingLegConvention,
floatingLegConvention,
ql.DateGeneration.Backward, False)
"""
list(floatSchedule)
[Date(20,9,2021), << is this the start of the period?
Date(21,3,2022),
Date(20,9,2022),
Date(20,3,2023),
Date(20,9,2023),
Date(20,3,2024),
Date(20,9,2024),
Date(20,3,2025),
Date(22,9,2025),
Date(20,3,2026),
Date(21,9,2026)]
"""
forecastingTermStructure.linkTo(euribor6MTermStructure)
discountingTermStructure.linkTo(estrTermStructure)
spot5YearSwap = ql.VanillaSwap(swapType, nominal,
fixedSchedule, fixedRate, fixedLegDayCounter,
floatSchedule, euriborIndex, spread,
floatingLegDayCounter)
# and then the discount curve for the engine:
swapEngine = ql.DiscountingSwapEngine(discountingTermStructure)
spot5YearSwap.setPricingEngine(swapEngine)
NPV = spot5YearSwap.NPV()
fairSpread = spot5YearSwap.fairSpread()
fairRate = spot5YearSwap.fairRate()
```
Now running this, I get the following error:
```
RuntimeError: 2nd leg: more than one instrument with pillar September 15th, 2023
```
Which I have found somewhere that it might have to do with the building of the curves (using same tenor twice), but I can't figure out where.
The questions I'd like to get an answer to is:
- Is it legit to use Eonia() and respective Helpers building my "ESTR"/OIS-curve? Or how should I go ahead with this? Looking at ECB, the Eonia-methodology from Oct 2019 seems to adjust for future ESTR-transition, but is this reflected in Quantlib? (See here: Eonia/ESTR-transition)
- Why am I getting the above error?
- Am I constructing my schedules for my swap correctly - BBG-dates and Python are aligned, yet my schedules show the first and last date?
How far off am I for getting this working?
Since I am this new to this, and I can't really find any "fresh" examples of this anywhere, I thought it could be a good question in this forum - especially considering EONIA is out, and ESTR will be the new standard going forward.
Best,
/N
## Answer by gussilago (score 2)
https://quant.stackexchange.com/a/68013
So, the error lied as commented on the collision on the 2y-point between FRA and swap.
By adding this line to the FRA-section:
```
elif 'M' in tenor:
fra_start = int(tenor[0:tenor.find('M')]) - 6
helpers.append( ql.FraRateHelper(ql.QuoteHandle(ql.SimpleQuote(rate/100)),fra_start, euribor6M) )
```
to take into account the period of the FRA (correct me if I'm wrong here guys!) I managed to end up with a similar fair rate as Bloomberg:
```
fairRate = spot5YearSwap.fairRate()
print(f"Fair swap rate: {fairRate:.6%}")
>> Fair swap rate: -0.280942%
```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.