vectorbt 1.1.2:NaNがCVaRを変える理由――尾部標本数とiscloseテストの落とし穴
リターン系列に NaN が1つ混じっていても、CVaR(Conditional Value at Risk、条件付きバリュー・アット・リスク)は普通の数値を返す場合があります。欠損値の除去だけを気にすると、損失側の尾部平均に何件を選んだかが変わったことを見落としかねません。
vectorbt v1.1.1 と v1.1.2 の固定ソースを比較しました。20件の有効な合成リターンに NaN 1件を追加し、下位10%を使うと、旧版の元配列長 n=21 では -4.6667%、新版の有効件数 n=20 では -5.5000%。最も悪い2件の値はそのままですが、尾部に含まれる件数が違います。
ここでの根拠は二種類です。NumbaとRustの実装差分は上流ソースを直接確認し、数値は自作のPython標準ライブラリによる合成モデルを実行して求めました。vectorbt・Numba・Rust本体や実相場のバックテストを実行した結果ではありません。
NaNが1つ増えると、なぜ損失側を多く取るのか
この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件の架空の小数リターンのうち最悪3件は -7%、-4%、-3%で、そこにNaNを1件挿入します。
| 計算規則 | n | k | 選択したリターン | 符号付き尾部平均 |
|---|---|---|---|---|
| 旧版:元の配列長 | 21 | 2 | -7%、-4%、-3% | -4.6667% |
| 新版:有効値だけ | 20 | 1 | -7%、-4% | -5.5000% |
| NaNなしの対照 | 20 | 1 | -7%、-4% | -5.5000% |
-3%をもう1件含めた結果、この例では平均が約 0.83ポイントゼロに近づきます。ただし旧版が常にリスクを過小評価するという主張ではありません。サンプル数、欠損の分布、切り出し境界に依存する現象です。
NumbaとRustの修正箇所を固定して読む
GitHubの v1.1.2 タグはコミット f0d2afba7af8a6e6c1b02afde27c9a16413e86ff を直接指します。v1.1.1は別の旧コミットです。この二つを固定することで後日の main 変更による混乱を避けられます。
自分のインストールを確認するなら、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)。ローリングCVaRも一系列向けの計算を再利用します。
Numbaのnb.py の核心だけを抜き出すと次の差分です(関数宣言と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)
計算式自体は同じですが、長さを測る配列が変わりました。新版では先に NaN を除外してから空配列判定とインデックス計算を行います。全要素がNaNの入力は旧版・新版とも最終的に NaN。修正が効くのは、有効値と欠損が混在するケースです。
Rustの旧版は元のスライスをコピーし、比較器でNaNを後ろに置きながら、cutoff_index を元の長さで計算していました。新版は !value.is_nan() で先に除外します。
Numbaで使うのは完全ソートの np.sort ではなく np.partition です。NumPyの仕様では NaN は通常の数値より後ろに並ぶため、このNaN自体は最悪3件に含まれず、旧版の k だけが増えます。フィルタ条件は isnan で、isfinite ではありません。無限大まで除去すると決めつけないようにします。
固定版の上流回帰テストには、NaN、空配列、全NaN、ローリング窓、オプションのRust比較が含まれます。これはテストコードの確認であって、現在の環境で公式テストを実行した記録ではありません。PR #879が修正元です。
独立した別のバグ:iscloseの早期リターン
計算が正しい標本を選んだとしても、その検証に使うアサーションが壊れていては意味がありません。同じリリースで別の 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とiscloseは同じ版に入った独立した修正です。前者が後者に隠されていたという因果関係までは立証されていません。標本の選び方とアサーションの比較動作を別々に検証することが重要です。
Pythonだけで両方の問題を再計算する
次のスクリプトは外部依存のない教材です。tail_mean は旧RustのNaNを末尾に置く規則と新旧の件数処理をモデル化し、old_isclose は旧テスト助手の最初の誤った早期リターンを再現します。CLEAN には20件の架空のリターンを全て記載しました。
Python 3.13.5で実際に動かした完全なコードと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の挿入位置を変えても新版の値は変わりません。有効値が0件ならゼロで埋めず NaN を返します。5/5 はこの自作の合成テスト5件の結果です。
実際のリスク指標のQAで確認すること
欠損値の取り扱いを指標の意味的な契約と考えます。CVaRの横に n_raw、n_valid、n_nan、リターン単位、cutoff、ローリング窓と必要な最小有効件数を追えると、数値が動いた理由を調べられます。オプションのRustバックエンドを使う場合は、同じ入力と妥当な許容誤差でNumbaと比較します。
NaNの原因は、価格ベンダーの欠配信、取引停止、上場前、未確定のデータ、ETLの不具合などさまざまです。除外により実装上の件数は一致しても、特に大幅な損失期ほどデータが欠けやすい非ランダムな欠損を解決するものではありません。
以前の同一銘柄の繰り返し選出と勝率の分母にも通じます。あちらは選出イベント、固有銘柄、日付を区別しました。こちらでは元配列の位置と左尾平均に入る有効なリターンを区別します。
実ライブラリまで確認するには、隔離環境に v1.1.1 と v1.1.2 を固定して導入し、Numba回帰テストを走らせ、対応するRust拡張があれば両バックエンドとローリング計算を照合する必要があります。
戦略の優位性を論じる前に、リスク指標が正しい有効標本を数え、検証のアサーションが本当に数値を比較しているかを確認したいところです。
証拠メモ:vectorbt v1.1.2は2026年10月9日公開。固定版ソースの確認とPython 3.13.5の合成実験は10月18日の記録です。参照コミット:f0d2afba7af8a6e6c1b02afde27c9a16413e86ff。教材用の架空データであり、実市場のバックテストや投資助言ではありません。