4. 息票剥离法(Bootstrap):原理、算法步骤、Python实现、插值方法
说到收益率曲线构建,我个人最常用的方法就是息票剥离法,也就是Bootstrap。这名字听起来挺唬人,其实说白了就是「把靴子上的鞋带提起来」——从短端到长端,一步步把零息利率给「提」出来。
我记得刚入行那会儿,第一次手动算Bootstrap,算到第三期就卡住了。后来才发现,这方法的核心就一句话:用已知的零息利率,去解未知的零息利率。
4.1 原理:为什么需要Bootstrap?
市场上我们能直接看到的,大多是附息债券的价格。比如一个3年期国债,每年付息一次,最后还本。这种债券的价格里,包含了多个现金流的折现信息。但问题是——每个现金流的折现率都不一样。
你想想看,第1年的现金流,应该用1年期零息利率折现;第2年的用2年期零息利率;第3年的用3年期零息利率。可我们一开始只知道1年期零息利率(因为有1年期零息债),2年期和3年期的都不知道。
Bootstrap的思路就是:从短端开始,一步步往前推。
核心思想:对于附息债券,其价格等于所有未来现金流按对应期限零息利率折现之和。已知短端利率,即可反推长端利率。
4.2 算法步骤:手把手拆解
我习惯把Bootstrap拆成四个步骤,这样不容易乱:
- 整理数据:收集各期限附息债券的市价、票面利率、付息频率。
- 确定短端:如果有1年期以内的零息债(如国库券),直接用它们的收益率作为对应期限的零息利率。
- 逐期求解:从最短的附息债开始,用已知利率解未知利率。
- 插值填充:对于缺失期限(比如1.5年、2.3年),用插值法补齐。
举个例子,假设我们有这样一组数据:
| 期限(年) | 债券类型 | 票面利率 | 市价(面值100) |
|---|---|---|---|
| 0.5 | 零息债 | 0% | 98.0 |
| 1.0 | 零息债 | 0% | 96.0 |
| 1.5 | 附息债 | 4% | 99.5 |
| 2.0 | 附息债 | 5% | 101.0 |
第一步,先算零息债的利率:
- 0.5年期:98.0 = 100 / (1 + r₀.₅/2) → r₀.₅ ≈ 4.08%
- 1.0年期:96.0 = 100 / (1 + r₁.₀/2)² → r₁.₀ ≈ 4.17%
第二步,解1.5年期附息债。它有三个现金流:0.5年付2元,1.0年付2元,1.5年付102元。
公式是:99.5 = 2/(1+r₀.₅/2) + 2/(1+r₁.₀/2)² + 102/(1+r₁.₅/2)³
把已知的r₀.₅和r₁.₀代进去,解方程就能得到r₁.₅。嗯,这里要注意,这是个一元方程,用数值方法(比如牛顿法)就能解。
避坑指南:我曾经在解这个方程时,直接用Excel的Goal Seek,结果因为初始值设得不对,算出来的利率是负的。建议初始值设成前一期利率+0.1%,这样收敛快。
4.3 Python实现:从零到一
下面是我常用的Bootstrap实现代码。我习惯用scipy.optimize里的fsolve来解方程,比手写牛顿法省事多了。
import numpy as np
from scipy.optimize import fsolve
def bootstrap_yield_curve(bonds):
"""
bonds: list of dict, 每个债券包含:
- 'maturity': 期限(年)
- 'coupon': 票面利率(小数)
- 'price': 市价(面值100)
- 'freq': 付息频率(次/年)
返回: dict, 期限->零息利率
"""
# 按期限排序
bonds_sorted = sorted(bonds, key=lambda x: x['maturity'])
# 存储已知利率
rates = {}
for bond in bonds_sorted:
T = bond['maturity']
c = bond['coupon']
P = bond['price']
freq = bond.get('freq', 2) # 默认半年付息
# 如果是零息债,直接算
if c == 0:
r = (100 / P) ** (1 / T) - 1
rates[T] = r
continue
# 附息债:用fsolve解方程
def equation(r):
# 计算所有现金流的折现和
pv = 0
# 生成付息时间点
n_periods = int(T * freq)
for i in range(1, n_periods + 1):
t = i / freq
cf = 100 * c / freq
if i == n_periods:
cf += 100 # 最后一期还本
# 如果该时间点的利率已知,直接用
if t in rates:
pv += cf / (1 + rates[t] / freq) ** (t * freq)
else:
pv += cf / (1 + r / freq) ** (t * freq)
return pv - P
# 初始值设为前一期利率+0.1%
prev_rate = list(rates.values())[-1] if rates else 0.03
r_solution = fsolve(equation, prev_rate + 0.001)[0]
rates[T] = r_solution
return rates
# 示例数据
bonds_data = [
{'maturity': 0.5, 'coupon': 0.0, 'price': 98.0, 'freq': 2},
{'maturity': 1.0, 'coupon': 0.0, 'price': 96.0, 'freq': 2},
{'maturity': 1.5, 'coupon': 0.04, 'price': 99.5, 'freq': 2},
{'maturity': 2.0, 'coupon': 0.05, 'price': 101.0, 'freq': 2},
]
result = bootstrap_yield_curve(bonds_data)
for T, r in sorted(result.items()):
print(f"{T}年: {r*100:.2f}%")
注意:实际市场中,不同债券的付息频率可能不同(半年付、年付、季度付)。代码里我默认了半年付息,但你可以通过freq参数灵活调整。我曾经遇到过一个奇葩债券,每月付息,当时代码没处理好,算出来的利率曲线像心电图一样抖。
4.4 插值方法:让曲线更平滑
Bootstrap算出来的利率,只在有债券的期限上有值。但实际应用中,我们需要任意期限的利率。比如要定价一个1.3年期的衍生品,就得靠插值了。
我个人最常用的两种插值方法:
4.4.1 线性插值
最简单,也最直观。在两个已知点之间画一条直线。
def linear_interpolate(rates, target_T):
"""
rates: dict, 期限->利率
target_T: 目标期限
"""
sorted_T = sorted(rates.keys())
# 边界处理
if target_T <= sorted_T[0]:
return rates[sorted_T[0]]
if target_T >= sorted_T[-1]:
return rates[sorted_T[-1]]
# 找到左右两个点
for i in range(len(sorted_T) - 1):
if sorted_T[i] <= target_T <= sorted_T[i+1]:
T1, T2 = sorted_T[i], sorted_T[i+1]
r1, r2 = rates[T1], rates[T2]
# 线性插值
r = r1 + (r2 - r1) * (target_T - T1) / (T2 - T1)
return r
4.4.2 三次样条插值
线性插值有个问题——在已知点处导数不连续,曲线看起来「有棱有角」。三次样条能保证一阶、二阶导数连续,曲线更平滑。
from scipy.interpolate import CubicSpline
def cubic_spline_interpolate(rates, target_T):
"""
使用三次样条插值
"""
T_array = np.array(sorted(rates.keys()))
r_array = np.array([rates[T] for T in T_array])
# 创建样条函数
cs = CubicSpline(T_array, r_array, bc_type='natural')
return cs(target_T)
经验之谈:我做过一个对比测试,用线性插值和三次样条分别构建曲线,然后定价一个5年期利率互换。结果两者差了2个基点。对于交易台来说,2个基点可能就是一笔不小的盈亏。所以,插值方法的选择,直接影响定价精度。
4.5 知识体系总览
下面这张图,是我自己总结的Bootstrap知识框架。每次带新人时,我都会先让他们看这张图,心里有个全局概念。
说实话,Bootstrap这个方法,看起来简单,但实际用起来坑不少。比如市场数据不完整、不同债券的流动性差异、节假日调整等等。我建议你先把上面的代码跑一遍,理解核心逻辑,然后再去处理真实市场数据。
嗯,今天就先聊到这儿。记住一句话:Bootstrap不是终点,而是起点——有了零息利率曲线,后面才能做各种利率产品的定价和对冲。