Skip to content
All library documents

Constructing SOFR Forward Curves with QuantLib

Article Quant Q&A · Author: dummy_quant

Summary

The document describes an attempt to construct a USD SOFR curve from overnight indexed swap quotes spanning short and long maturities, then extract one-, three-, six-, and twelve-month forward rates. It uses QuantLib rate helpers and a piecewise flat forward curve, checking calibration by comparing modeled swap rates, zero rates, discount factors, and present values with Bloomberg values.

The author reports that spot curve outputs align reasonably well, while forward rates and zero rates differ slightly and generated dates are shifted by two days. The example calculates forwards using continuous compounding and ACT/365, but its tenor dates mix calendar periods with fixed day advances and business-day adjustments. The document is a troubleshooting question rather than a confirmed solution; it does not establish which conventions or date-generation choices account for the discrepancies.

Key ideas

  • SOFR OIS quotes can be used as rate-helper inputs to build a QuantLib yield curve.
  • Curve calibration can be checked against market swap rates, discount factors, and zero rates.
  • Forward rates depend on day-count, compounding, tenor, calendar, and date-rolling conventions.
  • The example reports a two-day date mismatch and small rate differences but does not identify a definitive cause.

Tags

Full text
# Building a forward curve for multiple tenors - Quantlib python


# Building a forward curve for multiple tenors - Quantlib python












I am attempting to build a forward curve for multiple tenors (1M / 3M / 6M / 12M) using the quantlib library. The input to my model are sofr swaps(1W through 50Y). It appears I am building my curve correctly as I can zero rates and discount factors for my spot curve directly back to bloomberg, but when I try getting forward rates I am no longer able to tie.

```
import pandas as pd
import QuantLib as ql
import math

rates = {
    '1W':5.30694,
    '2W':5.3096,
    '3W':5.3119,
    '1M':5.3155,
    '2M':5.3381,
    '3M':5.36788,
    '4M':5.39426,
    '5M':5.4169,
    '6M':5.43035,
    '7M':5.4355,
    '8M':5.43425,
    '9M':5.42945,
    '10M':5.415,
    '11M':5.39297,
    '12M':5.36985,
    '18M':5.071,
    '2Y':4.81595,
    '3Y':4.42031,
    '4Y':4.1825,
    '5Y':4.04375,
    '6Y':3.95985,
    '7Y':3.90315,
    '8Y':3.8633,
    '9Y':3.83855,
    '10Y':3.82213,
    '12Y':3.80775,
    '15Y':3.79964,
    '20Y':3.74566,
    '25Y':3.63489,
    '30Y':3.528,
    '40Y':3.31713,
    '50Y':3.11,

}

calculation_date = ql.Date(2,8,2023)
settle_time = 2

ql.Settings.instance().evaluationDate = calculation_date
yts = ql.RelinkableYieldTermStructureHandle()
index = ql.OvernightIndex("USD Overnight Index", 0, ql.USDCurrency(), ql.UnitedStates(ql.UnitedStates.Settlement), ql.Actual360(), yts)

swaps = {}
for x in rates.keys():
    swaps.update(
        {
            ql.Period(x):rates.get(x)/100
        }
    )        

#build helpers
rate_helpers = []
for tenor, rate in swaps.items():
    helper = ql.OISRateHelper(settle_time, tenor, ql.QuoteHandle(ql.SimpleQuote(rate)), index)
    rate_helpers.append(helper)

#build curve based on swap helpers
curve = ql.PiecewiseFlatForward(calculation_date, rate_helpers, ql.Actual360())
curve.enableExtrapolation()
yts.linkTo(curve)
engine = ql.DiscountingSwapEngine(yts)

#used to compare zeros and discount factors back to bloomberg
print("maturity |  market  |  model  |  zero rate  |  discount factor |  present value")

for tenor, rate in swaps.items():
    ois_swap = ql.MakeOIS(tenor, index, rate)
    pv = ois_swap.NPV()
    fair_rate = ois_swap.fairRate()
    maturity_date = ois_swap.maturityDate()
    discount_factor = curve.discount(maturity_date)
    zero_rate = -math.log(discount_factor) * 365.0/(maturity_date-calculation_date)    
    print(f"   {tenor}    | {rate*100:.6f} | {fair_rate*100:.6f} | {zero_rate*100:.6f} | {discount_factor:.6f} | {pv:.6f}")

#create monthly cadence for differrent tenors of forward rates
days = ql.MakeSchedule(curve.referenceDate(), curve.maxDate(), ql.Period('3M'))

dates,list_3mo,list_1mo,list_6mo,list_12mo = [],[],[],[],[]
for d in days:    
    forward_3mo = curve.forwardRate(
        d,
        ql.UnitedStates(ql.UnitedStates.Settlement).advance(d,90,ql.Days),
        ql.Actual365Fixed(),
        ql.Continuous,
    ).rate()
    
    forward_1mo = curve.forwardRate(
        d,
        ql.UnitedStates(ql.UnitedStates.Settlement).advance(d,30,ql.Days),
        ql.Actual365Fixed(),
        ql.Continuous,
    ).rate()
    
    forward_6mo = curve.forwardRate(
        d,
        ql.UnitedStates(ql.UnitedStates.Settlement).advance(d,ql.Period('6M')),
        ql.Actual365Fixed(),
        ql.Continuous,
    ).rate()
    
    forward_12mo = curve.forwardRate(
        d,
        ql.UnitedStates(ql.UnitedStates.Settlement).advance(d,360,ql.Days),
        ql.Actual365Fixed(),
        ql.Continuous,
    ).rate()
    
    dates.append(d)
    list_1mo.append(forward_1mo)
    list_3mo.append(forward_3mo)
    list_6mo.append(forward_6mo)
    list_12mo.append(forward_12mo)

#save output
df = pd.DataFrame(data=zip(dates,list_1mo,list_3mo,list_6mo,list_12mo),columns=('Dates','SOFR_1MO','SOFR_3MO','SOFR_6MO','SOFR_12MO'))
print(df)
df.to_csv('forward_rates.csv')
```

The dates that I return versus the screenshot are off by 2 days and the forward rates / zero rates are slightly off. Can anyone provide any insight into what some of the difference might be? The forward rates shown by bloomberg are continuous | ACT/365.

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.