12. SOFR Yield Curve Construction

[26]:
from fixedincome2025 import table

Overview

  • The SOFR yield curve is by far the most important curve if you work in a bank’s rates business

    • Most OTC products, e.g. swaps, use SOFR as the index rate

  • In this chapter, we study the SOFR yield curve construction

    • As we will see now, it is very different from Treasury yield curve construction

Curve Instruments

  • Unlike Treasury yield curve which is constructed from the Treasuries, there is no SOFR bonds

  • The SOFR yield curve is constructed from

    • Daily SOFR fixing for the day

    • 3M SOFR futures

    • Standard SOFR swaps

  • 3M SOFR futures and standard SOFR swaps are the most liquid rates instruments linked to the SOFR index

Curve Interpolation

  • Numerical settings same as explained in Curve Interpolation

    • Linear interpolation on the time weighted zero rate \(TR(0, T)\) at the short end

    • Cubic spline interpolated \(TR(0, T)\) for the rest of the curve

    • Which implies piecewise constant instantaneous forward rate \(f(0, T)\) at the short end and quadratic spline \(f(0, T)\) for the rest

    • Recall that

      \[f(0, T) = \frac{d}{dT}[TR(0, T)]\]

Market Data

[69]:
table('curve_instruments')
[69]:
Instrument Quote Convexity (bps)
Deposit 3.9700
Futures Sep 25 95.9010 0
Futures Dec 25 96.1825 0.015563
Futures Mar 26 96.3925 0.091519
Futures Jun 26 96.6275 0.207215
Futures Sep 26 96.7875 0.362651
Swap 1y 3.6151
Swap 2y 3.3980
Swap 3y 3.3624
Swap 5y 3.4232
Swap 7y 3.5379
Swap 10y 3.7120
Swap 20y 4.0420
Swap 30y 4.0199

Forward Rates

  • For each futures contract, the corresponding convexity adjustment is quoted, and we first compute forward rates by

    \[\text{Forward Rate} = \text{Futures Rate} - \text{Convexity Adjustment}\]
[59]:
table('forward_rates')
[59]:
Instrument Quote (%)
Futures Sep 25 4.100
Futures Dec 25 3.818
Futures Mar 26 3.608
Futures Jun 26 ?
Futures Sep 26 ?
  • Next we list the dates and compute year fractions

[51]:
table('key_dates')
[51]:
Integer Year Fraction IMM Dates FOMC Meeting Dates From Prev Key Date (Days) From Today (Days) Year Fraction
9/17/2025 9/17/2025 0 -58 -0.1589
10/29/2025 42 -16 -0.0438
11/14/2025 16 0 0.0000
12/10/2025 26 26 0.0712
12/17/2025 7 33 0.0904
1/28/2026 42 75 0.2055
3/18/2026 3/18/2026 49 124 0.3397
4/29/2026 42 166 0.4548
6/17/2026 6/17/2026 49 215 0.5890
7/29/2026 42 257 0.7041
9/16/2026 9/16/2026 49 306 0.8384
10/28/2026 42 348 0.9534
11/14/2026 17 365 1.0000
12/9/2026 25 390 1.0685
12/16/2026 7 397 1.0877
[80]:
import numpy as np
import datetime

df = table('key_dates').iloc[:, :3]
diff = np.diff(sorted([datetime.datetime.strptime(date_str, '%m/%d/%Y') for date_str in np.unique(df.values.flatten()) if date_str != ' ']))
yfs = [(td.days-58)/365 for td in diff.cumsum()]
yfs
[80]:
[-0.043835616438356165,
 0.0,
 0.07123287671232877,
 0.09041095890410959,
 0.2054794520547945,
 0.33972602739726027,
 0.4547945205479452,
 0.589041095890411,
 0.7041095890410959,
 0.8383561643835616,
 0.9534246575342465,
 1.0,
 1.0684931506849316,
 1.0876712328767124]
[82]:
np.diff(yfs)
[82]:
array([0.04383562, 0.07123288, 0.01917808, 0.11506849, 0.13424658,
       0.11506849, 0.13424658, 0.11506849, 0.13424658, 0.11506849,
       0.04657534, 0.06849315, 0.01917808])

\(f(0, T)\) Implied From Futures Sep 2025

  • Recall that the instantaneous forward rate is a piecewise constant function with discontinuities being the FOMC meeting dates

  • In the period \([T, T+\tau]\) = [9/17/2025, 12/17/2025] there is a piecewise constant function with discontinuities at 10/29/2025 and 12/10/2025

  • We know the forward term rate for the reference quarter is \(4.1\%\)

  • With \(T=-0.1589\), \(T+\tau = 0.0904\), \(t=0\in [T, T+\tau]\), we have \begin{align*} 0.041 = L(t, T, T+\tau) &= \frac{1}{\tau}\left(\frac{P(t, T)}{P(t, T+\tau)} - 1\right) = \frac{1}{\tau}\left(\frac{M_t/M_T}{P(t, T+\tau)} - 1\right)\\ &= \frac{1}{\tau}\left(\frac{e^{\int_T^t r_u\,du }}{e^{-\int_t^{T+\tau} f(t, u)\,du}} - 1\right) = \frac{1}{\tau}\left(e^{\int_T^t r_u\,du + \int_t^{T+\tau} f(t, u)\,du} - 1\right)\\ \end{align*}

  • Current overnight rate is \(3.97\%\) and there was a rates cut of 25 bps at 10/29/2025, so \(r_s\) was fixed to be \(4.22\%\) before 10/29/2025 and \(3.97\%\) after

  • \(f(0, u)\) is \(3.97\%\) in the interval \(u\in\) [10/29/2025, 12/17/2025] and unknown after

  • For each date, find the corresponding year fraction

\(f(0, T)\) Implied From Futures Sep 2025 (Cont.)

  • We have \(\tau = (T+\tau) - T = 0.0904 + 0.1589 = 0.2493\) so \begin{align*} 0.041 &= L(0, T, T+\tau) \\ &= \frac{ e^{0.0422\times (-0.0438 -(-0.1589)) + 0.0397\times(0.0712 - (-0.0438)) + f_1\times(0.0904 - 0.0712))} - 1}{0.2493}, \end{align*} which solves to \(f_1 = 3.889\%\)

\(f(0, T)\) Implied From Futures Dec 2025

  • We have \(T=0.0904\), \(T+\tau = 0.3397\) so \(\tau = 0.2493\)

  • Forward rate for the reference quarter is \(3.818\%\)

  • In this reference quarter, there is only one meeting (one discountinuity), and we the first half of the \(f(0, u)\) function is \(3.889\%\), so \begin{align*} 0.03818 = L(t, T, T+\tau) &= \frac{1}{\tau}\left(e^{\int_T^{T+\tau} f(t, u)\,du} - 1\right)\\ &= \frac{e^{ 0.03889\times(0.2055 - 0.0904) + f_2\times(0.3397 - 0.2055)} - 1}{0.2493}, \end{align*} which solves to \(f_2 = 3.723563\%\)

\(f(0, T)\) Implied From Futures Mar 2026

  • We have \(T=0.3397\), \(T+\tau=0.5890\), so \(\tau=0.2493\) (again!)

  • Forward rate for the reference quarter is \(3.608\%\)

  • Both \(T\) and \(T+\tau\) are meeting dates and there is one more in the middle, so \begin{align*} 0.03608 = L(t, T, T+\tau) &= \frac{1}{\tau}\left(e^{\int_T^{T+\tau} f(t, u)\,du} - 1\right)\\ &= \frac{e^{ f_3\times(0.4548 - 0.3397) + f_4\times(0.5890 - 0.4548)} - 1}{0.2493} \end{align*}

  • Two variables with one equation, extra assumption has to be made

\(f(0, T)\) Implied From Futures Mar 2026 (Cont.)

\[\]

\begin{align*} 0.03608 = \frac{e^{ f_3\times(0.4548 - 0.3397) + f_4\times(0.5890 - 0.4548)} - 1}{0.2493} \end{align*}

  • Two variables with one equation, extra assumption has to be made

  • By looking at the forward term rates, we know this part of the curve is still downward sloping: \(3.723563\% = f_2 > f_3 > f_4\)

  • The extra assumption is that the step sizes are the same: \(f_4 - f_3 = f_3 - f_2 = f_3 - 0.03723563\), which leads to \(f_4 = 2f_3 - 0.03723563\) and

    \[0.03608 = \frac{e^{ f_3\times(0.4548 - 0.3397) + (2f_3 - 0.03723563)\times(0.5890 - 0.4548)} - 1}{0.2493},\]

    which solves to \(f_3 = 3.637954\%\) and \(f_4 = 2f_3 - 3.723563\% = 3.552345\%\)

Exercise: \(f(0, T)\) Implied From Futures Jun 2026 and Sep 2026

  • Like the previous reference quarter, we will have two variables with one equation for each futures

  • Using the same assumption

    • What’s \(f_5\) and \(f_6\) for futures Jun 2026?

    • What’s \(f_7\) and \(f_8\) for futures Sep 2026?

Swaps

  • We use the tenor structure \(T_j = j\) for the first few years

    • \(T_0 = 0\) is today 11/14/2025

    • \(T_1 = 1\) is 1 year from today

    • \(T_2 = 2\) is 2 years from today

    • \(\tau_n = T_{n+1} - T_n = 1\)

  • Recall that, we have the \(N\)-year spot starting swap rate formula \begin{align*} S(T_0) = \frac{1 - P(T_0, T_{N})}{A(T_0)},\qquad A(t) = \sum_{n=0}^{N-1}\tau_n P(t, T_{n+1}) \end{align*}

  • In particular, 1 year swap rate is

    \[\frac{1 - P(T_0, T_1)}{\tau_0 P(T_0, T_1)} = \frac{1 - P(0, 1)}{P(0, 1)}\]

\(R(0, T_1)\) Implied From 1Y Swap

\[\]
  • Given the 1y swap rate quote, we have \begin{align*} 0.036151 &= \frac{1 - P(0, 1)}{P(0, 1)}, \end{align*} which solves to

    \[P(0, 1) = 0.96511029763\]

    and

    \[R(0, 1) = -\log P(0, 1) = 3.551289\%\]
  • Recall that \(R(0, T) = -(\log P(0, T))/T\)

\(f(0, T)\) Implied From 1Y Swap

  • But wait, it can also be written as \begin{align*} 0.036151 &= \frac{1 - P(0, 1)}{P(0, 1)}\\ &= \frac{1-e^{-\int_0^1 f(0, u)\,du}}{e^{-\int_0^1 f(0, u)\,du}}\\ \end{align*} which is in terms of \(f_1, f_2, \ldots, f_8\)

  • We should not have used extra assumption when computing \(f_7\) and \(f_8\). Instead, we should use one equation from futures and one from 1y swap to back them out

  • In general, whenever there is a quote, we don’t impose ad hoc assumptions

\(R(0, T_2)\) Implied From 2Y Swap

\[\]
  • Given the 2y swap rate quote, we have \begin{align*} 0.03398 &= \frac{1 - P(0, 2)}{P(0, 1) + P(0, 2)}, \end{align*} but \(P(0, 1) = 0.96511\) is known, so \(P(0, 2)\) can be solved to be

    \[P(0, 2) = 0.93542\]

    and

    \[R(0, 2) = -(\log P(0, 2))/2 = 3.33798\%\]
  • The same process goes on and on until \(R(0, 30y)\) is found

  • We can then compute the entire curve by interpolating \(TR(0, T)\) at the knots

Exercise: \(R(0, T_3)\) Implied From 3Y Swap

  • Given the 3y swap rate quote \(3.3624\%\), find \(R(0, 3)\)

Bump and Revaluate and Delta Ladder

  • Today’s SOFR curve is the base scenario, and the portfolio value computed with today’s SOFR curve is the base PV (present value)

  • Base curve has the quotes: \(3.97\%, \$95.901, \$96.1825, \ldots, 3.6151\%, 3.398\%, \ldots\)

  • Bumped curves: Adding 1 bp to the rate at each bucket, we obtain 14 bumped curves (as there are 14 buckets on our SOFR curve)

    • The first one has quotes \(\underline{3.98\%}, \$95.901, \$96.1825, \ldots, 3.6151\%, 3.398\%, \ldots\)

    • The second one has quotes \(3.97\%, \underline{\$95.891}, \$96.1825, \ldots, 3.6151\%, 3.398\%, \ldots\)

    • The 7th one, corresponding to 1 bp bump on the 1y swap rate, has quotes \(3.97\%, \$95.901, \$96.1825, \ldots, \underline{3.6251\%}, 3.398\%, \ldots\)

    • Note that, in the futures buckets the quotes are prices, not rates, so the 1 bp bump should be subtracted from, not added to, the quotes

  • Revaluate the same portfolio with the 14 bumped curves to obtain 14 PVs

  • These 14 PVs minus the base PV give you 14 PV differences, which form the Delta ladder

[87]:
table('delta_ladder_sofr')
[87]:
Delta
Deposit -185
Futures Sep 25 -231
Futures Dec 25 -303
Futures Mar 26 -275
Futures Jun 26 -690
Futures Sep 26 778
Swap 1y -3739
Swap 2y -2320
Swap 3y 314
Swap 5y -65
Swap 7y 23
Swap 10y -6
Swap 20y 1
Swap 30y 0
Sum -6698

Closed Form Delta Ladder

  • Recall that, the Delta ladder of a ZCB (Treasury) on the Treasury yield curve has one single nonzero element, if the term of the ZCB happens to be a bucket

  • Similarly, a standard SOFR swap’s Delta ladder on the SOFR curve has one single nonzero element, if the term of the Swap happens to be a bucket

  • And the nonzero number is the swap’s PV change corresponding to 1 bp of the swap rate change, which is the swap’s PV01 \(A(T_0)\times 0.0001 \times N\), where \(N\) is the notional

[14]:
# SOFR Yield Curve Construction Timeline: Step Function → Quadratic Spline
import numpy as np
import matplotlib.pyplot as plt
from datetime import datetime
from scipy.interpolate import interp1d

# Define key dates
today = datetime(2025, 11, 14)
imm_dates = [
    datetime(2025, 12, 17),
    datetime(2026, 3, 18),
    datetime(2026, 6, 17),
    datetime(2026, 9, 16),
    datetime(2026, 12, 16)
]

fomc_dates = [
    datetime(2025, 12, 10),
    datetime(2026, 1, 28),
    datetime(2026, 3, 18),
    datetime(2026, 4, 29),
    datetime(2026, 6, 17),
    datetime(2026, 7, 29),
    datetime(2026, 9, 16),
    datetime(2026, 10, 28),
    datetime(2026, 11, 14)
]

# Convert to days from today for plotting
def to_days(dt):
    return (dt - today).days

timeline_dates = [today] + imm_dates
timeline_days = [to_days(d) for d in timeline_dates]
fomc_days = [to_days(d) for d in fomc_dates]

# Create example step function values (hypothetical forward rates in %)
# Start at 4.5%, then step down at each FOMC meeting
step_values = [4.50, 4.40, 4.30, 4.20, 4.10, 4.00, 3.90, 3.85, 3.80]

# Build step function: piecewise constant between FOMC dates
x_step = []
y_step = []
for i, fomc_day in enumerate(fomc_days):
    if i == 0:
        # From today to first FOMC
        x_step.extend([0, fomc_day])
        y_step.extend([step_values[0], step_values[0]])
    else:
        # From previous FOMC to current FOMC
        x_step.extend([fomc_days[i-1], fomc_day])
        y_step.extend([step_values[i], step_values[i]])

# After last FOMC (11/14/2026), transition to quadratic spline
last_fomc_day = fomc_days[-1]
last_step_value = step_values[-1]

# Define a few points beyond last FOMC for quadratic spline (example: extending to 2 years out)
spline_days = [last_fomc_day, last_fomc_day + 90, last_fomc_day + 180, last_fomc_day + 365]
spline_values = [last_step_value, 3.70, 3.60, 3.50]  # hypothetical declining rates

# Create quadratic spline interpolator
spline_func = interp1d(spline_days, spline_values, kind='quadratic', fill_value='extrapolate')
x_spline = np.linspace(last_fomc_day, spline_days[-1], 200)
y_spline = spline_func(x_spline)

# Plot
fig, ax = plt.subplots(figsize=(14, 6))

# Plot step function
ax.plot(x_step, y_step, color='#1f77b4', lw=2, label='Step Function (FOMC-driven forward rates)', drawstyle='steps-post')

# Plot quadratic spline
ax.plot(x_spline, y_spline, color='#ff7f0e', lw=2, label='Quadratic Spline (post last FOMC)')

# Mark today and IMM dates at the BOTTOM of the plot
for day, dt in zip(timeline_days, timeline_dates):
    ax.axvline(day, color='gray', linestyle='--', alpha=0.3, lw=0.8)
    # Place labels at bottom
    label = 'Today' if dt == today else dt.strftime('%m/%d/%y')
    ax.text(day, 2.50, label,
            rotation=45, ha='right', va='top', fontsize=14, color='gray')

# Mark FOMC meeting dates at the TOP of the plot
for fomc_day, fomc_dt in zip(fomc_days, fomc_dates):
    ax.axvline(fomc_day, color='red', linestyle=':', alpha=0.5, lw=1)
    ax.scatter([fomc_day], [step_values[fomc_days.index(fomc_day)]],
               color='red', s=40, zorder=5, marker='o')
    # Place labels at top
    ax.text(fomc_day, 4.60, fomc_dt.strftime('%m/%d/%y'),
            rotation=45, ha='left', va='bottom', fontsize=14, color='red')

# Mark transition point (last FOMC)
ax.scatter([last_fomc_day], [last_step_value], color='green', s=80, zorder=6,
           marker='s', label='Transition to spline')

# Formatting
ax.set_xlabel('Days from Today (11/14/2025)', fontsize=11)
ax.set_ylabel('Forward Rate (%)', fontsize=11)
# ax.set_title('SOFR Yield Curve Construction: FOMC Step Function → Quadratic Spline', fontsize=13, fontweight='bold')
ax.legend(loc='best', fontsize=9)
ax.grid(alpha=0.3, linestyle='--')
ax.set_xlim(-10, spline_days[-1] + 20)
ax.set_ylim(2.45, 4.65)  # Adjust y-limits to accommodate labels at top and bottom

plt.tight_layout()
plt.show()
_images/12_yc_construction_29_0.png