# vectorbt 1.1.2: How a NaN Changed CVaR—Tail Counts and an isclose Assertion Bug

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

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](https://github.com/polakowo/vectorbt/releases/tag/v1.1.1) and [v1.1.2](https://github.com/polakowo/vectorbt/releases/tag/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:

```text
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.

| Rule | `n` | `k` | Values entering the lower-tail mean | Signed tail mean |
| --- | ---: | ---: | --- | ---: |
| v1.1.1 raw input length | 21 | 2 | -7%, -4%, -3% | **-4.6667%** |
| v1.1.2 valid count | 20 | 1 | -7%, -4% | **-5.5000%** |
| No-NaN control | 20 | 1 | -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](https://github.com/polakowo/vectorbt/releases/tag/v1.1.2) points directly to commit [`f0d2afba7af8a6e6c1b02afde27c9a16413e86ff`](https://github.com/polakowo/vectorbt/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=...)`](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/vectorbt/returns/accessors.py#L900-L912), which calls [`vectorbt.returns.dispatch.cond_value_at_risk`](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/vectorbt/returns/dispatch.py#L847-L867). 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`](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/vectorbt/returns/nb.py#L634-L642), with the function signature and JIT decorator omitted:

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

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](https://github.com/polakowo/vectorbt/blob/v1.1.1/rust/src/returns.rs#L764-L776), the routine copied the original slice, placed NaNs after finite values in its comparator, but computed `cutoff_index` from the unfiltered length. [The fix](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/rust/src/returns.rs#L763-L770) applies `!value.is_nan()` before selecting the tail.

Numba uses `np.partition`, not a complete `np.sort`. [NumPy's partition ordering](https://numpy.org/doc/2.2/reference/generated/numpy.partition.html) 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](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/tests/test_returns.py#L521-L564) 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](https://github.com/polakowo/vectorbt/pull/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](https://github.com/polakowo/vectorbt/pull/878), merged in the same release, changed `tests/utils.py::isclose`. The [older helper](https://github.com/polakowo/vectorbt/blob/v1.1.1/tests/utils.py#L9-L16) began with:

```python
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](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/tests/utils.py#L9-L14) 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**:

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

Save it as `cvar_nan_demo.py` and run:

```bash
python3 cvar_nan_demo.py
```

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

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](/posts/quant-winrate-repeated-stock-picks/). 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.*
