Bootstrapping OIS Zero Rates for Longer Coupon-Bearing Instruments
Summary
The document examines how to reproduce longer-maturity continuously compounded zero rates from par OIS rates in a textbook example. Short maturities can be converted from their quoted compounding conventions directly, but the two-year and five-year instruments have quarterly payments. Their zero rates must therefore be consistent with the present value of all coupon and principal cash flows equaling par, rather than being obtained by converting a single quoted rate.
The post compares textbook values with a QuantLib-based curve and a manual root solve. The manual calculation produces a noticeably different two-year result, and the author suspects that assigning one zero rate to several intermediate coupon dates is inadequate. No answer resolving the discrepancy is included. The listed rates and output demonstrate the mismatch, but calendar, day-count, payment-date, and curve-interpolation conventions may also affect comparisons, so the example does not isolate the cause.
Key ideas
- Short-maturity OIS rates can be converted using their stated compounding frequency.
- Longer coupon-bearing instruments require discounting each cash flow when bootstrapping a zero curve.
- The document’s manual two-year solve differs from both the textbook figure and the curve-library output.
- The post raises the issue but does not provide a resolution or identify all convention differences.
Tags
Full text
# Matching OIS Bootstrapped Zero Rates from Hull
# Matching OIS Bootstrapped Zero Rates from Hull
There's an example in Hull's Option, Futures and other Derivatives of bootstrapping an OIS driven discount curve - and the following zero rates are given. Section 7.2.
| OIS maturity | OIS rate | Compounding frequency for OIS rate | Zero rate (cont. comp.) |
| 1 month | 1.8% | Monthly | 1.7987% |
| 3 months | 2.0% | Quarterly | 1.9950% |
| 6 months | 2.2% | Semiannually | 2.1880% |
| 12 months | 2.5% | Annually | 2.4693% |
| 2 years | 3.0% | Quarterly | 2.9944% |
| 5 years | 4.0% | Quarterly | 4.0401% |
The 1-12M points are fairly trivial to match, but the 2Y and 5Y points, because of the Quarterly coupons are trickier - I can't get a close match.
Hull offers this:
> In this example, the two-year and five-year zero rates would be chosen using an iterative search procedure (such as Solver in Excel) so that they are consistent with the following: • A two-year bond making quarterly interest payments at 3% per annum is worth par. • A five-year bond making quarterly interest payments at 4% per annum is worth par.
If I use QuantLib (or ORE which contains QuantLib) I can get a reasonable match on the 2Y point, but I'm keen understand how Hull's methodology arrives at the 2Y zero rate (and by extension the 5Y zero rate).
I have the code below which shows the output - my use of Brent solver for the 2Y is obviously wrong - can anyone offer any tips on how to get it to match Hull and/or Quantlib?
```
import numpy as np
from scipy.optimize import brentq # Brent's method for root finding
import ORE # this is ORE's build of QuantLib
# Constants from Hull's Table 7.3
ois_swap_rates = {
'1M': 0.018, # 1.8%
'3M': 0.020, # 2.0%
'6M': 0.022, # 2.2%
'12M': 0.025, # 2.5%
'2Y': 0.030, # 3.0%
}
# Compounding frequencies for OIS rates
compounding_frequencies = {
'1M': 12, # Monthly
'3M': 4, # Quarterly
'6M': 2, # Semiannual
'12M': 1, # Annual
'2Y': 4, # Quarterly
}
# Periods for each tenor in years
periods = {
'1M': 1 / 12,
'3M': 0.25,
'6M': 0.5,
'12M': 1.0,
'2Y': 2.0,
}
# Hull's zero rates for comparison (from Table 7.3, expressed as decimals)
hull_zero_rates = {
'1M': 0.017987,
'3M': 0.019950,
'6M': 0.021880,
'12M': 0.024693,
'2Y': 0.029944,
}
# Helper function to convert an OIS rate to continuous compounding
def convert_to_continuous(ois_rate, compounding_freq):
return compounding_freq * np.log(1 + ois_rate / compounding_freq)
# Helper function to calculate discount factor from zero rate
def calc_discount_factor(zero_rate, time):
return np.exp(-zero_rate * time)
# Define ORE's payment frequency mapping function
def get_payment_frequency(tenor_str):
if tenor_str == "1M":
return ORE.Monthly
elif tenor_str == "3M":
return ORE.Quarterly
elif tenor_str == "6M":
return ORE.Semiannual
elif tenor_str == "12M":
return ORE.Annual
elif tenor_str == "2Y":
return ORE.Quarterly # For tenors greater than 1 year, use Quarterly as per Hull
else:
raise ValueError(f"Unknown tenor: {tenor_str}")
# Function to build the ORE discount curve using given OIS swap rates
def build_ore_discount_curve(ois_par_rates):
# Initialize QuantLib's settings
ORE.Settings.instance().evaluationDate = ORE.Date(1, 1, 2024) # Use any placeholder date
calendar = ORE.NullCalendar() # Simplify by using a NullCalendar
# Define the day count convention for SOFR: Actual/360 is standard in the USD market - but it's a textbook problem so keep it simple!
day_count = ORE.SimpleDayCounter()
# Define the settlement days (number of days between trade date and settlement) - again usually 2, but for a textbook example keep it simple:
settlement_days = 0
# Initialize rate helpers array to store all rate helpers for curve construction
rate_helpers = []
# Use a simplified SOFR-like index
sofr_index = ORE.OvernightIndex(
"HullSOFR", # familyName
settlement_days, # fixingDays
ORE.USDCurrency(),
calendar,
day_count,
)
# Add OIS swap rate helpers for bootstrapping the curve from 1M to 2Y
for tenor_str, rate in ois_par_rates.items():
payment_frequency = get_payment_frequency(tenor_str)
# Create the OIS rate helper for each tenor
ois_helper = ORE.OISRateHelper(
settlement_days,
ORE.Period(tenor_str), # Use string period directly
ORE.QuoteHandle(ORE.SimpleQuote(rate)), # fixingRate
sofr_index,
paymentFrequency=payment_frequency,
paymentLag=0,
paymentConvention=ORE.Unadjusted,
forwardStart=ORE.Period(0, ORE.Days),
)
rate_helpers.append(ois_helper)
# Build the piecewise yield curve for discounting using the rate helpers
sofr_curve = ORE.PiecewiseLogLinearDiscount(
ORE.Settings.instance().evaluationDate, rate_helpers, day_count
)
# Return the curve handle
return ORE.YieldTermStructureHandle(sofr_curve)
# Step 1 to 4: Calculate zero rates and discount factors for bullet swaps (up to 12M)
discount_factors = {}
zero_rates = {}
print("Hull Comparison vs Calculated Values and ORE Zero Rates:\n")
print(f"{'Tenor':<5} {'Hull Zero Rate (%)':<20} {'Calc Zero Rate (%)':<20} {'ORE Zero Rate (%)':<20} {'Calc DF':<10}")
# Calculate zero rates and discount factors for bullet swaps (1M, 3M, 6M, 12M)
for tenor in ['1M', '3M', '6M', '12M']:
period = periods[tenor]
ois_rate = ois_swap_rates[tenor]
compounding_freq = compounding_frequencies[tenor]
# Convert OIS rate to continuous compounding
zero_rate_cc = convert_to_continuous(ois_rate, compounding_freq)
df = calc_discount_factor(zero_rate_cc, period)
zero_rates[tenor] = zero_rate_cc
discount_factors[tenor] = df
# Construct the ORE discount curve
discount_curve_handle = build_ore_discount_curve(ois_swap_rates)
# Print comparisons for tenors
for tenor in ['1M', '3M', '6M', '12M']:
period = periods[tenor]
ore_zero_rate = discount_curve_handle.zeroRate(period, ORE.Continuous).rate() * 100 # ORE zero rate in %
print(f"{tenor:<5} {round(hull_zero_rates[tenor] * 100, 4):<20} "
f"{round(zero_rates[tenor] * 100, 4):<20} {round(ore_zero_rate, 4):<20} {round(discount_factors[tenor], 5):<10}")
# Step 5: Calculate zero rate for the 2Y swap using iterative solving
# Known discount factors up to 1Y from bullet swaps
known_dfs = {
'1M': discount_factors['1M'],
'3M': discount_factors['3M'],
'6M': discount_factors['6M'],
'12M': discount_factors['12M'],
}
# Constants for payment calculations for the 2Y swap
ois_rate_2y = ois_swap_rates['2Y']
compounding_freq_2y = compounding_frequencies['2Y']
payment_frequency = 0.25 # Quarterly payments for 2Y swap
payment_per_quarter = ois_rate_2y * payment_frequency
# Function to calculate the NPV of the 2Y swap (aiming for NPV = 0)
def npv_2y_zero_rate(r_2y_cc):
npv = (payment_per_quarter * known_dfs['3M'] +
payment_per_quarter * known_dfs['6M'] +
payment_per_quarter * known_dfs['12M'] +
payment_per_quarter * calc_discount_factor(r_2y_cc, 1.25) +
payment_per_quarter * calc_discount_factor(r_2y_cc, 1.5) +
payment_per_quarter * calc_discount_factor(r_2y_cc, 1.75) +
(1 + payment_per_quarter) * calc_discount_factor(r_2y_cc, periods['2Y']))
return npv - 1 # aiming for a par swap (NPV = 0)
# Use Brent's method to solve for the 2Y zero rate (more robust than Newton-Raphson)
r_2y_cc_solution = brentq(npv_2y_zero_rate, 0.01, 0.05) # Bracketed range around the expected solution
# Calculate the discount factor for 2Y using the solved zero rate
df_2y = calc_discount_factor(r_2y_cc_solution, periods['2Y'])
# Store results
zero_rates['2Y'] = r_2y_cc_solution
discount_factors['2Y'] = df_2y
# ORE-based zero rate for 2Y
ore_zero_rate_2y = discount_curve_handle.zeroRate(periods['2Y'], ORE.Continuous).rate() * 100
# Print results for 2Y, rounding appropriately
print(f"{'2Y':<5} {round(hull_zero_rates['2Y'] * 100, 4):<20} "
f"{round(r_2y_cc_solution * 100, 4):<20} {round(ore_zero_rate_2y, 4):<20} {round(df_2y, 5):<10}")
]
```
The code generates the following results - where we have no coupon all 3 calcs agree:
```
Hull Comparison vs Calculated Values and ORE Zero Rates:
Tenor Hull Zero Rate (%) Calc Zero Rate (%) ORE Zero Rate (%) Calc DF
1M 1.7987 1.7987 1.7987 0.9985
3M 1.995 1.995 1.995 0.99502
6M 2.188 2.188 2.188 0.98912
12M 2.4693 2.4693 2.4693 0.97561
2Y 2.9944 2.6147 2.998 0.94905
```
But the 2Y manually calculated zero rate of 2.6147% is way off the other 2Y zero rates. Any insight into how to rectify this would be much appreicated!
I suspect my naive use of the root solver to find a single 2Y zero rate that solves for all 4 missing coupon rates from 1Y3M, 1Y6M, 1Y9M, and 2Y is wrong, but I'm a bit lost of what alternatives might match the market convention?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.