vectorbt 1.1.2:NaN 如何让 CVaR 风险指标失真?从样本分母到测试断言
做量化策略评估时,看到一列收益里有 NaN,很容易把它当成“后面再清理”的数据质量问题。如果程序还照常输出 CVaR(Conditional Value at Risk,条件风险价值),我们甚至不一定会注意到这条缺失记录。
但它可能已经改变了结果,而且问题不只是均值里混进一个空值:尾部到底取几条收益,也可能跟着变。
我对照了 vectorbt v1.1.1 与 v1.1.2 的固定源码。用同一份包含 20 条有效收益、1 条 NaN 的合成样本复算:旧版规则按原始长度 n=21 计算,左尾均值为 -4.6667%;新版规则先去掉 NaN,按有效长度 n=20 计算,左尾均值为 -5.5000%(截断比例 10%)。最差的两条有效收益没变,改变的是参与尾部平均的样本数量。
这里的两项证据要分开看:源码差异来自上游固定版本;数值输出来自自编合成测试,没有安装或运行 vectorbt、Numba 或 Rust,也没有使用真实市场数据。它证明的是风险指标实现中的一个可复算陷阱,不代表某个实盘策略的 CVaR 曾经偏低。
多一个 NaN,为什么会多选一条亏损收益
vectorbt 这段实现用的是“收益左尾的平均值”,而不是把亏损幅度改成正数的展示口径。对于截断比例 alpha,它先计算:
k = int((n - 1) * alpha)
signed_tail_mean = mean(按从小到大排序后前 k+1 条收益)
代码中 int 对这里的非负索引相当于向下取整。因此,决定截断位置的 n 应当是参与排序和平均的有效样本数。如果 n 仍然包含一个本应跳过的 NaN,临界点附近就可能选多一条有效收益。
教学脚本的 alpha = 0.10 对应上游函数的参数 cutoff=0.10,刻意选这个值来显露截断分母的差异。底层函数的默认值是 cutoff=0.05;访问器不显式传参时则从 self.defaults["cutoff"] 取值。数据是人为构造的 20 条十进制收益率,其中最差三条分别为 -7%、-4%、-3%,另插入一条 NaN。
| 同一份合成收益,改变的只是计数规则 | n | k | 参与平均的收益 | 带符号尾部均值 |
|---|---|---|---|---|
| 原始长度计数(模拟旧版) | 21 | 2 | -7%、-4%、-3% | -4.6667% |
| 先去掉 NaN(模拟新版) | 20 | 1 | -7%、-4% | -5.5000% |
| 完全没有 NaN 的对照组 | 20 | 1 | -7%、-4% | -5.5000% |
只要把第三条 -3% 也算进尾部均值,亏损最严重部分的平均值就会被拉向零;在这个例子里,相差约 0.83 个百分点。注意,这不是断言“旧版本一律低估风险”:换一套样本数、截断比例、缺失位置,结果可能不变,也可能被 NaN 直接污染。小样本与离散 k 本来就会让尾部估计敏感。
固定源码对照:Numba 与 Rust 都改变了计算顺序
核对了两个版本的 vectorbt/returns/nb.py 与 rust/src/returns.rs,还通过 GitHub 标签引用确认:v1.1.2 标签直接指向提交 f0d2afba7af8a6e6c1b02afde27c9a16413e86ff;对照标签 v1.1.1 指向 4f22e370e6f75717d50cc8eab4adc4b37ccbbbce。这里的标签和对应提交不是两个不同的代码版本。
在自己的安装中,可以从 ReturnsAccessor.cond_value_at_risk(cutoff=..., engine=...) 追到 vectorbt.returns.dispatch.cond_value_at_risk,最终对应 Numba 的 cond_value_at_risk_1d_nb(returns, cutoff=0.05) 或 Rust 的 cond_value_at_risk_1d(returns, cutoff);滚动路径同样调用单序列实现。Numba 端的关键差异如下(省略签名和 JIT 装饰器):
# v1.1.1
if len(returns) == 0:
return np.nan
cutoff_index = int((len(returns) - 1) * cutoff)
# v1.1.2
+returns = returns[~np.isnan(returns)]
if len(returns) == 0:
return np.nan
cutoff_index = int((len(returns) - 1) * cutoff)
两版随后都调用 np.partition 并取前 k + 1 条的平均值。公式没变,公式接收到的数组变了。 旧实现只检查原数组是否为空;新版先过滤缺失值,再检查有效数组是否为空。全 NaN 输入在两个版本中最终都会返回 NaN;新版的区别是显式通过“有效样本为空”分支返回,不再把无效样本送进尾部统计。
Rust 同样如此。旧版先 returns.to_vec(),对比较器里的 NaN 做末尾排序处理,但索引仍以原长度计算;新版先 filter(|value| !value.is_nan()),再决定索引和选取尾部。这意味着改动并不是仅针对 Python 表层补一个 dropna,而是同时涉及两条计算后端。
排序前提也需要说清:Numba 走的是 np.partition,不是完整的 np.sort;NumPy 官方文档规定 NaN 在分区比较顺序中位于普通数值之后,旧版 Rust 则在比较器中显式把 NaN 排到有效数值后面。因此本例多出的 NaN 虽没有被选进最差三条收益,却让原始长度计算出的尾部截断索引多了 1。
这里的过滤条件是 isnan,不是 isfinite:+Inf 和 -Inf 的语义不能想当然地视为“也已被新版清理”。对于风险统计,缺失值、无穷大和真实的 0% 收益是三个不同问题。
上游的 tests/test_returns.py 增加了带 NaN 与先清洗结果的相等性检查、全 NaN 与空数组、滚动窗口等覆盖;Rust 对照测试附有运行时可用性的跳过条件。这些都是上游固定版本中可直接核对的测试用例。 对应变更可见 PR #879。
更隐蔽的坑:测试辅助函数自己就可能误判
CVaR 的尾部样本数需要核对,验证它的数值断言也不能默认可信。同一次 v1.1.2 Release 还合并了另一项独立修复:PR #878 修正了 tests/utils.py 中的 isclose 辅助函数。
旧版源码曾有这样的提前返回判断:
if np.isnan(a) == np.isnan(b):
return True
代入两个普通有限数 1.0 和 2.0,np.isnan 都是 False,条件居然成立,直接返回 True。类似地,旧实现后续的 np.isinf(a) == np.isinf(b) 也存在“两个都不是无穷大就相等”的判断陷阱。结果是:本该比较容差的路径可能根本到不了。
修正后的实现改为:有任一 NaN 就检查是否两者都是 NaN;有任一无穷大则使用真实相等性;普通有限数最后做绝对/相对容差比较。下面的合成脚本只验证旧版第一条提前返回,不重写整个上游辅助函数。
两个修复对应两道不同的校验:CVaR 必须选对尾部样本,测试辅助函数必须真的比较数值。它们都进入了 v1.1.2,但没有证据表明 CVaR 的缺陷此前就是被这个 isclose 问题掩盖。
用完整 Python 复算一遍,包括反例
我把这两处问题放进同一份零依赖脚本:tail_mean 按旧版 Rust 的 NaN 置后规则模拟尾部选择,对照原始样本数与有效样本数;old_isclose 单独复现旧版测试助手的提前返回。
下面是 Python 3.13.5 实际执行过的完整脚本,CLEAN 中包含 20 条合成收益;五组 unittest 同时检查无缺失值、NaN 位置变化、全缺失值和旧测试助手等边界。
"""Synthetic CVaR tail-mean demo; source-inspection model, not vectorbt runtime."""
import math
import sys
import unittest
# All 20 returns below are fictional decimal returns. NaN is NOT zero.
CLEAN = [
-0.07, -0.04, -0.03, -0.02, -0.01, 0.0, 0.01, 0.02, 0.03, 0.0,
-0.02, 0.01, 0.02, 0.03, 0.01, 0.0, 0.02, 0.01, 0.0, 0.04,
]
DIRTY = CLEAN[:8] + [float('nan')] + CLEAN[8:]
ALPHA = 0.10 # Deliberate teaching cutoff; upstream default is 0.05.
def tail_mean(data, alpha=ALPHA, *, drop_nan):
"""Model v1.1.1 raw-count vs v1.1.2 valid-count tail selection.
Returns (signed tail mean, cutoff_index, selected tail values).
The original Rust comparison puts NaN last; this is a stdlib model.
"""
values = [v for v in data if not math.isnan(v)] if drop_nan else list(data)
if not values:
return float('nan'), None, []
ordered = sorted(values, key=lambda v: (math.isnan(v), 0 if math.isnan(v) else v))
k = int((len(values) - 1) * alpha)
selected = ordered[:k + 1]
return sum(selected) / len(selected), k, selected
def old_isclose(a, b):
# Models the v1.1.1 tests/utils.py early-return bug only.
if math.isnan(a) == math.isnan(b):
return True
return math.isclose(a, b, rel_tol=1e-6)
class SyntheticChecks(unittest.TestCase):
def test_old_new_tail_count(self):
self.assertAlmostEqual(tail_mean(DIRTY, drop_nan=False)[0], -0.04666666666666667)
self.assertAlmostEqual(tail_mean(DIRTY, drop_nan=True)[0], -0.055)
self.assertEqual(tail_mean(DIRTY, drop_nan=False)[1], 2)
self.assertEqual(tail_mean(DIRTY, drop_nan=True)[1], 1)
def test_no_nan_control(self):
self.assertAlmostEqual(tail_mean(CLEAN, drop_nan=False)[0], -0.055)
self.assertAlmostEqual(tail_mean(CLEAN, drop_nan=True)[0], -0.055)
def test_nan_position_invariance(self):
for i in (0, 8, 20):
values = CLEAN[:i] + [float('nan')] + CLEAN[i:]
self.assertAlmostEqual(tail_mean(values, drop_nan=True)[0], -0.055)
def test_empty_and_all_nan(self):
self.assertTrue(math.isnan(tail_mean([], drop_nan=True)[0]))
self.assertTrue(math.isnan(tail_mean([float('nan')] * 3, drop_nan=True)[0]))
def test_test_helper_false_positive(self):
self.assertTrue(old_isclose(1.0, 2.0))
self.assertFalse(math.isclose(1.0, 2.0, rel_tol=1e-6))
if __name__ == '__main__':
old, old_k, old_tail = tail_mean(DIRTY, drop_nan=False)
new, new_k, new_tail = tail_mean(DIRTY, drop_nan=True)
print('SYNTHETIC_INPUT: 20 valid returns + one NaN; alpha=0.10')
print(f'RAW_COUNT: n={len(DIRTY)}, k={old_k}, tail={old_tail}, signed_mean={old:.8f}')
print(f'VALID_COUNT: n={len(CLEAN)}, k={new_k}, tail={new_tail}, signed_mean={new:.8f}')
print(f'NO_NAN_CONTROL: raw={tail_mean(CLEAN, drop_nan=False)[0]:.8f}, '
f'valid={tail_mean(CLEAN, drop_nan=True)[0]:.8f}')
print(f'OLD_ISCLOSE_FALSE_POSITIVE: isclose(1.0,2.0)={old_isclose(1.0, 2.0)}')
suite = unittest.defaultTestLoader.loadTestsFromTestCase(SyntheticChecks)
result = unittest.TextTestRunner(stream=sys.stdout, verbosity=1).run(suite)
print(f'CHECKS_PASS={result.testsRun - len(result.failures) - len(result.errors)}/{result.testsRun}')
sys.exit(0 if result.wasSuccessful() else 1)
运行方式是将上述完整代码保存为 cvar_nan_demo.py,在 Python 3 环境执行:
python3 cvar_nan_demo.py
实际记录的标准输出如下(退出码 0):
SYNTHETIC_INPUT: 20 valid returns + one NaN; alpha=0.10
RAW_COUNT: n=21, k=2, tail=[-0.07, -0.04, -0.03], signed_mean=-0.04666667
VALID_COUNT: n=20, k=1, tail=[-0.07, -0.04], signed_mean=-0.05500000
NO_NAN_CONTROL: raw=-0.05500000, valid=-0.05500000
OLD_ISCLOSE_FALSE_POSITIVE: isclose(1.0,2.0)=True
.....
----------------------------------------------------------------------
Ran 5 tests in 0.000s
OK
CHECKS_PASS=5/5
五组检查中,三个负控尤其关键:没有 NaN 时两种规则同值;移动 NaN 的位置不影响新版结果;有效数据为空时返回 NaN 而不是 0。 它们约束了样本选择逻辑,而 5/5 只代表这组独立合成检查。
真正做风险指标 QA 时,还要查什么
先把这类问题当成指标计算的语义契约,而不是拿一个新的风险数值就宣称模型更安全。对每条风险统计结果,最好能够追查 n_raw、n_valid、n_nan、收益率单位、alpha、滚动窗口大小及有效观测的最小要求;如果启用了可选 Rust 后端,还应当用同一输入和容差规则比较计算结果,而不是假设有两个后端就天然一致。
另一个容易混淆的地方是:NaN 可能表示数据供应商没有提供价格、交易停牌后的缺口、标的未上市、收益尚未结算,或者纯粹是清洗管道的缺陷。删除缺失值解决了实现的样本计数一致性,不自动解决“为什么缺失”这个统计问题。尤其当亏损最严重的时段更容易缺数据时,直接丢掉样本也可能改变风险解释。
这和此前讨论重复选股如何改变胜率分母有相似的工程原则:分母不只是一个写在公式里的数字,它要跟实际被统计的观察对象一一对应。前一篇统计的是“入选事件、不同标的和入选日期”,这里则要分清“原始收益槽位”和“真正参与左尾排序的收益值”。
如果要把结论从源码分析推进到真实 vectorbt 行为,还需要在隔离环境中分别安装固定的 v1.1.1/v1.1.2,执行官方 Numba 测试;有合适的 vectorbt-rust 版本再做双后端对照,并记录版本、平台、原始输入和逐窗口结果。这一步才能检验两条计算后端的实际一致性。
这次修复真正有价值的地方,不是 -4.67% 和 -5.50% 哪个更接近“真实市场风险”,而是提醒我们:在讨论策略有没有优势之前,要先确认风险统计有没有使用正确的有效样本集,以及负责验证它的断言是否真的在比较数值。
附注 / 证据时间:vectorbt v1.1.2 发布于 2026 年 10 月 9 日;本次源码核对和 Python 3.13.5 合成实验记录于 2026 年 10 月 18 日。文章以固定提交 f0d2afba7af8a6e6c1b02afde27c9a16413e86ff 为版本锚点;教学样本非真实行情、非实盘回测;仅作量化工程与测试方法讨论,不构成投资建议。