A return series can contain a NaN while a dashboard continues to display conditional value at risk (CVaR). That looks like a data-cleaning problem for later. The more subtle failure is that a missing observation can change how many returns enter the loss tail, even if it never participates in the final mean.

I inspected the pinned code for vectorbt v1.1.1 and v1.1.2. With 20 valid synthetic returns and one NaN, at a 10% tail cutoff, the old raw-count rule (n=21) gives -4.6667%; the new valid-count rule (n=20) gives -5.5000%. The two worst valid returns did not change; the selection count did.

The evidence comes from two separate activities: inspecting versioned Numba/Rust source and executing my own Python standard-library teaching fixture. This is not a vectorbt, Numba or Rust runtime test, nor a historical-market backtest. It illustrates an implementation boundary, not a claim about any live portfolio.

One missing return can add another loss to the tail

This vectorbt calculation returns the signed mean of lower-tail returns, rather than a positive magnitude of losses. For a lower-tail proportion alpha, its selection rule can be summarized as:

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

For a nonnegative index, int effectively rounds down. The key question is what n counts: the raw array positions, or the valid observations that can actually enter the statistic. An extra missing slot can move the discrete cutoff to include one more valid return.

The synthetic example deliberately sets alpha=0.10 to match the upstream parameter cutoff=0.10 and make this edge visible. The low-level default is cutoff=0.05; the public accessor uses self.defaults["cutoff"] when nothing is passed. The three lowest values among the 20 fictional decimal returns are -7%, -4% and -3%; a single NaN is inserted elsewhere.

RulenkValues entering the lower-tail meanSigned tail mean
v1.1.1 raw input length212-7%, -4%, -3%-4.6667%
v1.1.2 valid count201-7%, -4%-5.5000%
No-NaN control201-7%, -4%-5.5000%

In this specific sample, including the third -3% return pulls the average toward zero by roughly 0.83 percentage points. This does not establish a universal direction of risk bias. The effect depends on the number of observations, the missingness pattern, the cutoff and the discretization.

What changed in the Numba and Rust implementations

GitHub’s v1.1.2 release tag points directly to commit f0d2afba7af8a6e6c1b02afde27c9a16413e86ff; v1.1.1 points to a different earlier commit. The fixed references make the comparison stable even as main changes.

To locate this in an installed copy, start at ReturnsAccessor.cond_value_at_risk(cutoff=..., engine=...), which calls vectorbt.returns.dispatch.cond_value_at_risk. The Numba path reaches cond_value_at_risk_1d_nb(returns, cutoff=0.05), while the Rust backend calls cond_value_at_risk_1d(returns, cutoff). Rolling CVaR also uses the one-dimensional implementation.

Here is the essential change in vectorbt/returns/nb.py, with the function signature and JIT decorator omitted:

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

The cutoff expression did not change. The array whose length enters the expression did. In v1.1.2, NaN values are removed before the empty-input check and cutoff calculation. A series consisting entirely of NaNs returned NaN in both versions; the meaningful difference concerns arrays mixing missing and valid returns.

In older Rust, the routine copied the original slice, placed NaNs after finite values in its comparator, but computed cutoff_index from the unfiltered length. The fix applies !value.is_nan() before selecting the tail.

Numba uses np.partition, not a complete np.sort. NumPy’s partition ordering puts NaNs after ordinary numerical values, so the missing value in this case shifts the raw-count index without being selected among the worst three finite values. The new filter is isnan, not isfinite; do not assume it eliminates positive or negative infinity.

The pinned upstream regression tests cover NaN, empty and all-NaN arrays, rolling windows and optional Rust parity. Those are tests I inspected in the source, not an upstream suite run I performed. See PR #879.

A different bug: an isclose assertion that did not compare numbers

Correct tail selection is one issue; whether the numeric test helper truly checks values is another. PR #878, merged in the same release, changed tests/utils.py::isclose. The older helper began with:

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

Given 1.0 and 2.0, both np.isnan calls are False, so False == False is True. The function returned before reaching a tolerance comparison. Its old np.isinf(a) == np.isinf(b) shortcut had a similar trap. The corrected helper handles NaNs and infinities when they actually occur, then compares finite numbers using relative/absolute tolerances.

These are two independent corrections in one release, not proof that this isclose bug hid the CVaR defect. Numeric QA needs both: a statistic that selects the right returns and a test helper that compares the numbers it receives.

Reproduce both errors with the complete Python script

The following teaching script has no third-party dependencies. tail_mean models the old Rust placement of NaNs and the raw-versus-valid counts; old_isclose models only the first erroneous early return in the former test helper. The CLEAN array lists all 20 fictional returns. Five unittest cases cover the two means, no-NaN control, NaN-position invariance, empty/all-missing input and the false positive.

This is the full script actually executed under Python 3.13.5:

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

Save it as cvar_nan_demo.py and run:

python3 cvar_nan_demo.py

Original captured standard output, exit code 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

Three negative controls are especially useful: without NaNs both calculation rules agree; moving the NaN does not affect the corrected rule; and with no valid values the calculation returns NaN rather than inventing zero. 5/5 refers only to these independently authored synthetic checks.

What to verify in a production risk-metric pipeline

Missing-data policy is part of a metric’s semantic contract. Alongside CVaR, track n_raw, n_valid, n_nan, return units, cutoff, rolling-window length and the minimum number of valid observations. If the optional Rust engine is in use, compare it with Numba on identical inputs using meaningful, verified tolerances.

A NaN can mean a missing vendor price, a trading halt, an observation before an instrument was listed, unsettled data or an ETL fault. Dropping it makes this implementation’s sample count consistent; it does not guarantee an unbiased statistic under nonrandom missingness, particularly when severe-loss periods are more likely to have missing prices.

This echoes the problem of repeated stock selections and win-rate denominators. There the count might represent signals, unique securities or selection dates. Here we must distinguish raw array positions from returns that genuinely participate in a lower-tail mean.

To verify the actual library behavior beyond the teaching fixture, a separate isolated integration test would install pinned v1.1.1 and v1.1.2, run Numba regressions and compare the optional compatible Rust backend, including rolling outputs.

Before debating whether a strategy has an edge, make sure its risk measure counted the right observations—and its assertions actually compared numbers.


Evidence note: vectorbt 1.1.2 was released on October 9, 2026. The pinned source review and Python 3.13.5 synthetic experiment were recorded on October 18, 2026. Version anchor: f0d2afba7af8a6e6c1b02afde27c9a16413e86ff. Fictional teaching inputs, not a market backtest or investment advice.