ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Python提取即期利率曲线:票息剥离、插值与债券收益率引擎

Python提取即期利率曲线:票息剥离、插值与债券收益率引擎

手上的活是从一张对不上账的估值表开始的。两条现金流,票息、期限、付息频率都核对过三遍,报价也一致,Excel 拉出来的到期收益率只差 0.3 个基点,可把这两条现金流折回今天,两个现值差了一万出头。查了两个下午,问题不在数字,在口径:有人把单只债券的到期收益率当成了所有期限的折现率。这正是即期利率这件事最容易被略过的地方,它是逐期限铺开的一条曲线,不是某只债券身上贴着的一个标签。这篇就把 Python 里怎么从市场报价里把即期利率剥出来、怎么用它去算收益率,连同我搭过的几套小工具一起拆开讲。内容偏实操,默认你懂一点债券基础,但完全没写过相关代码也能跟下来。前两年我做过一个内部的小估值工具,输入一列债券报价,输出整张表的收益率、久期、凸性,当时踩过的坑基本都在下面了。

1. 先把折现口径钉死:即期利率是一套约定,不是一个数字

在写第一行代码之前,有两件事必须先定死,否则后面所有计算都是在错误的基数上做乘除。这两件事一件是复利方式,一件是计息天数规则。它们听起来像教科书里的边角料,实际上是所有对不上的账的源头。

1.1 即期利率、到期收益率、远期利率三者的分工

即期利率 z(t) 的定义很朴素:今天把钱锁进去,到期一次性拿回本息,这段期限内的年化收益率。它对应的金融工具是零息债——中间没有一分钱现金流,所以不存在再投资假设,也就没有歧义。折现因子和它的关系是 DF(t) = 1 / (1 + z(t))^t(年复利口径下),这个式子是整个体系的基石。

到期收益率 y 完全是另一回事。它是让一只付息债的全部现金流用同一个折现率折现后恰好等于市场价格的内部收益率。注意"同一个"这三个字——它隐含了一个非常强的假设:利率期限结构是水平的。当真实的曲线是上斜的时候,这个假设必然带来定价误差,而且误差方向是可以预判的:短端现金流被过度折价,长端被过度溢价。

远期利率 f(t1, t2) 则是从两个即期利率反推出来的:从今天到 t1 锁定,再从 t1 滚到 t2,这两段合起来的收益应该等于直接锁到 t2。写成式子就是 (1 + z(t2))^t2 = (1 + z(t1))^t1 × (1 + f(t1,t2))^(t2−t1)。远期利率本身不能直接交易,但它是检验即期曲线是否合理的第一道关卡——如果反推出来的瞬时远期利率是负的,曲线八成出问题了。

拿组真实的数字感受一下差异。假设即期曲线是 1 年 3%、2 年 4%、3 年 5%(年复利),现在有一只 3 年期、票息 6%、年付、面值 100 的债券。按即期曲线逐笔折现:

  • DF(1) = 1/1.03 = 0.970874
  • DF(2) = 1/1.04² = 0.924556
  • DF(3) = 1/1.05³ = 0.863838
  • 全价 = 6×0.970874 + 6×0.924556 + 106×0.863838 ≈ 102.94

反过来解它的到期收益率,得到 y ≈ 4.92%。用 4.92% 折第一年的 6 元票息,现值是 5.718 元;用真实的即期利率 3% 折,是 5.825 元,差了 0.107 元。单看一百块面值,这点差像是可以忽略,乘上一千手、再叠加不同期限结构上的系统性偏移,就变成了开头那笔一万块的差额。所以第一条经验是:即期利率用于折现和定价,到期收益率用于横向比较和快速报价,两者不能互相替代。

1.2 复利方式和计息天数:两个乘数决定结果的量级

复利方式的选择直接影响折现因子的形状。常见的四种口径,转换关系必须记牢:

口径折现因子转换到年复利
年复利DF = 1/(1+z)^t—
半年复利DF = 1/(1+z/2)^(2t)z_ann = (1+z/2)² − 1
连续复利DF = e^(−zt)z_ann = e^z − 1
单利(短端)DF = 1/(1+z·t)近似,只适合 t < 1

连续复利在数学上最顺手——取对数、求和、线性回归都方便,做曲线拟合时我基本都先转成连续口径。但市场报价不是连续复利,绝大多数债券报价用的是半年复利或者年复利,短端贴现工具经常用单利。中间这层转换如果漏掉,结果会出现几个甚至几十个基点的偏差,而且这个偏差在长端会被时间指数放大。

计息天数规则是第二个乘数。t 到底等于"实际天数除以 365"还是"实际天数除以 360"还是"30/360",会让同一个报价算出不同的年化利率。实际操作里的惯例大致是:短端贴现类工具用 ACT/360 的居多,中长期付息债用 ACT/ACT 或者 30/360 的居多。这里没有什么普适真理,唯一可靠的做法是建立曲线时先用一个口径统一所有输入,输出时再统一换算成你要展示的口径,绝不允许半路混着用。

一个非常具体的教训:早期我做批量估值时,把两张表的日期差直接用(d2 - d1).days算完就当 t 用了,结果短端贴现券全部按 ACT/365 处理,而报价端是 ACT/360,三个月期的品种整体偏了将近 0.14 个百分点。这种错误不会报错,只会安静地错。

1.3 用 Python 把口径转换封装成不可绕过的函数

既然口径是最大的错误源,最省心的办法是把它包成函数,让所有计算路径强制走同一扇门。我习惯写成这样一组工具,只依赖 numpy:

import numpy as np def df_from_rate(z, t, comp="annual"): """由年化利率和年限算折现因子""" z = np.asarray(z, dtype=float) t = np.asarray(t, dtype=float) if comp == "annual": return 1.0 / (1.0 + z) ** t if comp == "semi": return 1.0 / (1.0 + z / 2.0) ** (2.0 * t) if comp == "cont": return np.exp(-z * t) if comp == "simple": return 1.0 / (1.0 + z * t) raise ValueError(f"未知复利口径: {comp}") def rate_from_df(df, t, comp="annual"): """由折现因子反推年化利率""" df = np.asarray(df, dtype=float) t = np.asarray(t, dtype=float) if np.any(df <= 0): raise ValueError("折现因子必须为正") if comp == "annual": return df ** (-1.0 / t) - 1.0 if comp == "semi": return 2.0 * (df ** (-1.0 / (2.0 * t)) - 1.0) if comp == "cont": return -np.log(df) / t if comp == "simple": return (1.0 / df - 1.0) / t raise ValueError(f"未知复利口径: {comp}") def convert(z, t, src="semi", dst="cont"): return rate_from_df(df_from_rate(z, t, src), t, dst)

写完立刻做一次往返测试,这是最廉价的保险:给定一组 t 从 0.25 到 30 的网格、z 从 0.5% 到 8% 的取值,对四种口径两两互转,要求convert(convert(z, t, a, b), t, b, a)与原始值的差小于 1e-12。这个测试我只写过一次,之后每一版代码都带着它跑。曾经有一次重构时把连续复利的转换写成了np.log(1+z)而不是np.exp(z)-1,就是这个往返测试在十秒内抓住的。

2. 从市场报价到折现因子:票息剥离的手工推演与代码落地

拿到一堆债券报价之后,第一个念头往往是"每只债券解一个收益率不就完了"。这么做出来的是到期收益率集合,不是即期曲线。要拿到即期曲线,必须让每只债券只贡献一个未知数,从短到长一层层剥开,这就是票息剥离(bootstrap)的全部含义。

2.1 一只债券只能解出一个点,这是剥离的根本原因

一只剩余 N 期的付息债,价格方程写开是 P = Σ CF_i × DF(t_i),其中 i 从 1 到 N。方程只有一个,未知数有 N 个折现因子,欠定,无解。但如果按期限排序,先处理最短的那只,它前面的现金流所对应的期限点,已经被更短的债券解出来过了——这些 DF 不再是未知数,可以当已知量直接代入。于是方程里只剩最后一个 DF(t_N) 一个未知数,可解。

这就是剥离能成立的核心:信息是从短端向长端单向传递的。它带来两个硬性前提,违反任意一个都会导致曲线失去意义:

  1. 债券必须严格按到期期限升序处理,中间不能跳期;
  2. 参与同一条曲线的债券必须是同一信用等级、同一币种、同一付息结构。把不同信用主体的报价混进一条曲线,剥出来的"即期利率"里混着信用溢价,谁都解释不清楚。

实际操作中,短端通常用零息类工具(贴现债、短期存款),中长端用付息国债,更长的段位可能要用利率互换报价去接。这三类工具的价格里含的流动性溢价和信息含量完全不同,所以成熟的做法是分工具建多条曲线,需要的时候再拼成一条"估值曲线"。我在做单币种内部工具时图省事只用了一类工具,短端到 1 年那段曲线就明显发虚,后来还是补了短端工具才踏实。

2.2 递推公式和一段能直接跑的剥离代码

把最后一步单独拿出来。假设前 N−1 期折现因子已知,最后一步的递推是:

DF(t_N) = [ P_full − Σ_{i=1}^{N−1} CF_i × DF(t_i) ] / CF_N

其中 P_full 必须是全价,CF_N 是最后一期的票息加本金。解出 DF(t_N) 之后,直接用rate_from_df转成即期利率即可。整段代码:

from dataclasses import dataclass import numpy as np @dataclass class Bond: tag: str ttm: float # 剩余年限 coupon: float # 年化票息率,小数 freq: int # 每年付息次数 clean_price: float # 净价 accrued: float = 0.0 face: float = 100.0 @property def full_price(self): return self.clean_price + self.accrued def flows(self): n = int(round(self.ttm * self.freq)) times = np.arange(1, n + 1) / self.freq cfs = np.full(n, self.face * self.coupon / self.freq) cfs[-1] += self.face return times, cfs def interp_log_df(t, knots_t, knots_df): """在 log(DF) 上做线性插值,保证结果恒正""" if len(knots_t) == 0: raise ValueError("曲线为空") lt = np.log(knots_df) if t <= knots_t[0]: # 短端用首个节点的即期利率平推,避免凭空外推 z0 = -np.log(knots_df[0]) / knots_t[0] return np.exp(-z0 * t) return np.exp(np.interp(t, knots_t, lt)) def bootstrap(bonds): knots_t, knots_df = [], [] for b in sorted(bonds, key=lambda x: x.ttm): times, cfs = b.flows() pv_known = sum( cf * interp_log_df(ti, knots_t, knots_df) for ti, cf in zip(times[:-1], cfs[:-1]) ) df_last = (b.full_price - pv_known) / cfs[-1] if df_last <= 0: raise ValueError(f"{b.tag} 剥出的折现因子非正,检查价格与票息口径") knots_t.append(times[-1]) knots_df.append(df_last) return np.array(knots_t), np.array(knots_df)

这段代码有几个刻意的设计。插值放在 log(DF) 上而不是 DF 上,是因为 DF 必须在 (0, 1] 区间内,直接线性插值在长端有可能插出负数,取对数之后插值天然保证结果为正。短端外推没做花哨处理,直接用首个节点的即期利率平推,理由是短端报价本来就密集,没有可靠信息时不做无谓的猜测。

2.3 真实报价里那些不规整的边界情况

教科书上的剥离示例总是每只债券都恰好整年、恰好整期付息,现实里几乎不会这么配合。下面几类我几乎每次都会遇到:

到期日不足一个完整付息周期。最后一段是残缺期(stub period),现金流会拆成"已知票息 + 到期本金"两部分,付息次数不再等于 ttm × freq 的整数。处理办法是把 ttm 按实际日期算到小数位,付息次数用ceil,最后一期的现金流单独按实际计息天数折算。如果图快直接round,残期债的折现因子会偏出一整个付息周期的量,长端尤其明显。

零息类和贴现类工具。这类不需要剥离,DF 直接等于 全价/面值,即期利率一步算出来。要把它们放在剥离序列的最前面,作为曲线的锚点。

付息日与曲线节点不对齐。这就是interp_log_df存在的理由。中间现金流的到期日几乎不可能正好落在已解出的节点上,必须插值。插值带来的误差会沿着剥离链条往后传,这也是为什么节点越密的段位,剥出来的曲线越可信。

负利率环境。DF 会大于 1,rate_from_df里那句df <= 0的检查不能写反成df >= 1。负利率本身完全合法,只是瞬时远期利率不能突破某个下限(理论上不能低于持有实物现金的成本),做合理性检查时要看远期而不是看即期。

3. 曲线不是折线:插值选型和拟合稳定性

剥离只给你一组离散的 (t, DF) 节点。要用它给任意期限定价,中间必须补点。补点方法的选择对短端影响不大,对 15 年以上的长端可以差出十几个基点,而且会直接决定你算出来的远期利率是否为正。

3.1 三种插值口径的实际差异

同样一组节点,至少有三种补法,结果不同:

插值对象含义优点风险
DF 线性折现因子在区间内线性变化实现最简单长端可能插出负 DF
log(DF) 线性区间内远期利率为常数恒正、直观、最常用远期曲线呈阶梯状,不光滑
即期利率线性利率在区间内线性变化报价端看起来自然远期利率会跳变,甚至反向

我默认用 log(DF) 线性,理由是它对远期利率的处理最"不武断"——它假设两个节点之间的远期利率是平的,这是在没有更多信息时最中性的假设。即期利率线性看起来更符合直觉,因为市场报价是按利率报的,但它在远期利率上产生的跳变会让关键利率久期这类依赖扰动的计算变得不稳定。

节点密度对精度的影响比插值方法本身更大。短端(1 年以内)通常有 3 个月、6 个月、9 个月、1 年这样的密集报价,任何插值方法误差都在亚基点级别。长端如果只有 10 年、20 年、30 年三个点,中间 15 年位置的定价几乎完全取决于插值假设。我在做一套内部工具时,长端就这样三个节点撑了半年,直到有一次拿另一套带 15 年报价的数据对拍,才发现 15 年位置的定价差了快 20 个基点。

3.2 三次样条为什么会造出负的远期利率

为了曲线光滑,很自然会想到三次样条。scipy.interpolate.CubicSpline一行就能用,二阶连续可导,看起来很美。问题在于样条是全局拟合,一个节点的局部误差会以振荡的形式传播到整条曲线。

具体表现是:短端节点密集(0.25、0.5、0.75、1.0 年),长端节点稀疏(10、20、30 年),两者的曲率差异巨大,样条在这两段之间会强行"找到"一个折中曲率,导致 2 到 5 年这一段的瞬时远期利率剧烈摆动,某些区段甚至掉进负值。远期利率为负不是绝对不可能,但在大多数市场环境下它是一个强烈的信号:你的曲线建错了。

规避办法有两个。一是改成在 log(DF) 上做样条而不是在 DF 上做,至少保证折现因子为正;二是用带惩罚项的平滑样条(scipy.interpolate.UnivariateSpline配合调参 s 值),用一点点拟合精度换取二阶导数的稳定。我一般会加一道后置检查:把曲线在 0.25 年步长上采样,反推瞬时远期,如果出现负值就打印警告,让我人工看一眼。

3.3 Nelson-Siegel 一族:参数少、外推稳,但别指望它穿过报价

另一条路是不追求穿过每个节点,而是用一族参数化的函数去逼近整条曲线。最经典的是 Nelson-Siegel:

z(t) = β0 + β1 × [(1 − e^(−t/τ)) / (t/τ)] + β2 × [(1 − e^(−t/τ)) / (t/τ) − e^(−t/τ)]

四个参数各司其职:β0 是长期水平(t 趋于无穷时的利率),β1 是短端和长端之间的斜率差,β2 控制中段的曲度,τ 决定曲度出现在哪个期限附近。Svensson 模型再加一组 β3 和 τ2,能多描述一个中段起伏。

from scipy.optimize import least_squares def nelson_siegel(t, b0, b1, b2, tau): t = np.asarray(t, dtype=float) x = t / tau factor = (1 - np.exp(-x)) / x return b0 + b1 * factor + b2 * (factor - np.exp(-x)) def fit_ns(t, z): def resid(p): return nelson_siegel(t, *p) - z best = None for tau0 in (0.5, 1.0, 2.0, 4.0, 8.0): x0 = [z.mean(), z[0] - z.mean(), 0.0, tau0] try: r = least_squares(resid, x0, bounds=([-0.1, -0.5, -0.5, 0.05], [0.3, 0.5, 0.5, 30.0])) except Exception: continue if best is None or r.cost < best.cost: best = r return best.x

这个模型的好处是外推稳定。30 年之后要是需要算 40 年的折现因子,样条基本是瞎猜,Nelson-Siegel 还能给出一个收敛到 β0 的合理值。代价是它不会精确穿过任何一个节点,残差通常在几个基点到十几个基点。所以选型上我的判断是:要做精确估值和交割对账,用剥离加插值;要做风险情景分析、压力测试、长期外推,用 Nelson-Siegel。两者不要指望一套打天下。

多起点拟合这一步不能省。τ 这个参数在残差空间里很不友好,单起点很容易落进局部极小,τ 收敛到 0.1 或者 25 这种明显不合理的边界值。我一般固定跑五个起点,取 cost 最小的那组,再人工看一眼 τ 是否落在 1 到 5 这个经验区间里。

4. 把计算做成任务:批量收益率引擎的工程化

单只债券算收益率是几十行代码的事,一旦变成"给一整张表算收益率、久期、凸性,每天跑一次",性质就变了。要考虑输入口径统一、批量求解的效率、异常数据的隔离、结果的校验。这一节讲的是我实际搭过的那套结构。

4.1 输入层:一张统一的现金流表

最常见的错误是让每一类债券走一条自己的代码路径。贴现债一套、付息债一套、含权债再一套,跑上两个月就没人搞得清哪条路径有 bug。我的做法是在入口处把所有品种都转换成同一种中间结构:一个 (时间点数组, 现金流数组) 的二元组。

import numpy as np import pandas as pd def build_flows(row): """把一行原始数据转成 (times, cfs)""" settle = pd.Timestamp(row["settle_date"]) mat = pd.Timestamp(row["maturity"]) freq = int(row["freq"]) if row["freq"] > 0 else 0 if freq == 0: # 贴现/零息 t = (mat - settle).days / 365.0 return np.array([t]), np.array([row["face"]]) dates = pd.date_range(end=mat, periods=int(row["n_periods"]) + 1, freq=pd.DateOffset(months=12 // freq))[1:] times = np.array([(d - settle).days / 365.0 for d in dates]) cfs = np.full(len(dates), row["face"] * row["coupon"] / freq) cfs[-1] += row["face"] return times, cfs

这段代码里有两个地方值得单独说。付息日我用pd.date_range(end=...)从到期日往回推,而不是从结算日往后推,因为债券的付息日是钉在到期日上的(到期日通常是付息日),从前往后推会累积闰年和月末的误差。另外12 // freq只支持能被 12 整除的频率,遇到按周付息或者不规则付息的品种要单独处理,我在代码里留了一个断言,遇到不支持的频率直接抛异常而不是静默出错。

还有个容易被忽略的点:工作日调整惯例。真实付息日如果落在非工作日,会按惯例调整,常见的是 Following(顺延到下一个工作日)和 Modified Following(顺延,但不跨月)。现金流量的日期差一天,在长端就是几个基点的差别。如果数据源已经给了调整后的日期,直接用;如果给的是理论付息日,就得自己补上调整逻辑。

4.2 求解层:牛顿法、二分法,以及向量化的取舍

求到期收益率本质是解一个一元方程:找 y 使得 P = Σ CF_i / (1 + y/f)^(f·t_i)。两个稳妥选项:

  • scipy.optimize.brentq:布伦特法,只要给对区间就必定收敛,速度中等,代码最省心。
  • 牛顿法:收敛快(通常 3 到 5 步),但需要导数,遇到价格远离面值或者负利率时可能跑飞。

单只债券我无脑用 brentq,区间取 (−0.5, 1.0),覆盖了绝大多数极端情形。真正需要优化的是批量场景——一张表几万行,逐行调 brentq 会有明显的开销。

批量的加速关键在于按现金流期数分组。同一组内(比如都是 10 期)的债券可以拼成一个矩阵,用 numpy 广播同时迭代:

def ytm_batch(prices, times_mat, cfs_mat, mask, freq=2, tol=1e-12, max_iter=60): """times_mat/cfs_mat 形状 (n_bonds, n_periods),mask 标记有效期""" y = np.full(len(prices), 0.05) for _ in range(max_iter): base = 1.0 + y[:, None] / freq disc = np.where(mask, cfs_mat / base ** (times_mat * freq), 0.0) pv = disc.sum(axis=1) # dPV/dy dpv = np.where( mask, -(times_mat * freq) * cfs_mat / (base ** (times_mat * freq + 1)) / freq, 0.0, ).sum(axis=1) step = (pv - prices) / dpv y = y - step if np.max(np.abs(step)) < tol: break return y

这个写法我在三万行规模的表上测过,比逐行 brentq 快一个数量级左右,前提是先把数据按期数分桶、每桶补齐成矩阵再用 mask 屏蔽。补齐的方式是在短的行后面填 0 现金流,注意填 0 之后base ** (0 * freq)等于 1,乘以 0 仍然是 0,不会污染结果,但times_mat要填成一个非零值避免 0 的 0 次幂问题——我一般填 1.0。

牛顿法跑飞的情况必须兜底。我在外层包一层:迭代完检查|pv - price| > 0.01的行,把这些行挑出来单独用 brentq 重算。实测重算比例通常在 0.1% 以下,主要是深度折价或者残期债。

4.3 久期和凸性顺手一起算出来

既然已经把现金流和收益率都算出来了,久期和凸性基本是白送的,加几行的事:

def duration_convexity(times, cfs, y, price, freq=2): n = times * freq base = 1.0 + y / freq disc = cfs / base ** n mac = np.sum(times * disc) / price mod = mac / base conv = np.sum(n * (n + 1) * cfs / base ** (n + 2)) / price / freq ** 2 return mac, mod, conv

修正久期给出的是收益率变动 1 个单位时价格的百分比变动,实务里更常用的是 DV01——收益率变动 1 个基点时每百元面值的价格变动,数值上等于 修正久期 × 全价 × 0.0001。凸性则用来修正大幅波动时的线性近似误差,收益率上下各走 100 个基点时,凸性项能解释掉久期估算误差的相当一部分。

这里有个坑要提醒:麦考利久期和修正久期用的时间单位必须和收益率口径一致。如果收益率是半年复利年化值,那么times必须是年,freq必须是 2,三个量要配套。我见过把times写成期数(而不是年)然后直接套年化收益率的写法,算出来的久期整整小了一半。

4.4 什么时候才值得上多进程

这一点我踩过反方向的坑。最开始做批量估值时,兴冲冲套了ProcessPoolExecutor,结果比单进程还慢。原因是单只债券的计算量只有几十微秒,而进程间传输一个 DataFrame 分片、序列化、再传回来,开销是这个数量级的上百倍。

判断标准很简单:看单个任务里有没有曲线重建。如果只是已知曲线的前提下算收益率久期,纯计算量极小,多进程纯亏,老老实实做向量化。只有当每只债券都需要重建一次曲线时(比如算关键利率久期,要对每个节点逐一扰动后重新剥离、重新定价),单个任务的开销上升到毫秒级,多进程才划算。

关键利率久期的逻辑是:把曲线第 k 个节点上移 1 个基点,其他节点不动,重建整条曲线,重估目标债券,得到价格变化量。N 个节点就要重建 N 次曲线,30 个节点的曲线、一千只债券,就是三万次剥离,这时候用进程池分片处理收益明显。分片粒度别太细,我一般按 CPU 核数的 2 到 4 倍切块,每块几百只债券,摊薄调度开销。

5. 对账清单:那些让结果差几个基点的细节

代码跑通和结果可信是两件事。这一节是我每次交付前必过的一遍清单,基本都是被现实教育出来的。

5.1 脏价和净价,以及应计利息的方向

市场报价习惯用净价,而所有定价公式里的 P 必须是全价。关系是 全价 = 净价 + 应计利息,应计利息 = 票息 × 已计息天数 / 当期计息天数。

方向搞反的后果非常直接:应计利息在付息日附近可以达到几块钱,直接进 bootstrap 会让剥出来的折现因子系统性偏大或偏小,而且这个偏差随付息周期呈锯齿状波动,看单点完全发现不了,画成曲线才发现每隔半年跳一次。

更隐蔽的是应计天数规则。同一笔债券,用 ACT/ACT 和 30/360 算出的应计利息在月末附近能差出一天的利息。原子操作上这不叫错误,叫约定不一致,但如果你的报价源用一种规则、你的估值用另一种,对账时就永远差那么一点点。

我现在的做法是:输入表里强制要求两列,clean_price和accrued,都由数据源直接给,代码里不做任何应计计算。如果数据源只给净价,那就单独写一个应计计算模块,并且用同一个模块的输出去校验数据源给的应计,两边对不上就告警。

5.2 结算日、到期日和"是否含首尾"

日期差算年限时,到底用 T+0 还是 T+1 作为估值基准日,直接影响 t 的取值,短端影响尤其明显。三个月期的品种,差一天就是 0.9% 左右的时间误差,年化利率上反映为几个基点。

我的处理是:结算日作为显式参数传进所有函数,从不在函数内部用datetime.today()去取。早期因为图方便在函数里默认取当天,结果跑历史回溯时整批数据全部错位一天,排查了大半天才发现。

另一个是"含首尾"的问题。(d2 - d1).days给出的是两个日期之间的间隔天数,如果计息规则是"算头不算尾",那正好;如果是"算头又算尾",要加 1。这件事没有统一答案,取决于具体的计息惯例,唯一可靠的办法是找一份已知答案的样本对一遍。

5.3 三条自检规则,每次跑完都要过一遍

不管代码写得多仔细,跑完之后我都会过这三条:

第一条,回算残差。用剥出来的曲线去给参与剥离的每只债券重新定价,得到的全价与原报价的差应该在 1e-8 量级。如果某只债券的残差跳到了 0.01 以上,说明它的现金流生成有问题(通常是付息日推错了或者残期处理不对)。这条能抓住九成以上的实现错误。

第二条,远期利率检查。把曲线在 0.25 年步长上采样,反推瞬时远期利率,检查是否存在异常的负值或者超过 20% 的跳变。正常的曲线远期应该是平滑的,出现尖刺基本就是插值振荡或者节点数据错误。

第三条,退化情形对拍。拿单期零息债单独算:z = (面值/全价)^(1/t) − 1。这是一步就能手算的式子,用来验证整条管线在最短路径上没走错。我一般会在测试里固定放三五个手算样例,每次改完代码先跑它们。

5.4 我实际踩过的三个坑

第一个坑是报价单位。有的数据源用"每百元面值报价"(价格 98.5 表示 98.5 元),有的用"每 1 元面值"(0.985)。混用之后所有价格尺度差 100 倍,剥出来的折现因子会大得离谱,极端情况下直接算出负利率。现在我在入口加了一条断言:价格落在 0.5 到 1.5 之间就当作每元报价并乘以 100,落在 50 到 150 之间就当作每百元,其他情况直接拒绝。

第二个坑是频率默认值。曾经有一批数据源没给freq字段,代码默认用了 1(年付),而实际品种全是半年付。结果票息被少算了一半,价格里的票息部分和本金部分严重不匹配,bootstrap 从第 3 年开始就崩了,剥出来的曲线尾部直接翘到天上。现在freq缺失我一律抛异常,绝不设默认值——缺失就是缺失,让数据方补。

第三个坑是浮点精度。折现因子在 30 年期限上小到 0.02 量级,而价格是 100 量级,两者相减时有效数字被吃掉不少。残期债的最后一个现金流如果很小(比如只有一点零头的票息),(P − pv_known) / cf_last这一步的相对误差会被放大到不可接受。解决办法是在关键步骤用np.float64全程,减法前先把量级相近的项合并,极端情况下用decimal.Decimal精确算一次做交叉验证。

最后分享一个小技巧。如果你手头只有收益率曲线的图、没有原始报价,想反推出即期曲线做个大概的估算,可以直接对图上的到期收益率曲线做一次平滑,然后用它近似当作即期曲线,再逐点微调,标准是让远期利率保持平滑。这个方法当然不精确,但在做快速敏感性测试、估算久期量级的时候够用,比什么数据都没有强得多。真要落到估值和交割上,还是得回到原始报价老老实实剥一遍。

返回列表