vectorbt 1.1.2:一個 NaN 如何讓 CVaR 風險值失真?從尾端樣本數到測試斷言
收益率序列中混入一筆 NaN,風險儀表板仍然可能正常顯示 CVaR(Conditional Value at Risk,條件風險值)。表面上是資料清理問題,實際上可能連左尾平均究竟選了幾筆收益都已改變。
我逐版閱讀了 vectorbt v1.1.1 和 v1.1.2 的固定原始碼,並另外用 20 筆有效合成報酬與 1 筆 NaN 做最小復算。在 10% 尾端截斷下,舊規則按原始長度 n=21 得到 -4.6667%,新規則按有效長度 n=20 得到 -5.5000%。最差的兩筆有效報酬完全沒變,變的是要平均的筆數。
證據分成兩部分:版本差異來自上游 Numba 與 Rust 原始碼,數值來自自行執行的 Python 標準函式庫合成模型。沒有執行 vectorbt、Numba 或 Rust 本體,也沒有使用真實市場資料回測。
一筆 NaN 如何多選一筆虧損
這裡的 CVaR 是報酬左尾的帶符號平均值,不是把虧損改成正數。以 alpha 表示尾端截斷比例:
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,而 v1.1.1 指向另一個較早提交,因此不會被後續 main 變化影響。
在自己的環境裡,可以從 ReturnsAccessor.cond_value_at_risk 追到 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)。滾動 CVaR 也透過單序列計算。
在 Numba 的 nb.py 中,省略函式簽名後,關鍵差異如下:
# 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 的舊版先複製原陣列,以比較器把 NaN 放在有限數值後面,但仍按原長度計算 cutoff_index;新版先以 !value.is_nan() 過濾。
Numba 路徑實際使用 np.partition 而不是完整的 np.sort。NumPy 文件也將 NaN 排在一般數值之後,因此本例的 NaN 不會進入最差三筆,卻改變舊版的截斷索引。注意過濾條件是 isnan,不是 isfinite;不能認為正負無窮大也已移除。
上游回歸測試涵蓋 NaN、空陣列、全 NaN、滾動視窗以及可選 Rust 比對;我查閱的是測試原始碼,並非本機執行的上游測試結果。PR #879有對應修復。
另一個獨立錯誤:isclose 測試輔助函式
即使 CVaR 選對樣本,測試的數值斷言也可能失效。同版的另一個 PR #878 修正了 tests/utils.py::isclose。舊版程式開頭寫著:
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) 也有類似問題。修正版先處理真正的 NaN/無窮大,再用容差比較有限數。
這是與 CVaR 互相獨立的第二項修正,並沒有證據證明 CVaR 缺陷正是被此測試錯誤掩蓋。要同時檢查「選對尾端樣本」與「斷言真的比較數字」。
完整 Python 程式與真實輸出
tail_mean 模擬舊 Rust 把 NaN 排在後面的規則,對照原始長度與有效長度;old_isclose 模擬舊測試助手第一個錯誤提前返回。CLEAN 列出完整的 20 筆合成資料。下面是 Python 3.13.5 實際執行過的完整程式,含五組 unittest:
"""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 並執行:
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,而不是憑空補零。5/5 只表示這五組自行編寫的合成檢查全部通過。
實務上的風險指標 QA
將缺值處理視為指標的語意契約。對每個 CVaR,最好記錄 n_raw、n_valid、n_nan、收益率單位、cutoff、滾動視窗長度及最少有效觀測數。啟用可選 Rust 引擎時,應以相同輸入及有意義的容差核對 Numba 結果。
NaN 可能是資料供應商缺價、交易暫停、上市前期間、尚未結算的資料,或 ETL 管線故障。移除它可以讓此實作的筆數一致,卻無法證明非隨機缺值不影響統計解釋,例如大虧損期間恰好更常缺資料。
這與先前的同一檔股票重複入選,勝率分母如何變動相似:彼處要區分訊號、不同標的和入選日期;這裡則區分原陣列槽位和真正參與左尾平均的有效報酬。
若要驗證真實 vectorbt 套件,需要在隔離環境分別安裝固定 v1.1.1/v1.1.2,執行上游 Numba 測試,並在相容時比對 Rust 與滾動視窗輸出。
討論策略是否有優勢之前,先確認風險統計選對有效樣本,也確認測試斷言真的在比數字。
證據附註:vectorbt v1.1.2 於 2026 年 10 月 9 日發布;固定原始碼查閱和 Python 3.13.5 合成實驗記錄於 10 月 18 日。固定提交:f0d2afba7af8a6e6c1b02afde27c9a16413e86ff。資料為教學用合成樣本,非實盤回測;僅供量化工程方法討論,不構成投資建議。