7
$\begingroup$

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.

$\endgroup$
4
  • 3
    $\begingroup$ One thing that comes to my mind: I think you need to build your curves using curve stacking, i.e. bootstrap the discounting curve first, and then build the LIBOR-projection curve, where your reference instruments require the SOFR discounting curve as a basis curve, see the last argument to the ql.SwapRateHelper here: quantlib-python-docs.readthedocs.io/en/latest/… $\endgroup$ Mar 26 at 9:04
  • $\begingroup$ Another comment: Are the cash flow schedules (cash flow dates) identical to those Bloomberg? $\endgroup$ Mar 26 at 9:05
  • $\begingroup$ @Kermittfrog The cash flow schedules are the same as what is in Bloomberg. The amounts on the fixed leg are also the same. The discounting and the float leg amounts are not the same. I will try your curve stacking idea. $\endgroup$
    – K. Mao
    Mar 26 at 16:21
  • 2
    $\begingroup$ @Kermittfrog I tried to implement the curve stacking idea that you mentioned and it seemed to work well. I know that my discounting curve is not exactly the same as the one that Bloomberg uses, but using curve stacking seemed to clean up most of the big issues with the forward curve. At the very least, now the NPV differences from Bloomberg on both legs are in the same direction, so when I subtract the two NPVs to get the value of the swap, the differences largely cancel out. $\endgroup$
    – K. Mao
    Mar 26 at 17:57

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.