# vectorbt 1.1.2：NaN 如何让 CVaR 风险指标失真？从样本分母到测试断言

> Canonical: https://vxi.me/posts/vectorbt-nan-cvar-tail-sample-test-assertions/
> Locale: zh-cn
> Published: 2026-10-18
> Updated: 2026-10-18
> Markdown: https://vxi.me/posts/vectorbt-nan-cvar-tail-sample-test-assertions.md

做量化策略评估时，看到一列收益里有 `NaN`，很容易把它当成“后面再清理”的数据质量问题。如果程序还照常输出 CVaR（Conditional Value at Risk，条件风险价值），我们甚至不一定会注意到这条缺失记录。

但它可能已经改变了结果，而且问题不只是均值里混进一个空值：**尾部到底取几条收益，也可能跟着变。**

我对照了 [vectorbt v1.1.1](https://github.com/polakowo/vectorbt/releases/tag/v1.1.1) 与 [v1.1.2](https://github.com/polakowo/vectorbt/releases/tag/v1.1.2) 的固定源码。用同一份包含 20 条有效收益、1 条 `NaN` 的合成样本复算：**旧版规则按原始长度 `n=21` 计算，左尾均值为 -4.6667%；新版规则先去掉 `NaN`，按有效长度 `n=20` 计算，左尾均值为 -5.5000%**（截断比例 10%）。最差的两条有效收益没变，改变的是参与尾部平均的样本数量。

这里的两项证据要分开看：源码差异来自上游固定版本；数值输出来自自编合成测试，**没有安装或运行 vectorbt、Numba 或 Rust，也没有使用真实市场数据**。它证明的是风险指标实现中的一个可复算陷阱，不代表某个实盘策略的 CVaR 曾经偏低。

## 多一个 NaN，为什么会多选一条亏损收益

vectorbt 这段实现用的是“收益左尾的平均值”，而不是把亏损幅度改成正数的展示口径。对于截断比例 `alpha`，它先计算：

```text
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`](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/vectorbt/returns/nb.py) 与 [`rust/src/returns.rs`](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/rust/src/returns.rs)，还通过 GitHub 标签引用确认：**`v1.1.2` 标签直接指向提交 [`f0d2afba7af8a6e6c1b02afde27c9a16413e86ff`](https://github.com/polakowo/vectorbt/commit/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff)**；对照标签 `v1.1.1` 指向 `4f22e370e6f75717d50cc8eab4adc4b37ccbbbce`。这里的标签和对应提交不是两个不同的代码版本。

在自己的安装中，可以从 [`ReturnsAccessor.cond_value_at_risk(cutoff=..., engine=...)`](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/vectorbt/returns/accessors.py#L900-L912) 追到 [`vectorbt.returns.dispatch.cond_value_at_risk`](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/vectorbt/returns/dispatch.py#L847-L867)，最终对应 Numba 的 `cond_value_at_risk_1d_nb(returns, cutoff=0.05)` 或 Rust 的 `cond_value_at_risk_1d(returns, cutoff)`；滚动路径同样调用单序列实现。Numba 端的关键差异如下（省略签名和 JIT 装饰器）：

```diff
 # 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 同样如此。[旧版](https://github.com/polakowo/vectorbt/blob/v1.1.1/rust/src/returns.rs)先 `returns.to_vec()`，对比较器里的 `NaN` 做末尾排序处理，但索引仍以原长度计算；[新版](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/rust/src/returns.rs)先 `filter(|value| !value.is_nan())`，再决定索引和选取尾部。这意味着改动并不是仅针对 Python 表层补一个 `dropna`，而是同时涉及两条计算后端。

排序前提也需要说清：**Numba 走的是 `np.partition`，不是完整的 `np.sort`**；[NumPy 官方文档](https://numpy.org/doc/2.2/reference/generated/numpy.partition.html)规定 `NaN` 在分区比较顺序中位于普通数值之后，旧版 Rust 则在比较器中显式把 `NaN` 排到有效数值后面。因此本例多出的 `NaN` 虽没有被选进最差三条收益，却让原始长度计算出的尾部截断索引多了 1。

这里的过滤条件是 `isnan`，**不是 `isfinite`**：`+Inf` 和 `-Inf` 的语义不能想当然地视为“也已被新版清理”。对于风险统计，缺失值、无穷大和真实的 0% 收益是三个不同问题。

上游的 [`tests/test_returns.py`](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/tests/test_returns.py) 增加了带 `NaN` 与先清洗结果的相等性检查、全 `NaN` 与空数组、滚动窗口等覆盖；Rust 对照测试附有运行时可用性的跳过条件。**这些都是上游固定版本中可直接核对的测试用例。** 对应变更可见 [PR #879](https://github.com/polakowo/vectorbt/pull/879)。

## 更隐蔽的坑：测试辅助函数自己就可能误判

CVaR 的尾部样本数需要核对，验证它的数值断言也不能默认可信。同一次 [v1.1.2 Release](https://github.com/polakowo/vectorbt/releases/tag/v1.1.2) 还合并了另一项独立修复：[PR #878](https://github.com/polakowo/vectorbt/pull/878) 修正了 `tests/utils.py` 中的 `isclose` 辅助函数。

[旧版源码](https://github.com/polakowo/vectorbt/blob/v1.1.1/tests/utils.py)曾有这样的提前返回判断：

```python
if np.isnan(a) == np.isnan(b):
    return True
```

代入两个普通有限数 `1.0` 和 `2.0`，`np.isnan` 都是 `False`，条件居然成立，直接返回 `True`。类似地，旧实现后续的 `np.isinf(a) == np.isinf(b)` 也存在“两个都不是无穷大就相等”的判断陷阱。结果是：本该比较容差的路径可能根本到不了。

[修正后的实现](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/tests/utils.py)改为：有任一 `NaN` 就检查是否两者都是 `NaN`；有任一无穷大则使用真实相等性；普通有限数最后做绝对/相对容差比较。下面的合成脚本只验证旧版第一条提前返回，不重写整个上游辅助函数。

两个修复对应两道不同的校验：CVaR 必须**选对尾部样本**，测试辅助函数必须**真的比较数值**。它们都进入了 v1.1.2，但没有证据表明 CVaR 的缺陷此前就是被这个 `isclose` 问题掩盖。

## 用完整 Python 复算一遍，包括反例

我把这两处问题放进同一份零依赖脚本：`tail_mean` 按旧版 Rust 的 `NaN` 置后规则模拟尾部选择，对照原始样本数与有效样本数；`old_isclose` 单独复现旧版测试助手的提前返回。

下面是 **Python 3.13.5 实际执行过的完整脚本**，`CLEAN` 中包含 20 条合成收益；五组 `unittest` 同时检查无缺失值、`NaN` 位置变化、全缺失值和旧测试助手等边界。

```python
"""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 环境执行：

```bash
python3 cvar_nan_demo.py
```

实际记录的标准输出如下（退出码 `0`）：

```text
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` 可能表示数据供应商没有提供价格、交易停牌后的缺口、标的未上市、收益尚未结算，或者纯粹是清洗管道的缺陷。删除缺失值解决了**实现的样本计数一致性**，不自动解决“为什么缺失”这个统计问题。尤其当亏损最严重的时段更容易缺数据时，直接丢掉样本也可能改变风险解释。

这和此前讨论[重复选股如何改变胜率分母](/posts/quant-winrate-repeated-stock-picks/)有相似的工程原则：分母不只是一个写在公式里的数字，它要跟实际被统计的观察对象一一对应。前一篇统计的是“入选事件、不同标的和入选日期”，这里则要分清“原始收益槽位”和“真正参与左尾排序的收益值”。

如果要把结论从源码分析推进到真实 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` 为版本锚点；教学样本非真实行情、非实盘回测；仅作量化工程与测试方法讨论，不构成投资建议。*
