# vectorbt 1.1.2：一個 NaN 如何讓 CVaR 風險值失真？從尾端樣本數到測試斷言

> Canonical: https://vxi.me/zh-tw/posts/vectorbt-nan-cvar-tail-sample-test-assertions/
> Locale: zh-tw
> Published: 2026-10-18
> Updated: 2026-10-18
> Markdown: https://vxi.me/zh-tw/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 做最小復算。在 10% 尾端截斷下，**舊規則按原始長度 `n=21` 得到 -4.6667%，新規則按有效長度 `n=20` 得到 -5.5000%**。最差的兩筆有效報酬完全沒變，變的是要平均的筆數。

證據分成兩部分：版本差異來自上游 Numba 與 Rust 原始碼，數值來自自行執行的 Python 標準函式庫合成模型。**沒有執行 vectorbt、Numba 或 Rust 本體，也沒有使用真實市場資料回測**。

## 一筆 NaN 如何多選一筆虧損

這裡的 CVaR 是**報酬左尾的帶符號平均值**，不是把虧損改成正數。以 `alpha` 表示尾端截斷比例：

```text
k = int((n - 1) * alpha)
signed_tail_mean = mean(按从小到大排序后前 k+1 条收益)
```

`k` 為非負索引，`int` 相當於向下取整。若用原始陣列長度作為 `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%** |
| 新版，有效筆數 | 20 | 1 | -7%、-4% | **-5.5000%** |
| 無 NaN 對照 | 20 | 1 | -7%、-4% | **-5.5000%** |

多納入 -3%，讓本例左尾均值向零移動約 **0.83 個百分點**。這不表示舊版必然低估所有風險；影響取決於樣本數、缺失模式與離散截斷邊界。

## 固定版本原始碼：Numba 與 Rust

GitHub 的 `v1.1.2` 標籤直接對應提交 [`f0d2afba7af8a6e6c1b02afde27c9a16413e86ff`](https://github.com/polakowo/vectorbt/commit/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff)，而 v1.1.1 指向另一個較早提交，因此不會被後續 `main` 變化影響。

在自己的環境裡，可以從 [`ReturnsAccessor.cond_value_at_risk`](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)`。滾動 CVaR 也透過單序列計算。

在 [Numba 的 nb.py](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/vectorbt/returns/nb.py#L634-L642) 中，省略函式簽名後，關鍵差異如下：

```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)
```

**公式沒變，輸入陣列變了。** 新版先過濾 `NaN`，再判斷有效樣本是否為空。全部都是 NaN 時，舊版和新版最終**都回傳 NaN**；真正重要的是有效值與缺值混在一起時的計數方式。

Rust 的[舊版](https://github.com/polakowo/vectorbt/blob/v1.1.1/rust/src/returns.rs#L764-L776)先複製原陣列，以比較器把 NaN 放在有限數值後面，但仍按原長度計算 `cutoff_index`；[新版](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/rust/src/returns.rs#L763-L770)先以 `!value.is_nan()` 過濾。

Numba 路徑實際使用 `np.partition` 而不是完整的 `np.sort`。[NumPy 文件](https://numpy.org/doc/2.2/reference/generated/numpy.partition.html)也將 NaN 排在一般數值之後，因此本例的 NaN 不會進入最差三筆，卻改變舊版的截斷索引。注意過濾條件是 `isnan`，**不是 `isfinite`**；不能認為正負無窮大也已移除。

[上游回歸測試](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/tests/test_returns.py#L521-L564)涵蓋 NaN、空陣列、全 NaN、滾動視窗以及可選 Rust 比對；我查閱的是測試原始碼，並非本機執行的上游測試結果。[PR #879](https://github.com/polakowo/vectorbt/pull/879)有對應修復。

## 另一個獨立錯誤：isclose 測試輔助函式

即使 CVaR 選對樣本，測試的數值斷言也可能失效。同版的另一個 [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#L9-L16)開頭寫著：

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

如果傳入 `1.0` 和 `2.0`，`np.isnan` 兩次都回傳 `False`，所以 `False == False` 是 `True`，在進入容差檢查前就直接判定相近。舊版的 `np.isinf(a) == np.isinf(b)` 也有類似問題。[修正版](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/tests/utils.py#L9-L14)先處理真正的 NaN／無窮大，再用容差比較有限數。

這是與 CVaR **互相獨立的第二項修正**，並沒有證據證明 CVaR 缺陷正是被此測試錯誤掩蓋。要同時檢查「選對尾端樣本」與「斷言真的比較數字」。

## 完整 Python 程式與真實輸出

`tail_mean` 模擬舊 Rust 把 NaN 排在後面的規則，對照原始長度與有效長度；`old_isclose` 模擬舊測試助手第一個錯誤提前返回。`CLEAN` 列出完整的 20 筆合成資料。下面是 **Python 3.13.5 實際執行過的完整程式**，含五組 `unittest`：

```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` 並執行：

```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，而不是憑空補零。**5/5** 只表示這五組自行編寫的合成檢查全部通過。

## 實務上的風險指標 QA

將缺值處理視為**指標的語意契約**。對每個 CVaR，最好記錄 `n_raw`、`n_valid`、`n_nan`、收益率單位、`cutoff`、滾動視窗長度及最少有效觀測數。啟用可選 Rust 引擎時，應以相同輸入及有意義的容差核對 Numba 結果。

NaN 可能是資料供應商缺價、交易暫停、上市前期間、尚未結算的資料，或 ETL 管線故障。移除它可以讓此實作的筆數一致，卻無法證明**非隨機缺值**不影響統計解釋，例如大虧損期間恰好更常缺資料。

這與先前的[同一檔股票重複入選，勝率分母如何變動](/posts/quant-winrate-repeated-stock-picks/)相似：彼處要區分訊號、不同標的和入選日期；這裡則區分原陣列槽位和真正參與左尾平均的有效報酬。

若要驗證真實 vectorbt 套件，需要在隔離環境分別安裝固定 v1.1.1／v1.1.2，執行上游 Numba 測試，並在相容時比對 Rust 與滾動視窗輸出。

討論策略是否有優勢之前，先確認風險統計選對有效樣本，也確認測試斷言真的在比數字。

---

*證據附註：vectorbt v1.1.2 於 2026 年 10 月 9 日發布；固定原始碼查閱和 Python 3.13.5 合成實驗記錄於 10 月 18 日。固定提交：`f0d2afba7af8a6e6c1b02afde27c9a16413e86ff`。資料為教學用合成樣本，非實盤回測；僅供量化工程方法討論，不構成投資建議。*
