Skip to content

Multi-Curve Construction

Background

Construction of multi-curve discount and forward curves from swap instruments.

Based on "Financial Engineering" course by L.A. Grzelak. The course is based on the book "Mathematical Modeling and Computation in Finance: With Exercises and Python and MATLAB Computer Codes", by C.W. Oosterlee and L.A. Grzelak, World Scientific Publishing Europe Ltd, 2019. @author: Lech A. Grzelak


Code

```python """ Construction of multi-curve discount and forward curves from swap instruments.

Based on "Financial Engineering" course by L.A. Grzelak. The course is based on the book "Mathematical Modeling and Computation in Finance: With Exercises and Python and MATLAB Computer Codes", by C.W. Oosterlee and L.A. Grzelak, World Scientific Publishing Europe Ltd, 2019. @author: Lech A. Grzelak """ import enum import numpy as np import matplotlib.pyplot as plt from copy import deepcopy from scipy.interpolate import splrep, splev, interp1d

======================================================================

Functions / Classes

======================================================================

class OptionTypeSwap(enum.Enum): """Swap option type enumeration.""" RECEIVER = 1.0 PAYER = -1.0

def ir_swap(option_type, notional, strike, t, t_i, t_m, n, p0t): """ Compute interest rate swap value.

Parameters
----------
option_type : OptionTypeSwap
    PAYER or RECEIVER
notional : float
    Notional amount
strike : float
    Strike rate
t : float
    Current time
t_i : float
    Swap start time
t_m : float
    Swap end time
n : int
    Number of payment dates
p0t : callable
    Zero-coupon bond pricing function

Returns
-------
float
    Swap value
"""
ti_grid = np.linspace(t_i, t_m, int(n))
tau = ti_grid[1] - ti_grid[0]

temp = 0.0
for idx, ti in enumerate(ti_grid):
    if idx > 0:
        temp = temp + tau * p0t(ti)

p_t_ti = p0t(t_i)
p_t_tm = p0t(t_m)

if option_type == OptionTypeSwap.PAYER:
    swap = (p_t_ti - p_t_tm) - strike * temp
elif option_type == OptionTypeSwap.RECEIVER:
    swap = strike * temp - (p_t_ti - p_t_tm)

return swap * notional

def ir_swap_multi_curve(option_type, notional, strike, t, t_i, t_m, n, p0t, p0t_frd): """ Compute interest rate swap value using multi-curve framework.

Parameters
----------
option_type : OptionTypeSwap
    PAYER or RECEIVER
notional : float
    Notional amount
strike : float
    Strike rate
t : float
    Current time
t_i : float
    Swap start time
t_m : float
    Swap end time
n : int
    Number of payment dates
p0t : callable
    Discount curve (zero-coupon bond pricing function)
p0t_frd : callable
    Forward curve (zero-coupon bond pricing function)

Returns
-------
float
    Swap value
"""
ti_grid = np.linspace(t_i, t_m, int(n))
tau = ti_grid[1] - ti_grid[0]

swap = 0.0
for idx, ti in enumerate(ti_grid):
    # L(t_0, t_{k-1}, t_k) from forward curve
    if idx > 0:
        l_frwd = (1.0 / tau * (p0t_frd(ti_grid[idx - 1]) -
                               p0t_frd(ti_grid[idx])) /
                  p0t_frd(ti_grid[idx]))
        swap = swap + tau * p0t(ti_grid[idx]) * (l_frwd - strike)

return swap * notional

def p0t_model(t, ti, ri, method): """ Compute zero-coupon bond price using interpolation.

Parameters
----------
t : float or array
    Time point(s)
ti : array
    Interpolation nodes (times)
ri : array
    Interpolation values (rates)
method : callable
    Interpolation method

Returns
-------
float or array
    Bond price P(0,t)
"""
r_interp = method(ti, ri)
r = r_interp(t)
return np.exp(-r * t)

def yield_curve(instruments, maturities, r0, method, tol): """ Compute yield curve from instrument prices.

Parameters
----------
instruments : list of callable
    List of instrument pricing functions
maturities : array
    Maturity points
r0 : array
    Initial rate guess
method : callable
    Interpolation method
tol : float
    Convergence tolerance

Returns
-------
array
    Optimal rates at maturities
"""
r0 = deepcopy(r0)
ri = multivariate_newton_raphson(r0, maturities, instruments, method,
                                 tol=tol)
return ri

def multivariate_newton_raphson(ri, ti, instruments, method, tol): """ Multi-dimensional Newton-Raphson solver.

Parameters
----------
ri : array
    Initial rate guess
ti : array
    Time nodes
instruments : list of callable
    Instrument pricing functions
method : callable
    Interpolation method
tol : float
    Convergence tolerance

Returns
-------
array
    Converged rates
"""
err = 10e10
idx = 0
while err > tol:
    idx = idx + 1
    values = evaluate_instruments(ti, ri, instruments, method)
    j = jacobian(ti, ri, instruments, method)
    j_inv = np.linalg.inv(j)
    err = -np.dot(j_inv, values)
    ri[0:] = ri[0:] + err
    err = np.linalg.norm(err)
    print('index in the loop is', idx, ' Error is ', err)
return ri

def jacobian(ti, ri, instruments, method): """ Compute Jacobian matrix for Newton-Raphson.

Parameters
----------
ti : array
    Time nodes
ri : array
    Current rate estimate
instruments : list of callable
    Instrument pricing functions
method : callable
    Interpolation method

Returns
-------
ndarray
    (n_instruments, n_instruments) Jacobian matrix
"""
eps = 1e-05
swap_num = len(ti)
j = np.zeros((swap_num, swap_num))
val = evaluate_instruments(ti, ri, instruments, method)
ri_up = deepcopy(ri)

for j_idx in range(0, len(ri)):
    ri_up[j_idx] = ri[j_idx] + eps
    val_up = evaluate_instruments(ti, ri_up, instruments, method)
    ri_up[j_idx] = ri[j_idx]
    dv = (val_up - val) / eps
    j[:, j_idx] = dv[:]
return j

def evaluate_instruments(ti, ri, instruments, method): """ Evaluate all instruments at given rates.

Parameters
----------
ti : array
    Time nodes
ri : array
    Rates at nodes
instruments : list of callable
    Instrument pricing functions
method : callable
    Interpolation method

Returns
-------
array
    Instrument values
"""
p0t_temp = lambda t: p0t_model(t, ti, ri, method)
val = np.zeros(len(instruments))
for i in range(0, len(instruments)):
    val[i] = instruments[i](p0t_temp)
return val

def linear_interpolation(ti, ri): """ Linear interpolation function.

Parameters
----------
ti : array
    Interpolation nodes
ri : array
    Interpolation values

Returns
-------
callable
    Interpolation function
"""
interpolator = lambda t: np.interp(t, ti, ri)
return interpolator

def spline_interpolate(ti, ri): """ Spline interpolation function.

Parameters
----------
ti : array
    Interpolation nodes
ri : array
    Interpolation values

Returns
-------
callable
    Interpolation function
"""
interpolator = splrep(ti, ri, s=0.01)
interp = lambda t: splev(t, interpolator)
return interp

def scipy_1d_interpolate(ti, ri): """ Scipy 1D quadratic interpolation.

Parameters
----------
ti : array
    Interpolation nodes
ri : array
    Interpolation values

Returns
-------
callable
    Interpolation function
"""
interpolator = lambda t: interp1d(ti, ri, kind='quadratic')(t)
return interpolator

def plot_curves(t, p0t_discount, p0t_frwd): """ Plot discount and forward curves.

Parameters
----------
t : array
    Time points
p0t_discount : callable
    Discount curve function
p0t_frwd : callable
    Forward curve function
"""
plt.figure()
plt.plot(t, p0t_discount(t), '--r')
plt.plot(t, p0t_frwd(t), '-b')
plt.legend(['discount', 'forecast'])

def main(): """Run multi-curve yield curve construction.""" # ============= Parameters ============= tol = 1.0e-15 r0 = np.array([0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01]) method = linear_interpolation

# Discount curve instruments
k = np.array([0.04 / 100.0, 0.16 / 100.0, 0.31 / 100.0, 0.81 / 100.0,
              1.28 / 100.0, 1.62 / 100.0, 2.22 / 100.0, 2.30 / 100.0])
mat = np.array([1.0, 2.0, 3.0, 5.0, 7.0, 10.0, 20.0, 30.0])

# ============= Build Discount Curve =============
swap1 = lambda p0t: ir_swap(OptionTypeSwap.PAYER, 1, k[0], 0.0, 0.0,
                             mat[0], 4 * mat[0], p0t)
swap2 = lambda p0t: ir_swap(OptionTypeSwap.PAYER, 1, k[1], 0.0, 0.0,
                             mat[1], 5 * mat[1], p0t)
swap3 = lambda p0t: ir_swap(OptionTypeSwap.PAYER, 1, k[2], 0.0, 0.0,
                             mat[2], 6 * mat[2], p0t)
swap4 = lambda p0t: ir_swap(OptionTypeSwap.PAYER, 1, k[3], 0.0, 0.0,
                             mat[3], 7 * mat[3], p0t)
swap5 = lambda p0t: ir_swap(OptionTypeSwap.PAYER, 1, k[4], 0.0, 0.0,
                             mat[4], 8 * mat[4], p0t)
swap6 = lambda p0t: ir_swap(OptionTypeSwap.PAYER, 1, k[5], 0.0, 0.0,
                             mat[5], 9 * mat[5], p0t)
swap7 = lambda p0t: ir_swap(OptionTypeSwap.PAYER, 1, k[6], 0.0, 0.0,
                             mat[6], 10 * mat[6], p0t)
swap8 = lambda p0t: ir_swap(OptionTypeSwap.PAYER, 1, k[7], 0.0, 0.0,
                             mat[7], 11 * mat[7], p0t)
instruments = [swap1, swap2, swap3, swap4, swap5, swap6, swap7, swap8]

ri = yield_curve(instruments, mat, r0, method, tol)
print('\n Spine points are', ri, '\n')

p0t_initial = lambda t: p0t_model(t, mat, r0, method)
p0t = lambda t: p0t_model(t, mat, ri, method)

# Price back swaps
swaps_model = np.zeros(len(instruments))
swaps_initial = np.zeros(len(instruments))
for i in range(0, len(instruments)):
    swaps_model[i] = instruments[i](p0t)
    swaps_initial[i] = instruments[i](p0t_initial)

print('Prices for Par Swaps (initial) = ', swaps_initial, '\n')
print('Prices for Par Swaps = ', swaps_model, '\n')

# ============= Multi-curve Extension =============
p0t_frd = deepcopy(p0t)
k_test = 0.2
swap1_test = lambda p0t_arg: ir_swap(OptionTypeSwap.PAYER, 1, k_test, 0.0,
                                      0.0, mat[0], 4 * mat[0], p0t_arg)
swap1_mc = lambda p0t_arg: ir_swap_multi_curve(
    OptionTypeSwap.PAYER, 1, k_test, 0.0, 0.0, mat[0], 4 * mat[0],
    p0t_arg, p0t_frd)
print('Sanity check: swap1 = {0}, swap2 = {1}'.format(
    swap1_test(p0t), swap1_mc(p0t)))

# ============= Forward Curve Instruments =============
r0_frwd = np.array([0.01, 0.01, 0.01, 0.01])
k_frwd = np.array([0.09 / 100.0, 0.26 / 100.0, 0.37 / 100.0, 1.91 / 100.0])
mat_frwd = np.array([1.0, 2.0, 3.0, 5.0])

p0t_discount = lambda t: p0t_model(t, mat, ri, method)
swap1_frwd = lambda p0t_frwd_arg: ir_swap_multi_curve(
    OptionTypeSwap.PAYER, 1, k_frwd[0], 0.0, 0.0, mat_frwd[0],
    4 * mat_frwd[0], p0t_discount, p0t_frwd_arg)
swap2_frwd = lambda p0t_frwd_arg: ir_swap_multi_curve(
    OptionTypeSwap.PAYER, 1, k_frwd[1], 0.0, 0.0, mat_frwd[1],
    5 * mat_frwd[1], p0t_discount, p0t_frwd_arg)
swap3_frwd = lambda p0t_frwd_arg: ir_swap_multi_curve(
    OptionTypeSwap.PAYER, 1, k_frwd[2], 0.0, 0.0, mat_frwd[2],
    6 * mat_frwd[2], p0t_discount, p0t_frwd_arg)
swap4_frwd = lambda p0t_frwd_arg: ir_swap_multi_curve(
    OptionTypeSwap.PAYER, 1, k_frwd[3], 0.0, 0.0, mat_frwd[3],
    7 * mat_frwd[3], p0t_discount, p0t_frwd_arg)

instruments_frwd = [swap1_frwd, swap2_frwd, swap3_frwd, swap4_frwd]

# ============= Solve Forward Curve =============
ri_frwd = yield_curve(instruments_frwd, mat_frwd, r0_frwd, method, tol)
print('\n Frwd Spine points are', ri_frwd, '\n')

p0t_frwd_initial = lambda t: p0t_model(t, mat_frwd, r0_frwd, method)
p0t_frwd = lambda t: p0t_model(t, mat_frwd, ri_frwd, method)

# Price forward curve swaps
swaps_model_frwd = np.zeros(len(instruments_frwd))
swaps_initial_frwd = np.zeros(len(instruments_frwd))

for i in range(0, len(instruments_frwd)):
    swaps_model_frwd[i] = instruments_frwd[i](p0t_frwd)
    swaps_initial_frwd[i] = instruments_frwd[i](p0t_frwd_initial)

print('Prices for Par Swaps (initial) = ', swaps_initial_frwd, '\n')
print('Prices for Par Swaps = ', swaps_model_frwd, '\n')

print(swap1_frwd(p0t_frwd))

# ============= Plotting =============
t = np.linspace(0, 10, 100)
plot_curves(t, p0t_discount, p0t_frwd)

======================================================================

Main

======================================================================

if name == "main": main() ```

Exercises

Exercise 1. In a single-curve framework, the discount curve and the forward curve are identical. Explain why the multi-curve framework emerged after the 2008 financial crisis and what its key feature is.

Solution to Exercise 1

Before 2008, OIS and LIBOR rates were nearly identical, so a single curve was used for both discounting and forward rate projection. After the crisis, the OIS-LIBOR spread (basis) widened significantly (from near zero to over 300 bps at peak), reflecting credit risk in LIBOR. The multi-curve framework uses separate curves:

  • Discount curve: Built from OIS instruments (reflecting the risk-free rate for collateralized derivatives).
  • Forward curves: Built from LIBOR-based instruments (swaps, FRAs) at each tenor (3M, 6M, etc.).

The key feature is that forward rates are projected from the LIBOR curve but cash flows are discounted using the OIS curve, leading to different curves for different purposes.


Exercise 2. The Newton-Raphson solver finds rates \(r_i\) such that all par swap values are zero. Write the convergence criterion and explain why a Jacobian matrix is needed.

Solution to Exercise 2

The convergence criterion is \(\|\mathbf{r}^{(k+1)} - \mathbf{r}^{(k)}\| < \text{tol}\), where the update is

\[ \mathbf{r}^{(k+1)} = \mathbf{r}^{(k)} - J^{-1}\,\mathbf{f}(\mathbf{r}^{(k)}), \]

and \(\mathbf{f}(\mathbf{r})\) is the vector of swap values. The Jacobian \(J_{ij} = \partial f_i / \partial r_j\) is needed because the system is multivariate: each swap price depends on all rate spine points (through the interpolated yield curve). A change in \(r_j\) at maturity \(T_j\) affects the discount factors at all maturities, impacting all swap prices. The Jacobian captures these cross-dependencies and enables simultaneous solution.


Exercise 3. If the discount curve gives \(P_d(0,5) = 0.98\) and the forward curve gives \(P_f(0,5) = 0.975\), compute the 5-year basis spread.

Solution to Exercise 3

The zero rates are \(y_d = -\ln(0.98)/5 = 0.00404\) and \(y_f = -\ln(0.975)/5 = 0.00507\). The basis spread is

\[ \text{basis} = y_f - y_d = 0.00507 - 0.00404 = 0.00103 = 10.3 \text{ bps}. \]

The forward curve lies above the discount curve, reflecting the credit premium embedded in LIBOR relative to OIS.


Exercise 4. Compare linear interpolation and cubic interpolation for yield curve construction. What are the trade-offs in terms of smoothness and accuracy?

Solution to Exercise 4
  • Linear interpolation: Simple, stable, and always produces monotonic segments between nodes. However, the interpolated curve has discontinuous first derivatives at nodes, leading to jumpy forward rates (the forward rate curve shows "saw-tooth" patterns).
  • Cubic interpolation: Produces smooth curves with continuous first and second derivatives, yielding well-behaved forward rates. However, it can overshoot between nodes (producing negative forward rates or discount factors greater than 1) and is more sensitive to the placement of nodes.

For risk management (Greeks computation), smooth curves are preferred to avoid artificial hedging artifacts. For simple pricing, linear interpolation is often sufficient and more robust.