I'm trying to learn more about the QuantLib python package and as an exercise I'm trying to replicate some swaps on Bloomberg. However, when I do so, it seems like my swaps are consistently underpriced compared to what Bloomberg thinks.
I'm wondering if anyone who has more familiarity with both QuantLib and Bloomberg knows what is going on. Maybe I'm not using the helpers correctly when building the two curves? Or maybe I'm not using the correct interpolation for the curves?
For reference, here is the code that I am currently using. For this example I'll try to price a 7 Year USD Swap with a 2% coupon.
First I do some basic date setup.
# Setup
import datetime
import pandas as pd
import QuantLib as ql
today_date = ql.Date(25, 3, 2021)
settlement_date = ql.Date(29, 3, 2021)
maturity_date = ql.Date(29, 3, 2028)
ql.Settings.instance().evaluationDate = today_date
I then build both the forward and the discount curves by just parsing data that I copied + pasted directly from Bloomberg. It looks like in the swap pricer Bloomberg uses "USD (30/360 S/A vs. 3M LIBOR)" as their forward curve and "USD SOFR (vs. FIXED RATE)" as their discounting curve so that is the data I copied. If you have access to Bloomberg yourself and want to check this code on the current day's data you can copy-paste their numbers into these sections.
# Forward Curve
# Copied directly from Bloomberg
fwd_data = """
3 MO 0.19513
EDM1 0.16965
EDU1 0.18399
EDZ1 0.25306
EDH2 0.20686
EDM2 0.24531
EDU2 0.30857
2 YR 0.25745
3 YR 0.42980
4 YR 0.68065
5 YR 0.91950
6 YR 1.13075
7 YR 1.30400
8 YR 1.44215
9 YR 1.55400
10 YR 1.64850
"""
fwd_data = [float(line.split('\t')[-1]) for line in fwd_data.splitlines() if line.strip()]
helpers = [
ql.DepositRateHelper(ql.QuoteHandle(ql.SimpleQuote(fwd_data[0] / 100)), ql.USDLibor(ql.Period('3M'))), # US0003M Index
ql.FuturesRateHelper(ql.QuoteHandle(ql.SimpleQuote(100 - fwd_data[1])), ql.Date(16, 6, 2021), ql.USDLibor(ql.Period('3M'))), # EDM1 Comdty
ql.FuturesRateHelper(ql.QuoteHandle(ql.SimpleQuote(100 - fwd_data[2])), ql.Date(15, 9, 2021), ql.USDLibor(ql.Period('3M'))), # EDU1 Comdty
ql.FuturesRateHelper(ql.QuoteHandle(ql.SimpleQuote(100 - fwd_data[3])), ql.Date(15, 12, 2021), ql.USDLibor(ql.Period('3M'))), # EDZ1 Comdty
ql.FuturesRateHelper(ql.QuoteHandle(ql.SimpleQuote(100 - fwd_data[4])), ql.Date(16, 3, 2022), ql.USDLibor(ql.Period('3M'))), # EDH2 Comdty
ql.FuturesRateHelper(ql.QuoteHandle(ql.SimpleQuote(100 - fwd_data[5])), ql.Date(15, 6, 2022), ql.USDLibor(ql.Period('3M'))), # EDM2 Comdty
ql.FuturesRateHelper(ql.QuoteHandle(ql.SimpleQuote(100 - fwd_data[6])), ql.Date(21, 9, 2022), ql.USDLibor(ql.Period('3M'))), # EDU2 Comdty
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(fwd_data[7] / 100)), ql.Period('2Y'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Quarterly, ql.ModifiedFollowing, ql.Thirty360(), ql.USDLibor(ql.Period('3M'))),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(fwd_data[8] / 100)), ql.Period('3Y'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Quarterly, ql.ModifiedFollowing, ql.Thirty360(), ql.USDLibor(ql.Period('3M'))),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(fwd_data[9] / 100)), ql.Period('4Y'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Quarterly, ql.ModifiedFollowing, ql.Thirty360(), ql.USDLibor(ql.Period('3M'))),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(fwd_data[10] / 100)), ql.Period('5Y'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Quarterly, ql.ModifiedFollowing, ql.Thirty360(), ql.USDLibor(ql.Period('3M'))),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(fwd_data[11] / 100)), ql.Period('6Y'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Quarterly, ql.ModifiedFollowing, ql.Thirty360(), ql.USDLibor(ql.Period('3M'))),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(fwd_data[12] / 100)), ql.Period('7Y'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Quarterly, ql.ModifiedFollowing, ql.Thirty360(), ql.USDLibor(ql.Period('3M'))),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(fwd_data[13] / 100)), ql.Period('8Y'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Quarterly, ql.ModifiedFollowing, ql.Thirty360(), ql.USDLibor(ql.Period('3M'))),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(fwd_data[14] / 100)), ql.Period('9Y'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Quarterly, ql.ModifiedFollowing, ql.Thirty360(), ql.USDLibor(ql.Period('3M'))),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(fwd_data[15] / 100)), ql.Period('10Y'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Quarterly, ql.ModifiedFollowing, ql.Thirty360(), ql.USDLibor(ql.Period('3M'))),
]
fwd_curve = ql.YieldTermStructureHandle(ql.PiecewiseLinearForward(settlement_date, helpers, ql.Thirty360()))
# Discount Curve
# Copied directly from Bloomberg
discount_data = """
1 DY 0.01000
1 WK 0.01750
2 WK 0.01741
3 WK 0.01770
1 MO 0.01750
2 MO 0.01660
3 MO 0.01990
4 MO 0.02200
5 MO 0.02520
6 MO 0.02775
7 MO 0.02875
8 MO 0.02950
9 MO 0.03200
10 MO 0.03500
11 MO 0.03670
12 MO 0.03920
18 MO 0.05350
2 YR 0.09050
3 YR 0.24300
4 YR 0.47115
5 YR 0.69700
6 YR 0.89730
7 YR 1.06400
8 YR 1.19550
9 YR 1.30400
10 YR 1.39300
"""
discount_data = [float(line.split('\t')[-1]) for line in discount_data.splitlines() if line.strip()]
helpers = [
ql.DepositRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[0] / 100)), ql.Sofr()), # SOFRRATE Index
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[1] / 100)), ql.Period('1W'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[2] / 100)), ql.Period('2W'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[3] / 100)), ql.Period('3W'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[4] / 100)), ql.Period('1M'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[5] / 100)), ql.Period('2M'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[6] / 100)), ql.Period('3M'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[7] / 100)), ql.Period('4M'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[8] / 100)), ql.Period('5M'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[9] / 100)), ql.Period('6M'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[10] / 100)), ql.Period('7M'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[11] / 100)), ql.Period('8M'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[12] / 100)), ql.Period('9M'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[13] / 100)), ql.Period('10M'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[14] / 100)), ql.Period('11M'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[15] / 100)), ql.Period('12M'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[16] / 100)), ql.Period('18M'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[17] / 100)), ql.Period('2Y'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[18] / 100)), ql.Period('3Y'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[19] / 100)), ql.Period('4Y'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[20] / 100)), ql.Period('5Y'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[21] / 100)), ql.Period('6Y'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[22] / 100)), ql.Period('7Y'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[23] / 100)), ql.Period('8Y'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[24] / 100)), ql.Period('9Y'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
ql.SwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(discount_data[25] / 100)), ql.Period('10Y'), ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.Annual, ql.ModifiedFollowing, ql.Actual360(), ql.Sofr()),
]
discount_curve = ql.YieldTermStructureHandle(ql.PiecewiseLinearForward(settlement_date, helpers, ql.Actual360()))
Then I set up the swap and check the price.
# Build Swap
calendar = ql.JointCalendar(ql.UnitedStates(ql.UnitedStates.FederalReserve), ql.UnitedKingdom())
fixed_schedule = ql.Schedule(settlement_date, maturity_date, ql.Period(6, ql.Months), calendar, ql.ModifiedFollowing, ql.ModifiedFollowing, ql.DateGeneration.Backward, False)
float_schedule = ql.Schedule(settlement_date, maturity_date, ql.Period(3, ql.Months), calendar, ql.ModifiedFollowing, ql.ModifiedFollowing, ql.DateGeneration.Backward, False)
libor3M_index = ql.USDLibor(ql.Period('3M'), fwd_curve)
notional = 10000000
fixed_rate = 0.02
fixed_leg_daycount = ql.Thirty360()
float_spread = 0
float_leg_daycount = ql.Actual360()
ir_swap = ql.VanillaSwap(ql.VanillaSwap.Payer, notional, fixed_schedule, fixed_rate, fixed_leg_daycount, float_schedule, libor3M_index, float_spread, float_leg_daycount)
swap_engine = ql.DiscountingSwapEngine(discount_curve)
ir_swap.setPricingEngine(swap_engine)
print('Total NPV: {}'.format(-1*ir_swap.NPV()))
print('Premium: {}'.format(-1*ir_swap.NPV()/notional*100))
The above code gives me a swap priced at 4.71, compared to Bloomberg's price of 4.75. This is something that is consistent no matter what swap I check, my price always seems to be biased lower than Bloomberg's price.
Doing some further analysis, if I look into the cash flows for both legs, it seems like this issue arises on both the fixed leg and the floating leg. This code gives a fixed leg premium of 106.29, compared to Bloomberg's 106.31, and a float leg premium of 101.57 compared to 101.55.
Because an issue arises on the fixed leg, whose payments are relatively easy to calculate, that means that there is probably something off with my discounting, which results in a premium that is 2 cents lower. However, if the discounting curve was the only problem, then I would also expect my float leg premium to be lower than what Bloomberg thinks it should be, and the these two errors should theoretically partially cancel out.
However, my code gives a premium on the float leg that is higher than Bloomberg's premium, which instead increases the difference even more.
# Fixed leg cash flows
leg = ir_swap.fixedLeg()
cash_flows = list(map(ql.as_coupon, leg))
dates = [datetime.datetime(x.date().year(), x.date().month(), x.date().dayOfMonth()) for x in cash_flows]
amounts = [x.amount() for x in cash_flows]
discounts = [discount_curve.discount(x.date()) for x in cash_flows]
df = pd.DataFrame(zip(amounts, discounts), index=dates, columns=['Amount', 'Discount'])
print(df)
print('Fixed Leg NPV: {}'.format(sum(df['Amount']*df['Discount'])+notional*df['Discount'][-1]))
print('Fixed Leg Premium: {}'.format((sum(df['Amount']*df['Discount'])+notional*df['Discount'][-1])/notional * 100))
# Float leg cash flows
leg = ir_swap.floatingLeg()
cash_flows = list(map(ql.as_coupon, leg))
dates = [datetime.datetime(x.date().year(), x.date().month(), x.date().dayOfMonth()) for x in cash_flows]
amounts = [x.amount() for x in cash_flows]
discounts = [discount_curve.discount(x.date()) for x in cash_flows]
df = pd.DataFrame(zip(amounts, discounts), index=dates, columns=['Amount', 'Discount'])
print(df)
print('Float Leg NPV: {}'.format(sum(df['Amount']*df['Discount'])+notional*df['Discount'][-1]))
print('Float Leg Premium: {}'.format((sum(df['Amount']*df['Discount'])+notional*df['Discount'][-1])/notional * 100))
This sort of premium difference seems to exist across multiple different USD swaps that I have tried to replicate (I haven't tried any foreign currency swaps yet). There is probably something off with how I'm building both the discount and forward curves, but I'm not experienced enough with swaps to know what the problem is. I originally thought that this might have to do with how Bloomberg interpolates their curves, but even switching the Bloomberg curve interpolation to "Piecewise Linear (Simple)" doesn't seem to make a huge difference in the price, at least not nearly a large enough difference to account for 4 cents in premium.
Any sort of help on this matter would be appreciated.
ql.SwapRateHelper
here: quantlib-python-docs.readthedocs.io/en/latest/… $\endgroup$