SOFR Coupon Forecasting and Compounding in QuantLib
Summary
The document explains why reading a single SOFR fixing at a coupon date does not reproduce the rate paid on a compounded-in-arrears SOFR bond. A floating-rate coupon based on term rates uses one fixing near the period start, while an overnight SOFR coupon compounds daily fixings throughout the accrual period, including adjustments for weekends and holidays. QuantLib’s generic FloatingRateBond models the former convention, so it is not the right instrument for the described SOFR cash flows.
The suggested approach is to create overnight coupons with an OvernightLeg and place them in a Bond. The example also highlights that a curve bootstrapped from OIS quotes can forecast SOFR fixings, but the same curve may not reflect the issuer’s credit risk for discounting bond cash flows. The discussion gives a conceptual explanation and a modeling direction, rather than a full implementation or validation against market prices; the appropriate discount curve depends on the bond and pricing objective.
Key ideas
- A compounded SOFR coupon aggregates daily overnight fixings across its accrual period.
- A fixing queried at the coupon date is not the compounded rate paid for the full period.
- QuantLib’s generic FloatingRateBond represents a single-fixing convention rather than daily SOFR compounding.
- An OvernightLeg can supply SOFR coupons for a Bond.
- An OIS curve may forecast SOFR but may not be suitable for discounting a credit-risky bond.
Tags
Full text
# Understanding SOFR Fixing Rate Retrieval for Future Dates in QuantLib
# Understanding SOFR Fixing Rate Retrieval for Future Dates in QuantLib
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.
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.
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")
```
## Answer by Luigi Ballabio (score 3, accepted)
https://quant.stackexchange.com/a/76592
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.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.