1
$\begingroup$

I am using QuantLib to calculate the SOFR index for a bond's (ISIN : US025816CL12) cash flows. My objective is to understand how QuantLib computes the SOFR fixing rate for future dates. Here is my code:

SOFRindex = ql.OvernightIndex("SOFR", 1, ql.USDCurrency(), calendar, day_count, discounting_term_structure)

bond = ql.FloatingRateBond(0,faceValue, schedule, SOFRindex, day_count,spreads=[spread])

#display bond cashflows
for i, cashflow in enumerate(bond.cashflows()):
    d = cashflow.date()
    try:
        result = SOFRindex.fixing(d, True)
        print(f"Index Value for {d} is {round(result*100,2)}")
    except Exception as e:
        print(f"Index Value for {d} is No Data")

The above code works, but I'm not quite sure how QuantLib is determining the SOFR rates for future dates. The output is as follows:

Index Value for August 4th, 2023 is 5.17
Index Value for November 6th, 2023 is 5.17
Index Value for February 5th, 2024 is 4.96
Index Value for May 6th, 2024 is 4.57
Index Value for August 5th, 2024 is 3.93
Index Value for November 4th, 2024 is 3.93
Index Value for February 4th, 2025 is 3.33
Index Value for May 5th, 2025 is 3.33
Index Value for August 4th, 2025 is 3.06
Index Value for November 4th, 2025 is 3.06
Index Value for February 4th, 2026 is 3.06
Index Value for May 4th, 2026 is 3.06
Index Value for August 4th, 2026 is 2.96
Index Value for November 4th, 2026 is 2.96

Using Bloomberg we can see that the SOFR Secured Overnight Financing Rate Compounded Index gives a rate of 5.10860 for the period from he 05/04/2023 to 08/04/2023 using the backward convention real date are 05/02/2023 and 08/02/2023. As we can see this does not match the 5.17.

enter image description here

To show that Bloomberg is relevant and correct we can take an example from the past :

start date : 11/04/2022 --> Backward convention : 11/02/2022

end date : 02/06/2023 --> Backward convention : 02/02/2023

Gives a rate of 4.73020 which does match with 4.730198

So I believe that I am missing something but I don't know what yet.

Is the below correct ?

SOFRindex = ql.OvernightIndex("SOFR", 1, ql.USDCurrency(), calendar, day_count, discounting_term_structure)

Because I am not quite sure that this use forward rates for futures date.

enter image description here

enter image description here

Full code is just below and can be used as a reproducible example

import QuantLib as ql
import datetime

calculation_date = ql.Date().todaysDate() #ql.Date(15, 6, 2023)

ql.Settings.instance().evaluationDate = calculation_date
yts = ql.RelinkableYieldTermStructureHandle()

index_for_curve_s490 = ql.OvernightIndex("USD Overnight Index", 0, ql.USDCurrency(), ql.UnitedStates(ql.UnitedStates.Settlement), ql.Actual360(),yts)

swaps = {
    ql.Period("1W"): 0.050881,
    ql.Period("2W"): 0.050907,
    ql.Period("3W"): 0.050901,
    ql.Period("1M"): 0.050985,
    ql.Period("2M"): 0.05155,
    ql.Period("3M"): 0.05202,
    ql.Period("4M"): 0.052316,
    ql.Period("5M"): 0.052405,
    ql.Period("6M"): 0.052419,
    ql.Period("7M"): 0.052346,
    ql.Period("8M"): 0.052213,
    ql.Period("9M"): 0.052052,
    ql.Period("10M"): 0.051765,
    ql.Period("11M"): 0.051434,
    ql.Period("12M"): 0.051021,
    ql.Period("18M"): 0.047224,
    ql.Period("2Y"): 0.044145,
    ql.Period("3Y"): 0.03992,
    ql.Period("4Y"): 0.037565,
    ql.Period("5Y"): 0.036239,
    ql.Period("6Y"): 0.035464,
    ql.Period("7Y"): 0.034974,
    ql.Period("8Y"): 0.034677,
    ql.Period("9Y"): 0.034518,
    ql.Period("10Y"): 0.03442,
    ql.Period("12Y"): 0.034378,
    ql.Period("15Y"): 0.03437,
    ql.Period("20Y"): 0.033933,
    ql.Period("25Y"): 0.032933,
    ql.Period("30Y"): 0.031949,
    ql.Period("40Y"): 0.029842,
    ql.Period("50Y"): 0.02773,
}


rate_helpers = []

for tenor, rate in swaps.items():
    helper = ql.OISRateHelper(2, tenor, ql.QuoteHandle(ql.SimpleQuote(rate)), index_for_curve_s490)
    rate_helpers.append(helper)

curve = ql.PiecewiseFlatForward(calculation_date, rate_helpers, ql.Actual360())
yts.linkTo(curve)

spread = 65/10000
#spread = 0

settlementDays = 2
faceValue = 100
compounding = ql.Compounded
pricingDate = calculation_date + ql.Period(f"{settlementDays}D")
issueDate = ql.Date(4, 11, 2021)
maturityDate = ql.Date(4, 11, 2026)
tenor = ql.Period("3M")
calendar = ql.UnitedStates(ql.UnitedStates.GovernmentBond)
day_count = ql.Actual360()
coupon_rate = 0.05729695699595476


schedule = ql.Schedule( pricingDate,
                        maturityDate,
                        tenor,
                        calendar,
                        ql.Unadjusted,
                        ql.Unadjusted,
                        ql.DateGeneration.Backward,
                        True)

flat_forward = ql.FlatForward(2,
                    calendar,
                    coupon_rate,
                    ql.Thirty360(ql.Thirty360.USA),
                    compounding)

                    

discounting_term_structure = ql.RelinkableYieldTermStructureHandle(curve)
index_term_structure = ql.RelinkableYieldTermStructureHandle(flat_forward)

SOFRindex = ql.OvernightIndex("SOFR", 1, ql.USDCurrency(), calendar, day_count, discounting_term_structure)


#index = index_for_curve_s490.clone(yts)

bond = ql.FloatingRateBond(2,faceValue, schedule, SOFRindex, day_count,spreads=[spread])

#display bond cashflows start date and end date
for i, cashflow in enumerate(bond.cashflows()):
    d = cashflow.date()
    try:
        result = SOFRindex.fixing(d, True)
        print(f"Accrual Start {d} : {round(result*100,2)}")
    except Exception as e:
        print(f" {d} No Data")
    #print(f"Coupon for {d} is {round(cashflow.amount(),2)}")

pricing_engine = ql.DiscountingBondEngine(discounting_term_structure)
bond.setPricingEngine(pricing_engine)
print(f"My Calculated Price is {bond.cleanPrice()} and the bloomberg price is 99.13") 
$\endgroup$
3
  • $\begingroup$ "above code works" in the sense that it doesn't throw excrptions, or that you're sure that it calculates what you intended? $\endgroup$ Jun 16, 2023 at 12:22
  • $\begingroup$ @DimitriVulis, it does not throw any errors but it still does not match the price. To be really close to the price I have to use issueDate in schedule instead of pricingDate and then to add a fixing for the date of the first period since it is in the paste. (If I set this fixing point as 0% then I am close to what bloomberg price, but if I put real fixing rate, I have a wide spread between me and bloomberg. Do you have any thought on what is happening ? $\endgroup$
    – TourEiffel
    Jun 16, 2023 at 13:49
  • $\begingroup$ In QuantLib, the issueDate argument should always be the issueDate of the bond, not your pricing date. See Luigi's answer to this question: quant.stackexchange.com/questions/8965/… $\endgroup$
    – HookahBoy
    Jun 18, 2023 at 13:31

1 Answer 1

2
$\begingroup$

If you ask the SOFR index for a fixing at the start date of the coupon, it will return it, but that's not what the coupon pays. A coupon paying SOFR over a period pays the compounded SOFR fixings over all the dates of the coupon; that is, a coupon starting May 2nd 2023 and ending August 2nd (after the fixing lag is applied) will pay the May 2nd fixing accrued for one day, compounded with the May 3rd fixing accrued for one day, compounded with the May 4th fixing accrued for one day, compounded with the May 5th fixing accrued for three days (because the next business day is on next Monday), and so on until the end of the coupon.

That's not what FloatingRateBond does; it models the old kind of bonds based on Libor in which the fixing is taken at the beginning of the coupon and accrued for the whole length.

At this time there's no specific class for bonds paying SOFR, but it would just be a convenience and it's not strictly needed. You can still create a list of SOFR-paying coupons using:

coupons = ql.OvernightLeg([nominal], schedule, SOFRindex)

and then pass them to build an instance of the base Bond class:

bond = Bond(settlement_days, calendar, issue_date, coupons)

Another thing: it might be because you simplified the code for posting, but I see you're using the same curve for discounting and for forecasting the SOFR fixings. It's bootstrapped from a set of OIS, so it's correct to use it for forecasting and will give you the correct coupon rates, but it might not be the correct one for discounting (since it doesn't include credit risk) and might not give you the expected price in the end. You might have to fit a discount curve from some quoted bond prices for the same issuer.

$\endgroup$
2
  • $\begingroup$ Many thanks, So you mean that the best practice is to create a separate yield curve to use for discounting that includes a credit spread. For example, I can use market-quoted CDS spreads, for example, to build this curve. Then set this curve in the pricing engine for discounting... Am I correct ? $\endgroup$
    – TourEiffel
    Sep 7, 2023 at 14:15
  • $\begingroup$ Yes, CDS spreads would be a way of building it, but I wouldn't just apply them on top of the curve—the conventions might be different, and there's a recovery rate involved that needs to be taken into account so it's more complex than that. Another way would be to fit a discount curve over quoted bond prices. $\endgroup$ Sep 8, 2023 at 6:54

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.