做固收核算、理财台账或者投研支持的朋友,大概率都被同一类需求追过:手上有一堆券的票息、付息频率、到期日、净价全价,对方要你在第二天开盘前把即期利率曲线拉出来;或者更朴素一点,一笔投资分几次投进去、中间又分几次回款,要算一个真实的年化收益率。前一件事是即期利率(spot rate)的剥离,后一件事我习惯叫它"任务收益率"计算——不管是产品户、专户还是个人台账,口径是同一套。用Python来处理这两件事其实都不复杂,难的全在细节里:计息频率怎么对齐、天数规则用哪一种、Bootstrap的剥离顺序会不会错位、求解器为什么不收敛、浮点误差怎么把一条本来很平滑的曲线抖成锯齿。
这篇就把我平时用的整套东西摊开讲,从概念辨析到代码实现、从参数取值到踩坑排查,全部落到可以直接复现的程度。不需要你有很深的量化背景,会写基础Python、知道什么是现金流,就能跟着走完。有经验的朋友可以直接跳到第3、第4节看实现和口径对比。
1. 先想清楚要算的是哪一种"利率"
1.1 到期收益率、即期利率、远期利率,三个概念别搅在一起
刚接触这块最容易犯的错,是把"债券的收益率"当成一个东西。实际上至少有三种口径,混用就会算出一堆看着合理、实际对不上的数。
到期收益率(YTM)是让债券全部现金流现值等于当前价格的单一折现率,本质是一个内部收益率。它隐含了两个假设:持有到期,且所有票息都能按这个YTM再投资。现实里第二个假设基本不成立,所以YTM只是一个报价惯例,不是真实回报。
即期利率(spot rate)是零息债的到期收益率,也就是"今天把钱放进去、中间一分钱不拿、到期一次性还本付息"所对应的年化利率。它才是真正干净的折现率,第n年的现金流就该用第n年的即期利率去折。
远期利率(forward rate)是从即期曲线里反推出来的、市场隐含的未来某段期间的利率。三者关系可以写成一个很短的式子:
$$(1+z_n)^n = (1+z_{n-1})^{n-1} \times (1+f_{n-1,n})$$
打个生活化的比方:YTM像把一堆不同甜度的水果打成果汁之后测出来的平均糖度,喝起来是那个味,但你不知道每颗水果到底多甜;即期利率是挨个给每颗水果单独测糖度;远期利率则是根据"今天测的糖度"去推测"下个月再进一批货大概什么甜度"。
做估值、做曲线、做套利测算,用的都是即期利率和远期利率;只有做报价对比、做持有到期测算的时候,YTM才上场。搞清楚这个,后面代码里为什么要有两套定价函数就顺理成章了。
1.2 为什么这类计算值得从电子表格搬到代码里
我见过太多用表格维护的利率曲线,问题不在算错,而在不可复现。改一个价格,整条曲线的手工剥离顺序都要跟着调整;加一只新券,得重新排一遍公式;换个人接手,看见一列嵌套七八层的单元格引用直接懵。
用代码做这件事,收益主要有三点。第一是可复现,同一份输入跑出来永远是同一个结果,出问题能定位到具体某一行。第二是可批量,几百只券的YTM、上千条现金流的XIRR,循环一次就完事,不用一个一个拖公式。第三是可测试,你写完一个定价函数,可以立刻用"给定即期曲线反算价格,再用价格反解YTM,看能不能回到原值"这种方式自检,这在表格里几乎做不到。
代价也有:前期要把数据结构设计好,后面改起来才不痛。所以第2节我会花不少篇幅讲日期和现金流这两块地基,地基歪了,上面全是白算。
1.3 技术栈与代码组织
依赖就三个:numpy做向量化、scipy做求根和拟合、datetime处理日期。pandas可选,批量处理时用来装结果比较顺手。
我一般会拆成四个模块,文件不大但职责清晰:
daycount.py:天数规则、年化因子换算bond.py:现金流构造、定价、YTM求解curve.py:Bootstrap剥离、插值、远期利率returns.py:任务收益率、XIRR、组合汇总
这么拆的好处是,天数规则这种东西一旦写错,会影响所有模块,单独放一个文件方便你写单元测试反复验证。我吃过这个亏——早期把天数计算内联在各个函数里,后来发现30/360的月末规则写错了,改了六七个地方,还漏了一个。
2. 地基部分:日期、天数规则与现金流
2.1 天数规则不是小事,它能让结果差出几十个基点
年化因子(year fraction)是两个日期之间的"年数",不同市场惯例算法不同。最常见的三种:
| 规则 | 算法 | 常见场景 | 一年实际天数 |
|---|---|---|---|
| ACT/365 | 实际天数 / 365 | 多数本币信用债、理财测算 | 固定365 |
| ACT/360 | 实际天数 / 360 | 货币市场工具、部分同业 | 固定360 |
| 30/360 | 每月按30天、每年按360天 | 部分公司债、票息计算 | 固定360 |
ACT/360 和 ACT/365 的差异是 360/365 ≈ 1.39%,看着不大,但落在一条三年期曲线上,端点能差出五六个基点,做相对价值比较的时候足够让你得出相反结论。
实现上我最常用的是这一个函数,把规则名当参数传进去:
from datetime import date def year_fraction(d1: date, d2: date, basis: str = "ACT/365") -> float: if basis == "ACT/365": return (d2 - d1).days / 365.0 if basis == "ACT/360": return (d2 - d1).days / 360.0 if basis == "30/360": dd1 = min(d1.day, 30) dd2 = d2.day if dd1 < 30 else min(d2.day, 30) return (360 * (d2.year - d1.year) + 30 * (d2.month - d1.month) + (dd2 - dd1)) / 360.0 raise ValueError(f"不支持的规则: {basis}")30/360 那段里的dd2 = d2.day if dd1 < 30 else min(d2.day, 30)是关键,它处理的是"起算日为31号"的情况:先把起算日压到30号,如果压了,到期日也要跟着压。这一行的写法不同市场还有细微差别(有的规则不压到期日),做跨市场比对时一定要跟对方确认口径,别自己想当然。
注意:天数规则必须和票息频率配套确认。半年付息配 ACT/365 是常见组合,但如果你拿到的价格是按30/360报的,直接套 ACT/365 去反解YTM,误差会全部堆到求解结果里,而且不会报错,只会给你一个看起来很正常的错误数字。
2.2 计息频率与复利口径的换算
同样是"年化5%",按年复利、半年复利、连续复利对应的实际增长完全不同。转换公式不复杂,但要背下来:
- 按 f 次复利转连续复利:$r_c = f \ln(1 + r_f / f)$
- 连续复利转按 f 次复利:$r_f = f (e^{r_c / f} - 1)$
- 半年复利转年复利:$r_1 = (1 + r_2/2)^2 - 1$
举个例子,半年复利 4% 对应年复利 $(1.02)^2 - 1 = 4.04%$,对应连续复利 $2\ln(1.02) = 3.9605%$。三个数摆在一起,谁也不会认错,但你如果拿半年复利的数直接去年复利的公式里算,一年期的误差就是4个基点,五年期累积下来接近20个基点,做曲线拟合的时候会明显看到近端和远端接不上。
代码里我会写一对互转函数,避免每次现场推导:
import numpy as np def to_continuous(r_f: float, freq: int) -> float: return freq * np.log(1 + r_f / freq) def from_continuous(r_c: float, freq: int) -> float: return freq * (np.exp(r_c / freq) - 1)实际项目里我倾向内部统一用连续复利存,理由很简单:连续复利下,多期折现就是简单相加,$e^{-r_1 t_1} \cdot e^{-r_2 t_2} = e^{-(r_1 t_1 + r_2 t_2)}$,插值、求远期、做曲线平滑都少一层幂运算,出错概率低。只在最终输出报表的时候转回市场习惯的频率口径。
2.3 现金流表怎么设计才不容易错
一只债券的现金流,本质就是一串(日期, 金额)。但工程上只存这两列不够,至少要带上方向(收/付)和期数索引。我常用的结构是这样:
bond = { "name": "示例券A", "face": 100.0, "coupon_rate": 0.03, # 年化票面利率 "freq": 2, # 每年付息次数 "issue": date(2022, 3, 15), "maturity": date(2025, 3, 15), "price": 99.20, # 全价,注意口径 "basis": "ACT/365", }然后写一个生成现金流的函数,输出期数、日期、金额三列:
def build_cashflows(face, coupon_rate, freq, maturity, periods_left): c = face * coupon_rate / freq flows = [] for k in range(1, periods_left + 1): amt = c if k == periods_left: amt += face # 最后一期加上本金 flows.append({"period": k, "amount": amt}) return flows这段代码里我刻意没有用日期去推期数,而是让调用方显式传入periods_left。原因是首期如果不是完整计息期(比如起息日到首个付息日只有四个月),用日期硬推容易多算或少算一期,这是我在实际项目里踩过的最隐蔽的一个坑——多算一期的结果不会报错,只会让YTM偏低十几个基点,而且因为曲线是单调的,看数字很难一眼发现。
提示:构造完现金流后养成一个好习惯,打印一下总现金流之和,和自己心里估算的"本金 + 票息总额"对一遍。这一步只要五秒钟,能拦住八成以上的期数错误。
3. 核心实现:从价格到即期利率曲线
3.1 到期收益率的数值求解
给定价格求YTM,没有解析解,必须数值求解。先写定价函数,注意这里的折现期数就是 1 到 n,不需要再乘频率:
import numpy as np def price_from_ytm(y, coupon_rate, face, periods, freq=2): c = face * coupon_rate / freq cf = np.full(periods, c) cf[-1] += face t = np.arange(1, periods + 1) return np.sum(cf / (1 + y / freq) ** t)注意这里的(1 + y/freq) ** t,t已经是期数而不是年数,所以指数部分不用再乘freq。我见过不少人写成(1 + y/freq) ** (t / freq),等于把折现期数又除了一次频率,结果算出来的YTM大得离谱——因为价格对收益率是单调递减的,除以频率相当于把整个时间轴压缩,同样的价格必然对应一个更高的收益率。
求解用scipy.optimize.brentq,不用newton。原因很实在:Brent法是区间收缩法,只要两端异号就一定能收敛,不需要导数;牛顿法虽然快,但价格函数在某些参数下导数接近零,会直接发散或者跳到负利率区间外面去。
from scipy.optimize import brentq def solve_ytm(price, coupon_rate, face, periods, freq=2): f = lambda y: price_from_ytm(y, coupon_rate, face, periods, freq) - price lo, hi = -0.5, 2.0 if f(lo) * f(hi) > 0: raise ValueError("区间内无解,检查价格或期数是否合理") return brentq(f, lo, hi, xtol=1e-12, rtol=1e-12)区间取[-0.5, 2.0]是我试出来的经验值:下限 -50% 足够覆盖极端折价的情况(某些违约债确实会算出负收益率),上限 200% 覆盖绝大多数高息场景。如果真遇到区间端点同号,说明输入有问题——通常是价格填成了净价、期数算错,或者票息率用了百分数(3而不是0.03)。
3.2 Bootstrap 剥离法构建即期利率曲线
这是整篇文章最核心的一块。逻辑不复杂:从最短期的券开始,用已知的短期贴现因子把中长期券的中间票息折掉,剩下的部分就对应一个新的贴现因子,从而解出新的即期利率。
先看数学形式。假设半年付息,第 n 期付息,票息为 c,面值 100:
$$P = \sum_{i=1}^{n-1} c \cdot d_i + (c + 100) \cdot d_n$$
其中 $d_i$ 是第 i 期的贴现因子。前面的 $d_1 \dots d_{n-1}$ 已经从更短的券里算出来了,所以:
$$d_n = \frac{P - \sum_{i=1}^{n-1} c \cdot d_i}{c + 100}$$
再由 $d_n = (1 + z_n/f)^{-n}$ 反解即期利率:
$$z_n = f \cdot \left( d_n^{-1/n} - 1 \right)$$
代码实现:
def bootstrap_spot_curve(bonds, freq=2, face=100.0): """bonds: 按 periods 升序排列的 dict 列表,字段含 periods/coupon_rate/price""" bonds = sorted(bonds, key=lambda b: b["periods"]) dfs, spots = [], [] for b in bonds: n = b["periods"] c = face * b["coupon_rate"] / freq pv_coupons = sum(c * dfs[i] for i in range(n - 1)) df_n = (b["price"] - pv_coupons) / (c + face) if df_n <= 0: raise ValueError(f"第{n}期贴现因子非正,检查价格或前置现金流") dfs.append(df_n) spots.append(freq * (df_n ** (-1.0 / n) - 1)) return dfs, spots这里有个df_n <= 0的检查,别省。它意味着前面所有票息的现值已经超过了整只券的价格,数学上等于要求一个负的本金折现值,实际中几乎一定是数据错了——要么价格填反了,要么前面某只券的期数重复了导致dfs索引错位。
我拿一组自拟的示例数据跑一遍,面值100,半年付息:
| 券 | 剩余期数 | 票面利率 | 价格 | 贴现因子 | 即期利率(年化,半年复利) |
|---|---|---|---|---|---|
| A | 1 | 0.00% | 98.50 | 0.985000 | 3.046% |
| B | 2 | 3.00% | 99.20 | 0.962784 | 3.829% |
| C | 3 | 4.00% | 100.30 | 0.945142 | 3.797% |
| D | 4 | 5.00% | 101.10 | 0.915782 | 4.448% |
手工验算第二只券,验证代码没错:$d_1 = 98.50/100 = 0.985$,$c = 100 \times 3% / 2 = 1.5$,$d_2 = (99.20 - 1.5 \times 0.985)/101.5 = 97.7225/101.5 = 0.962784$,$z_2 = 2 \times (0.962784^{-0.5} - 1) = 3.829%$。对得上。
这张表里有个细节值得说:1.5年期的即期利率(3.797%)低于1年期(3.829%),曲线出现了一个小幅倒挂。这不是代码错了,是我故意让第三只券的价格偏贵造成的——现实中新券发行溢价、流动性差异、税收因素都能造成这种局部倒挂。看到倒挂先别改代码,回去核对价格来源。
3.3 曲线插值:从离散点得到连续曲线
Bootstrap只给你离散的几个期限点,但实际用的时候往往需要任意期限的即期利率。三种常用做法,按复杂度和适用场景排:
线性插值最简单,numpy.interp一行搞定,缺点是曲线在节点处有折角,算远期利率(本质是求导)时会出现跳变。
import numpy as np def interp_linear(ts, zs, t_new): return np.interp(t_new, ts, zs)三次样条光滑,scipy.interpolate.CubicSpline直接用,远期利率连续。缺点是节点附近容易过冲,特别是近端数据稀疏的时候。
from scipy.interpolate import CubicSpline cs = CubicSpline(ts, zs, bc_type="natural") z_new = cs(t_new) fwd = cs(t_new, 1) # 一阶导即瞬时远期Nelson-Siegel参数化拟合,用四个参数刻画整条曲线的水平、斜率、曲度,特别适合期限点很少的场合,给出的是"经济意义上合理"的平滑曲线:
from scipy.optimize import curve_fit def nelson_siegel(t, b0, b1, b2, tau): x = np.asarray(t) / tau return b0 + b1 * (1 - np.exp(-x)) / x + b2 * ((1 - np.exp(-x)) / x - np.exp(-x)) p0 = [zs[-1], zs[0] - zs[-1], 0.0, 1.0] bounds = ([0, -1, -1, 0.1], [0.5, 1, 1, 10]) params, _ = curve_fit(nelson_siegel, ts, zs, p0=p0, bounds=bounds, maxfev=20000)初值的取法有讲究:b0是长端水平,直接取最长期限的即期利率;b1是长短端之差,取短端 - 长端;b2给0让它自己找;tau给1。这个初值组合在我处理过的数据里从来没失败过。另外bounds里给tau设了下限0.1,是防止$x = t/\tau$ 溢出——我曾见过tau收敛到0.001,np.exp(-x)直接下溢成0,结果全变成常数。
3.4 贴现因子与远期利率的推导
有了即期曲线,贴现因子和远期利率都是顺手的事:
def discount_factor(z, t, freq=2): return (1 + z / freq) ** (-freq * t) def forward_rate(z1, t1, z2, t2, freq=2): d1 = discount_factor(z1, t1, freq) d2 = discount_factor(z2, t2, freq) return freq * ((d1 / d2) ** (1.0 / (freq * (t2 - t1))) - 1)拿上面表格的数据算一下1.0到1.5年的远期:$d_{1.0}=0.962784$,$d_{1.5}=0.945142$,比值 $1.018666$,年化远期 = $2 \times (1.018666 - 1) = 3.733%$。这个数比两端的即期都低,正是前面那个倒挂造成的——远期利率对曲线斜率极其敏感,这也是为什么做曲线时宁可多花点时间确认价格质量,也不要事后去修远期。
4. 任务收益率的几种口径与实现
4.1 简单年化、复利年化、XIRR,到底该用哪个
"任务收益率"这个词在不同的台账里含义不一样,我总结成三种口径,各有各的适用面:
| 口径 | 公式 | 适用场景 | 缺陷 |
|---|---|---|---|
| 简单年化 | 总收益 / 本金 × 365 / 持有天数 | 单笔一次性投入、期限短 | 忽略复利,多笔投入无解 |
| 复利年化 | (期末/期初)^(365/天数) - 1 | 单笔、无中间现金流 | 有中间现金流时失真明显 |
| XIRR | 解 $\sum CF_i (1+r)^{-t_i/365} = 0$ | 任意时点的不规则现金流 | 多解风险,需给合理区间 |
判断标准很简单:只要中间有追加投入或者部分回款,就必须用XIRR。前两种口径本质上是把整个任务当成一笔钱进出,中间的钱去了哪里它完全不管,算出来的数只能叫"粗略参考"。
我拿一组自拟现金流做个对照,投入15万、分两笔,回收16.5万、分两笔:
| 日期 | 现金流 |
|---|---|
| 2023-01-05 | -100,000 |
| 2023-07-20 | -50,000 |
| 2024-03-15 | +40,000 |
| 2024-12-10 | +125,000 |
按简单年化:总收益1.5万,本金15万,持有约705天,年化 = 10% × 365/705 ≈ 5.18%。按复利年化:$1.1^{365/705} - 1 ≈ 5.06%$。按XIRR算出来是6.25%。
差了整整一个百分点以上。原因在于资金的时间权重:那10万从第一天就压在里面,占了全部时长;后面追加的5万只压了不到一年半。简单年化把两笔钱当成同时投入,低估了前一笔的贡献,所以XIRR更高。做业绩归因必须用XIRR,这张表我常拿来给别人解释为什么两种算法差这么多。
4.2 XIRR 的实现细节
def xnpv(rate, cashflows): t0 = cashflows[0][0] return sum(cf / (1 + rate) ** ((d - t0).days / 365.0) for d, cf in cashflows) def xirr(cashflows, lo=-0.99, hi=10.0): cashflows = sorted(cashflows, key=lambda x: x[0]) f = lambda r: xnpv(r, cashflows) if f(lo) * f(hi) > 0: raise ValueError("区间内未找到解,检查现金流符号是否至少变号一次") return brentq(f, lo, hi, xtol=1e-10)三个要点。
第一,现金流必须先按日期排序。XIRR的公式对顺序不敏感(求和嘛),但t0取的是第一笔的日期,顺序乱了整个时间轴就错位。
第二,现金流必须有正有负,而且至少变号一次。如果全部同号,NPV单调,区间里永远无解,brentq会直接报错。这个报错信息其实很有用,出现它基本等于数据里漏了一笔投入或者回款。
第三,lo取 -0.99 是个保险值,不能取 -1。因为 $(1+r)^{t}$ 在 $r=-1$ 处除零,取 -0.99 相当于允许 -99% 的极端亏损年化,正常业务永远不会碰到。
注意:账面上经常出现"投入记正、回款记负"的符号约定,跟这里的写法正好相反。导入数据前一定统一符号,否则求解器会给你一个数学上正确、业务上完全错的结果——而且不会报任何错。
4.3 组合收益率:加权方式和调仓的影响
单个任务的XIRR清楚了,组合层面还有一个坑:你算的是资金加权还是时间加权。
资金加权(也就是整体XIRR)反映的是"这笔钱在这个账户里实际赚了多少",会受申赎时点影响:在低点大额加仓会拉高它,在高点加仓会拉低它。这个数适合给投资人看。
时间加权(TWR)是把区间切成若干段,每段算一个子区间收益率,再连乘。它剔除了现金流时点的影响,反映的是"策略本身的表现",适合评价管理能力。
简化版的近似算法是修正Dietz:
$$R = \frac{V_1 - V_0 - CF}{V_0 + \sum w_i \cdot CF_i}$$
其中 $w_i$ 是第 i 笔外部现金流在区间内的剩余时间占比。这个方法精度不如TWR,但只需要期初、期末和现金流三个数,台账维护成本低,实务里用得很多。
组合层面的XIRR,最省事的做法是把所有账户的现金流合并到一张表里再求根,等价于把整个组合当成一个大任务。前提是货币单位一致、日期口径一致。我见过有人把不同币种的现金流直接相加,年化算出来看着挺正常,实际毫无意义。
5. 常见问题与排查技巧实录
5.1 求解器相关的问题
不收敛、报"区间内无解"是最常见的。排查顺序我固定成四步:
- 检查现金流符号,有没有全正或全负;
- 打印区间两端的函数值,看是不是同号(同号说明根不在区间里,或者根本没根);
- 检查价格输入是净价还是全价——净价少了应计利息,反解出的YTM会偏低,而且偏低得不多,很难肉眼发现;
- 检查期数,尤其是首期非整期的情况。
结果跳到一个明显不合理的值,比如YTM算出 80%。这种九成是把票息率当百分数填进去了,3而不是0.03。价格函数里c = face * 3 / 2 = 150,价格才100,那只能靠极低的折现因子去凑,反过来解就是极高的收益率。加一个断言if not 0 <= coupon_rate <= 0.3: raise,能省下大量调试时间。
Bootstrap过程中贴现因子算成负数或大于1。大于1意味着即期利率为负,在某些市场环境里是真实的,不要急着报错;小于0基本一定是错的,检查前置现金流索引有没有越界。我在bootstrap_spot_curve里加的df_n <= 0检查就是为了拦这个。
5.2 数值与口径层面的坑
浮点比较。if price == 100.0这种写法在算过几轮折现之后几乎永远为假。要么用abs(a - b) < 1e-9,要么用math.isclose。我在早期写的一个校验函数里吃过这个亏,明明逻辑正确,测试就是过不了,查了半天才发现是 99.99999999999999 不等于 100。
插值外推。np.interp在超出范围时默认返回端点值,不报错也不警告。如果你拿2年期曲线去算3年期的贴现,它会默默给你用2年期的利率,误差全在你看不见的地方。我的做法是在插值函数外面包一层,超出范围直接抛异常,逼着调用方显式处理。
频率不一致。票息半年付、曲线按年复利报,直接混用会让远期利率算出锯齿。稳妥做法是内部全部转连续复利统一计算,输出时再转回需要的频率。
闰年和月末。30/360 规则里2月28日和3月1日的处理、2月29日的存在与否,都会让年化因子差一天。做跨年现金流的时候,务必用真实的date对象相减,不要手算天数。
正则解析日期。从报表里读日期时,01/02/2024到底是1月2日还是2月1日,取决于来源。稳定的做法是让上游固定输出 ISO 格式2024-01-02,实在不行就用datetime.strptime显式指定格式,别用自动推断的解析器。
5.3 速查表
| 症状 | 最可能的原因 | 处理方式 |
|---|---|---|
| YTM 求解无解 | 区间不含根 / 符号全同 | 扩大区间,检查现金流符号 |
| YTM 高达 50% 以上 | 票息率填成百分数 | 断言取值范围 |
| 即期曲线整体偏低 | 用了净价而非全价 | 加回应计利息 |
| 曲线末端突然翘起 | 最后一只券票息低、期限长,剥离误差放大 | 检查该券价格,考虑剔除或加权 |
| 远期利率剧烈锯齿 | 插值方法不光滑 | 换三次样条或N-S拟合 |
| XIRR 报错无解 | 现金流全同号 | 核对是否漏记投入或回款 |
| XIRR 结果偏离直觉 | 符号约定反了 | 统一"投入为负、回收为正" |
| 现金流期数多一期 | 首期非整期 | 显式传期数,别用日期硬推 |
6. 工程收尾:自检、向量化与留痕
6.1 用回算做自检,比任何单测都实在
写完一套定价和Bootstrap,最有效的验证方式是闭环验证:给定一组即期利率,先正算出一批债券的理论价格,再把这些价格丢进Bootstrap,看能不能还原出原来的即期曲线。误差在 $10^{-8}$ 量级以内就说明剥离逻辑没问题。
def self_check(spots, freq=2, face=100.0): # spots: 第 i 个元素是第 i+1 期的即期利率 dfs = [(1 + z / freq) ** (-(i + 1)) for i, z in enumerate(spots)] bonds = [] for n in range(1, len(spots) + 1): coupon = 0.03 if n > 1 else 0.0 c = face * coupon / freq pv = sum(c * dfs[i] for i in range(n - 1)) + (c + face) * dfs[n - 1] bonds.append({"periods": n, "coupon_rate": coupon, "price": pv}) _, recovered = bootstrap_spot_curve(bonds, freq, face) return max(abs(a - b) for a, b in zip(spots, recovered))这个函数里我把第一只券设成零息券,因为零息券的即期利率可以直接从价格读出,不需要剥离,是最干净的数据源。实际业务里零息券不一定有,那就退一步,用最短期限的附息券当起点,接受近端一点微小误差。
我建议把这套自检放进 CI,每次改动天数规则或者定价公式就跑一遍。浮点计算这种东西,改动一处可能影响很远,靠人工核对是不靠谱的。
6.2 批量计算时把循环干掉
单只券怎么算都无所谓,几百只券、几万条现金流的时候,for循环加brentq会明显变慢,因为每次求根都要迭代十几次,每次都重新构造现金流数组。
提速的关键是预构造现金流矩阵。把所有券按最大期数补齐成矩阵,用numpy一次性算折现:
def batch_price(ytm_vec, cf_matrix, periods, freq=2): t = np.arange(1, cf_matrix.shape[1] + 1) mask = t <= periods[:, None] disc = (1 + ytm_vec[:, None] / freq) ** (-t) return np.sum(cf_matrix * disc * mask, axis=1)实测下来,500只券的批量定价从原来逐只循环的 1.2 秒降到 30 毫秒左右,快了将近 40 倍。求根本身没法完全向量化(每只券的根不一样),但可以在外层用一个列表推导加brentq,配上已经预构造好的矩阵,整体速度仍然能提升一个数量级。
6.3 结果留痕:比算得快更重要的是算得清
最后说一个容易被忽略的点。利率计算这种东西,半年后有人问你"当时那条曲线是怎么来的",如果你只能翻出一张结果表,那就等于没留痕。我现在的做法是每次跑完把三样东西一起存下来:输入数据(含数据源和时间戳)、关键参数(天数规则、频率、插值方法、求解区间)、结果表(含每只券的反解YTM和残差)。
残差这一列特别有用。Bootstrap剥离出来的曲线,如果拿回去正算价格,和原始价格的偏差应该极小;如果某只券的残差明显偏大,说明这只券的价格和曲线不一致,可能是数据错,也可能是这只券有特殊性(比如含权、流动性差)。把这列留着,等于给数据质量装了一个监控。
另外,参数一定要写进文件名或者伴随的元数据里。我踩过一次坑:同一个项目跑了ACT/365和ACT/360两版,结果表文件名只差一个后缀,后来合并报表的时候拿错了版本,整条曲线偏了3个基点,查了两天才定位到。
我个人在实际操作中的体会是,这类计算真正的门槛从来不在数学,而在口径和数据质量。公式就那么几个,抄下来十分钟能背完;难的是搞清楚对面要的到底是哪种收益率、价格是全价还是净价、天数规则按谁的惯例。我现在的习惯是先花二十分钟跟需求方把这些确认清楚,写进注释里,再动手写代码——这二十分钟能省下的返工时间,通常是它的十倍以上。