Python 实现即期利率、贴现因子与到期收益率求解
1. 先把概念摆正:即期利率、贴现因子和收益率不是一回事
做固收核算、理财台账或者资产端收益归集的同行,多半被“即期利率”这四个字绊过一下。数据源给的是一条零息曲线,业务方要的是“这笔资产到期到底能给我多少收益”,中间隔着贴现因子、复利口径、计息天数三座小山。我第一次接这类需求时,用表格软件硬怼了两天,公式套公式,改一个参数整张表全红,最后还得手工拿计算器核对两遍才敢交出去。后来把这套东西搬到 Python 上,写成几十行纯函数,再包一层曲线对象,同样的活从两天缩到两分钟,而且每一步都能复现、能回溯、能拿给别人看。
这篇就把这套做法完整摊开:即期利率和贴现因子怎么互转,票息债券怎么反推零息曲线,到期收益率怎么求解,以及中间那些教科书不写、但实际会让人加班到半夜的细节。适合三类人看:做财务核算需要独立验算收益的、做数据分析刚接触固定收益的、以及写量化脚本想找一份能直接改改就用的骨架的。不需要你有多深的数学底子,高中复利公式能看懂就够,剩下的都是工程问题。
1.1 三个词在真实业务里的分工
很多人把“即期利率”和“收益率”当同义词用,这在短端、低息环境下问题不大,一旦曲线陡峭或者期限拉长,两者能差出几十个基点。**即期利率(spot rate)**描述的是“今天把钱放进去,到期一次性拿回来”这种零息交易的年化回报,它对应的是一个个孤立的期限点,1 年期、2 年期、3 年期各有一个,中间没数据。**贴现因子(discount factor)**是即期利率的另一种写法,本质是“未来 1 块钱在今天值多少”,2 年期贴现因子 0.925 就表示两年后的 1 块钱现在值 0.925 块。**到期收益率(YTM)**则是针对一只具体债券,把它的全部现金流(票息加本金)按同一个折现率折到今天的价格上,反解出来的那个折现率。
差别在哪?即期利率是“一价到底”的零息口径,YTM 是“一率折全程”的打包口径。一只 3 年期、年付票息的债券,它的 YTM 其实是 1 年、2 年、3 年三个即期利率的加权混合结果,票息越高,混合权重越偏向短端。所以你会看到同一个发行人、同一天,3 年期零息债的即期利率和 3 年期付息债的 YTM 数值不一样,这不是数据错了,是口径不同。
注意:业务方口头说的“收益率”经常是 YTM,也可能是“持有到期年化”,还可能是“票面利率”,这三个值能差出好几个点。动手写代码之前,先把这一句问清楚,比后面调三天参数都管用。
1.2 为什么这件事值得用 Python 做
表格软件当然能算,YIELD、PRICE、IRR这些函数都在。问题出在可复用性和可审计性上。一只债券一套公式勉强能忍,当你要处理几十上百只、期限从 3 个月到 10 年、计息规则还不统一的时候,表格的公式引用会变成一团意大利面,任何一个中间单元格被误改,结果静默出错,你还不一定发现。
Python 的优势不是算得快,而是把口径固化成代码。复利方式、年化基准、付息频率这些容易被忽略的假设,在代码里必须显式写成参数,写一次,之后所有调用都一致。再加上可以写单元测试、可以批量跑、可以把中间结果(贴现因子、现金流明细)一起落表,出了问题能一层层往回查。我现在的习惯是:任何进报表的收益率数字,都必须由一段可重跑的脚本生成,不接受手工填。
1.3 动手前必须确认的四组输入
写第一行代码之前,有四组信息不确认清楚,后面一定返工:
| 输入项 | 典型取值 | 不问清楚会怎样 |
|---|---|---|
| 复利方式 | 连续复利、年复利、按频率复利、单利 | 同一组贴现因子能算出四种即期利率 |
| 年化基准 | 实际天数/365、实际天数/360、30/360 | 长端偏差可达几个基点 |
| 付息频率 | 年付、半年付、季付 | YTM 与价格对不上 |
| 价格口径 | 全价(含应计利息)、净价 | 差额等于应计利息,短端尤其明显 |
复利方式这一项坑最深。市场报价里,货币市场工具常用单利,债券常用年复利或半年复利,利率互换和部分衍生品用连续复利。四套口径互相换算有闭式公式,但一旦混用,1 年期以内能差 1 到 2 个基点,10 年期能差出十几二十个基点。我在项目里吃过这个亏:上游给的曲线是连续复利,下游报表按年复利展示,没做转换,季度对账的时候被财务追问了三天。
价格口径同样不能含糊。净价是报价,全价才是现金流折现的结果,两者差一个应计利息。如果你拿净价直接去解 YTM,在付息日附近误差小到看不出来,在付息前一天误差最大,容易得出一个“看起来合理”的错数。
2. 计算骨架怎么搭:从数据到结果的分层设计
这套东西写得好不好,不在于公式多花哨,而在于流程会不会咬人。我的原则是分三层:最底下是一组无状态的纯函数,只做数学换算,输入输出都是数字,不碰文件不碰网络;中间是一层曲线对象,把一组期限点和贴现因子捏在一起,负责插值和查询;最上面是报表层,负责读数据、批量算、写结果、留日志。这样分的好处是,底层函数可以单独拿去做单元测试,曲线对象可以换插值方法而不影响上层,报表层想换输出格式也不用动数学。
2.1 三层结构的具体落法
纯函数层只放那些“给同样的输入永远给同样的输出”的东西:贴现因子转即期利率、即期利率转贴现因子、现金流折现、YTM 求根、应计利息计算。这些函数我一般放在一个rates_core.py里,一个函数不超过 30 行,参数全部显式,不用全局变量。
曲线对象这层,其实就是一个类,内部存两个等长列表(期限、贴现因子),对外提供df(t)和spot(t)两个查询方法。中间期限点用线性插值还是对数线性插值,是个需要想清楚的选择。贴现因子在时间上是指数衰减的形状,直接对贴现因子做线性插值,会引入一个微小的、系统性的高估;对ln(DF)做线性插值,等价于假设即期利率在区间内分段线性,这是行业里更常见的做法,误差也小。这个差异在 1 年期以内基本看不出来,在 5 年以上、曲线拐点附近能差小半个基点。
报表层就是胶水,读入债券清单,循环调用曲线对象和求根函数,把 YTM、贴现因子、现金流明细一起吐成一张宽表。这一层我强烈建议把中间结果全部保留,别只输出最后那个百分数。出了问题,能一眼看出是哪只债、哪个期限、哪笔现金流出的偏差,比从头 debug 快十倍。
2.2 依赖选型:numpy 够不够用
结论是够用,而且大多数场景下比引入重型金融库更省心。numpy提供向量化运算和数组操作,scipy.optimize.brentq可以拿来求根,pandas负责表格进出。这三样基本能覆盖 95% 的日常需求。要不要上专门的定价库,取决于你是不是要处理含权债、可转债、结构化产品这些带期权特征的品种,如果只是普通付息债和零息债,自己写反而更透明,出问题知道去哪找。
有一个细节值得提:scipy.optimize.brentq比手写二分法快,但它要求你先给出一个“函数值异号”的区间,否则直接抛异常。很多新手报ValueError: f(a) and f(b) must have different signs,八成是因为区间给得太窄,或者价格输入有问题(比如把净价当全价传进去)。我的习惯是先用二分法跑一遍拿到粗解,再用牛顿法精修,两次结果对不上就报警——这本身就是一道很有效的自检。
2.3 计息天数规则的抽象
计息天数规则(day count convention)看起来是个小问题,实际是核算分歧的高发区。ACT/365 按时历天数除以 365,ACT/360 除以 360,30/360 则把每个月都当 30 天。同一笔 3 个月期的资产,ACT/360 算出来的年化比 ACT/365 高一点点,因为它把分母压小了。
代码上怎么处理?我建议不要去写一个“万能”的日期差函数,而是定义三四个明确的函数,用字符串标记选择,然后在一处集中映射。这样以后要加新规则,只改一个字典,不动业务逻辑。更重要的是,每笔计算都要把用了哪条规则写进结果里,别让它隐身在默认参数中。我见过最典型的翻车是:一批资产混用了两种基准,报表加总的时候没人发现,等到季度末跟托管方对账,差了几万块,回头查了整整一周。
提示:如果你的数据源来自多个渠道,先做一次“同券同日同价格”的交叉验证,把各家的计息基准和复利口径摸清楚再合并,不要假设大家都一样。
3. 贴现因子与即期利率的互转:最容易翻车的一段
这一段的数学很浅,就是复利公式,但它是整套计算的地基,口径错了上面全错。核心关系只有一句:贴现因子等于“未来 1 单位货币按即期利率折回今天的系数”,即期利率就是把贴现因子反解出来的年化利率。麻烦在于,同一组贴现因子,按不同复利假设反解出来的即期利率不一样,数值差异随期限放大。
3.1 三种复利口径的换算公式
设期限为t年(可以是小数),即期利率为r,贴现因子为DF,四套口径分别是:
- 连续复利:
DF = exp(-r * t),反解r = -ln(DF) / t - 年复利:
DF = (1 + r) ** (-t),反解r = DF ** (-1/t) - 1 - 按频率 m 复利:
DF = (1 + r/m) ** (-t*m),反解r = m * (DF ** (-1/(t*m)) - 1) - 单利:
DF = 1 / (1 + r * t),反解r = (1/DF - 1) / t
拿一组具体数字感受一下。假设 2 年期贴现因子是 0.9210,那么连续复利即期利率是-ln(0.9210)/2 ≈ 4.11%,年复利是0.9210**(-1/2) - 1 ≈ 4.20%,单利是(1/0.9210 - 1)/2 ≈ 4.29%。同一组数据,三个数差出 18 个基点。这就是为什么“口径”必须先定,谈绝对数值之前先把尺子对齐。
代码不长,但要把参数校验写进去,否则遇到t = 0或者DF <= 0会直接崩:
import math def df_from_spot(r, t, comp="annual", freq=1): """由即期利率求贴现因子。t 为年数,可为小数。""" if t < 0: raise ValueError("期限不能为负") if comp == "continuous": return math.exp(-r * t) if comp == "annual": return (1.0 + r) ** (-t) if comp == "freq": return (1.0 + r / freq) ** (-t * freq) if comp == "simple": return 1.0 / (1.0 + r * t) raise ValueError(f"未知复利方式: {comp}") def spot_from_df(df, t, comp="annual", freq=1): """由贴现因子反解即期利率。""" if t <= 0: raise ValueError("期限必须为正") if df <= 0: raise ValueError("贴现因子必须为正") if comp == "continuous": return -math.log(df) / t if comp == "annual": return df ** (-1.0 / t) - 1.0 if comp == "freq": return freq * (df ** (-1.0 / (t * freq)) - 1.0) if comp == "simple": return (1.0 / df - 1.0) / t raise ValueError(f"未知复利方式: {comp}")写完这段,第一件事是做往返自检:随机生成一批利率和期限,转成贴现因子再转回来,看是否回到原值。误差应该小于 1e-12,否则公式写错了。这个测试我每次新建模块都会跑,五秒钟的事,能省掉后面两小时的排查。
3.2 年化基准与真实天数带来的偏差
期限t怎么算,是另一个隐形雷区。1 年期按 365 天、按 360 天,t差了 1.4%,折算到利率上就是几个基点。更常见的是付息周期不规则:某只债上一个付息日是 3 月 15 日,下一个是 9 月 15 日,实际天数 184 天,你按 0.5 年算,就有偏差。
我在实际项目里的做法是:所有期限都换算成“按约定基准的年数小数”,而不是简单地用整数年。具体写法是先拿到实际天数差days,再按基准除:ACT/365 就days/365,ACT/360 就days/360,30/360 则用专门的函数算“名义天数”。这样后续所有公式里的t都是同一个含义,不用再考虑特例。
注意:IFRS 或内部核算里如果规定了资产端和负债端用不同基准,别图省事统一成一个。宁可多传一个参数,也别让口径在代码里悄悄漂移。
3.3 曲线对象的插值与查询
有了互转函数,就可以把一条离散的曲线封装起来。插值方式我倾向于对ln(DF)做线性插值,理由是它和“即期利率分段线性”等价,经济含义清楚,也不会在长期限上产生负利率的异常。代码大致长这样:
class ZeroCurve: def __init__(self, tenors, dfs, comp="annual"): if len(tenors) != len(dfs): raise ValueError("期限与贴现因子长度不一致") order = sorted(range(len(tenors)), key=lambda i: tenors[i]) self.t = [tenors[i] for i in order] self.df = [dfs[i] for i in order] self.comp = comp def df_at(self, t): if t <= 0: return 1.0 if t <= self.t[0]: return self.df[0] ** (t / self.t[0]) if t >= self.t[-1]: # 末端按最后一段的隐含即期利率外推 last_r = spot_from_df(self.df[-1], self.t[-1], self.comp) return df_from_spot(last_r, t, self.comp) for i in range(1, len(self.t)): if t <= self.t[i]: t0, t1 = self.t[i - 1], self.t[i] w = (t - t0) / (t1 - t0) ln_df = (1 - w) * math.log(self.df[i - 1]) + w * math.log(self.df[i]) return math.exp(ln_df) return self.df[-1]注意末端外推那一行:曲线内的插值有数据支撑,曲线外的外推就没有了,只能假设最后一段的即期利率延续。这个假设在 1 到 2 年的延长范围内还算能用,超过就该警惕了。写报表的时候,我一般会给外推得到的点打一个标记,方便复核的人知道哪些数是真实数据、哪些是推出来的。
4. 收益率求解的完整实操
前面都是准备工作,这一段才是业务方真正要的那个数。整体流程是:先把债券的现金流序列(金额加上时间点)建出来,再用求根算法找到那个让“现金流折现之和等于市场价格”的利率,也就是 YTM。如果手上是一组不同期限的付息债,还可以反过来推出一条零息曲线,这就是所谓的 bootstrapping(自助法)。
4.1 到期收益率的求解器:二分法与牛顿法的取舍
YTM 求解本质上是一元方程求根,目标函数是价格(ytm) - 市场价 = 0。这个函数在正常范围内单调递减(利率越高,折现值越低),所以二分法几乎不会失败,缺点是收敛慢,通常要迭代 40 到 60 次才能到 1e-12 精度。牛顿法快,5 到 8 次就够,但它依赖导数,而且在久期很小的品种上可能跳出去。
我的实际选择是:默认用二分法,需要跑大批量时再切牛顿法。原因很实在,二分法行为可预测,迭代次数稳定,不会因为某只债的现金流特别而产生异常分支,出错的时候也好排查。批量跑几千只债,二分法多花的那点时间在 Python 里大概是毫秒级,不值得为它引入不确定性。
def bond_price(ytm, cashflows, times, freq=1): """给定 YTM(年化)与现金流,返回全价。""" return sum(cf / (1.0 + ytm / freq) ** (t * freq) for cf, t in zip(cashflows, times)) def solve_ytm(price, cashflows, times, freq=1, lo=-0.95, hi=3.0, tol=1e-12, max_iter=300): """二分法求解到期收益率。""" if price <= 0 or not cashflows: raise ValueError("价格或现金流非法") f = lambda y: bond_price(y, cashflows, times, freq) - price flo, fhi = f(lo), f(hi) if flo * fhi > 0: raise ValueError( f"区间 [{lo}, {hi}] 内无解,检查价格口径与现金流方向: " f"f(lo)={flo:.6f}, f(hi)={fhi:.6f}" ) for _ in range(max_iter): mid = 0.5 * (lo + hi) fm = f(mid) if abs(fm) < tol: return mid if flo * fm < 0: hi, fhi = mid, fm else: lo, flo = mid, fm return 0.5 * (lo + hi)这里有个参数值得说清楚:freq。年付债freq=1,半年付freq=2。它同时影响折现公式和年化口径。同一只债,用freq=1解出来的 YTM 和用freq=2解出来的不一样,后者通常略小。这不是 bug,是两种年化约定,报出去的时候必须注明用的是哪种。国内不少报表习惯年付口径,但银行间一些品种按半年付报价,混着用迟早出事。
4.2 一个能对得上号的完整算例
拿一只具体债券走一遍:面值 100,票面利率 4%(年付),剩余期限整 3 年,当前全价 98.5,求 YTM。
现金流是[4, 4, 104],时间点是[1, 2, 3],freq=1。调用solve_ytm(98.5, [4,4,104], [1,2,3], freq=1),二分法会给到约4.547%。手工验算一遍:4/1.04547 + 4/1.093007 + 104/1.142701,三项分别是 3.82603、3.65960、91.01414,加起来 98.4998,和 98.5 差 0.0002,是四舍五入带来的,可接受。
这一步顺便验证了另一件事:如果你拿净价去解,得到的数会不一样。假设应计利息是 1.2,那全价就是 99.7 而不是 98.5,解出来的 YTM 约 3.72%,差了 80 多个基点。所以价格口径这件事,真的是差之毫厘谬以千里。
4.3 用票息债券反推即期曲线(Bootstrapping)
单只债券求 YTM 不算难,真正有价值的是从一组不同期限的债券里,把零息曲线剥出来。逻辑是逐段推进:最短那期如果是零息债,贴现因子直接可求;下一期用已知的短端贴现因子把中间票息折现掉,剩下的残值对应最后一笔现金流,反解出这一期的贴现因子;依次往前推。
def bootstrap_curve(bonds, face=100.0, freq=1): """ bonds: 按到期年限排序的列表,元素为 dict: {"maturity": 整数年, "coupon": 每百元面值年票息, "price": 全价} 返回每期的贴现因子与即期利率(年复利)。 """ bonds = sorted(bonds, key=lambda b: b["maturity"]) dfs, curve = [], [] for b in bonds: n, c, p = b["maturity"], b["coupon"], b["price"] pv_coupon = sum(c * dfs[t - 1] for t in range(1, n)) df_n = (p - pv_coupon) / (face + c) if df_n <= 0: raise ValueError(f"{n} 年期贴现因子非正,检查价格") dfs.append(df_n) curve.append({ "tenor": n, "df": df_n, "spot": df_n ** (-1.0 / n) - 1.0, }) return curve拿三只债试一下:
| 期限 | 票息 | 全价 | 反推贴现因子 | 即期利率(年复利) |
|---|---|---|---|---|
| 1 年 | 0 | 97.5 | 0.975000 | 2.5641% |
| 2 年 | 3 | 98.2 | 0.925000 | 3.9750% |
| 3 年 | 4 | 99.0 | 0.878846 | 4.3988% |
第一行零息债,贴现因子就是97.5/100 = 0.975,即期利率1/0.975 - 1 = 2.5641%。第二行,先扣掉 1 年期票息的影响:3 × 0.975 = 2.925,残值98.2 - 2.925 = 95.275,对应 2 年后的 103 块,DF = 95.275/103 = 0.9250,开根号倒数就是 3.9750%。第三行同理:4 × 0.975 + 4 × 0.925 = 7.6,残值99 - 7.6 = 91.4,除 104 得 0.878846,开三次方倒数为 4.3988%。
为了让读者能直接复现,我把校验也写一下:用推出来的曲线给一只 4 年期、票息 5、全价 102 的债定价,看是不是自洽。这里需要 4 年期贴现因子,得先假设一个值或者用外推,实际报表里我一般会明确标注“该点由外推得到”。
注意:bootstrapping 对数据质量极其敏感。如果某只债的价格是三天前的,剥出来的那一期贴现因子就会被污染,而且错误会往长端传导。我的习惯是先做一遍数据体检:检查期限是否唯一、价格是否在合理区间(比如 80 到 120)、票息与市场水平是否明显脱节,任何一项不过就直接剔除并记日志。
4.4 结果自检的三个口径
数字算出来,别急着提交。我固定跑三道自检:
第一道,往返一致性。用解出的 YTM 反算价格,和输入价格比对,误差应在 1e-8 以内。这一道能抓住大部分公式错误。
第二道,单调性检查。同一组债券,久期越长、票息越低的,YTM 与其他指标之间的关系应该大体符合直觉。如果出现“10 年期 YTM 明显低于 1 年期”这种极端倒挂,先别急着下结论说市场就是这样的,先检查数据。
第三道,与外部基准对拍。挑两三只标准品种,用另一套独立方法(比如表格软件的现成函数)算一遍,看差异是否可解释。差异本身不可怕,可怕的是不知道为什么差。
5. 常见问题与排查实录
这一段是我踩过的坑的汇总,也是这篇里最不像文档的部分。代码能抄,坑得自己踩,但下面这些至少能让你少踩几个。
5.1 求解器不收敛,或者报“区间内无解”
这是最高频的报错,原因按出现频率排下来大概是:价格口径用了净价(应计利息没加)、现金流方向写反(把付出去的钱当收进来的钱)、区间设得太窄、票息为负但没处理、期限换算成了错误的年数。
排查顺序建议这么走:先把现金流列表打印出来,看最后一笔是不是“本金加最后一期票息”,这是最容易漏的;再手动算一个极端利率下的价格,比如 ytm = 0 时价格应该是所有现金流之和,如果这个都对不上,说明现金流建错了;最后检查区间,lo设成 -0.95 通常够宽了,但如果遇到深度负利率或者超长久期品种,可能需要放宽。
还有一种隐蔽情况:某只债的现金流里同时有常规票息和摊销本金,你按标准付息债的模板建现金流,结果当然解不出来。这类品种要单独走一个分支,别硬套。
5.2 和表格软件算出来的数差一截
差几个基点通常是口径问题,差几十个基点通常是数据问题。我做过一个对照清单:
| 差异幅度 | 优先怀疑 | 怎么验证 |
|---|---|---|
| 小于 1 个基点 | 复利方式、年化基准 | 换成相同基准重算 |
| 1 到 20 个基点 | 计息天数、付息频率 | 检查freq与t的计算 |
| 20 个基点以上 | 价格含息与否、现金流建错 | 打印现金流与全价核对 |
| 数值符号相反 | 现金流方向 | 检查是否把买入当卖出 |
我印象最深的一次,是某批资产的 YTM 整体比对方低了 15 个基点,查了两天,最后发现是双方对“一年按 365 天还是 360 天”理解不同。改一个分母,全对上了。这类问题没有技术含量,但极其耗时,所以我现在第一版代码就把基准做成显式参数,并且在输出表里专门加一列写清楚用的是哪条规则。
5.3 极端行情下的负数与零息
负利率不是理论上的东西,短端出现负值已经不算罕见。代码上会碰到两个坑:一是(1 + r) ** (-t)在r < -1时没有实数意义,虽然现实中不会到 -100%,但你得防住异常输入;二是某些品种的票息为零,bootstrap_curve里除数是face + c = 100,没问题,但如果面值不是 100 就要注意别写死。
我的处理方式是加两个断言:即期利率必须大于 -1,贴现因子必须在 (0, 1] 之间(严格说也可以大于 1,但那意味着负利率,需单独标记)。断言不是用来防止别人使坏,而是用来在数据出错的第一时间把问题暴露出来,别让它一路传到报表里变成看起来正常的数字。
5.4 排查速查表
把这几年遇到的问题浓缩成一张表,出问题的时候按顺序过一遍:
| 现象 | 可能原因 | 处理 |
|---|---|---|
| 求根函数抛“无解” | 价格口径、现金流方向、区间 | 打印现金流,先验 0 利率下的价格 |
| 结果每次跑都不一样 | 用了未排序的字典或随机种子 | 检查数据加载顺序 |
| 长端利率明显不合理 | 数据陈旧、外推过头 | 加时间戳校验,外推点单独标记 |
| 批量结果里零星异常值 | 单只债的现金流特殊 | 加逐只校验,异常单独输出 |
| 与对手方对不上账 | 基准、复利、含息口径 | 逐项对表,从最小差异开始排 |
6. 让它变成能长期用的工具
脚本能跑通只是第一步,能长期用、别人接手也不出事,还得补几块工程细节。这部分不涉及金融知识,纯是让代码别在半年后坑自己。
6.1 向量化与批量计算
单只债的求根用循环没问题,几百只也扛得住。如果真的到几万只,二分法的循环会成为瓶颈。这时候可以把求根过程向量化:一次性对所有债券、所有迭代步做数组运算,用numpy的掩码来收缩区间。实测下来,几万只债的求解时间能从几十秒压到一秒以内。
但我要提醒一句:先确认真的需要。我在项目里见过为了“性能”把代码写成一行嵌套向量运算,结果没人看得懂,出了错也没法调。真实业务里,能一次处理几千只的场景已经很少,循环加一点进度条完全够用。性能优化留到有明确瓶颈的时候再做,别提前给自己挖坑。
6.2 单元测试怎么写才有效
测试不是越多越好,是越准越好。我固定写四类:
- 换算对拍:贴现因子和即期利率互转,往返误差小于 1e-12。
- 已知答案:拿一只现金流极简单的债(比如 1 年期零息),YTM 应该等于
100/价格 - 1,代码结果必须一致。 - 边界行为:价格等于所有现金流之和时,YTM 应该约等于 0;价格高于面值时,YTM 应低于票面利率。
- 异常输入:价格为负、期限为零、现金流为空,必须抛异常而不是返回一个数。
第三类最有价值,因为它验证的是“经济直觉”,而不只是代码逻辑。代码写得再漂亮,如果价格 110 的债解出来的 YTM 高于票息,那肯定哪里错了。
6.3 结果落库与可追溯
最后一件事,也是最容易被忽略的:每次计算都要留下可追溯的痕迹。我会在输出表里固定带几列:计算时间戳、数据源标识、复利口径、年化基准、付息频率、是否含息。这些字段平时看起来冗余,真正对账的时候就是救命的东西。
再加一条,所有中间结果单独存一份:每只债的现金流明细、每个期限的贴现因子。这样半年后有人问“这个 4.39% 是怎么来的”,你能直接把链路调出来,而不是重新跑一遍还不一定对得上。
提示:如果这套计算要跑在定时任务上,把“输入数据校验”放在最前面,任何一只债的数据不过关就整批中止并报警,别让半批脏数据流进报表。宁可不跑,也别跑出错的数。
我个人在实际操作中的体会是,这类计算的难点从来不在数学,而在于把口径讲清楚、把假设写进代码、把中间过程留痕。公式就那么几个,真正让人加班的是“这个数为什么和别人的不一样”。等到某天有人拿着报表问你某个数字的来历,你能在两分钟内调出完整链路,这套东西才算真正做成了。