# vectorbt 1.1.2: Wie ein NaN den CVaR verändert – Tail-Stichprobe und isclose-Falle

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

Eine Renditeserie kann einen `NaN` enthalten, während das Dashboard weiterhin einen CVaR (Conditional Value at Risk) anzeigt. Das wirkt zunächst wie eine später lösbare Datenbereinigung. Tatsächlich kann der fehlende Wert bereits verändert haben, **wie viele Renditen im linken Verlustschwanz gemittelt werden**.

Ich habe die fest versionierten Quellen von [vectorbt v1.1.1](https://github.com/polakowo/vectorbt/releases/tag/v1.1.1) und [v1.1.2](https://github.com/polakowo/vectorbt/releases/tag/v1.1.2) verglichen. Mit 20 gültigen synthetischen Renditen und einem NaN liefert die **alte Regel mit der ursprünglichen Länge `n=21` einen signierten Tail-Mittelwert von -4,6667 %; die korrigierte Regel mit `n=20` gültigen Werten ergibt -5,5000 %**. Die beiden schlechtesten gültigen Renditen sind identisch, aber die Anzahl der ausgewählten Beobachtungen unterscheidet sich.

Hier gibt es zwei verschiedene Evidenzquellen: Der Code von Numba und Rust wurde in fixierten Upstream-Versionen geprüft. Die Zahlen stammen aus einem separat geschriebenen Python-Modell mit Standardbibliothek. **Weder vectorbt noch Numba oder Rust wurden ausgeführt; reale Marktdaten und ein historischer Backtest wurden nicht verwendet**.

## Warum ein fehlender Wert eine zusätzliche Verlustbeobachtung auswählt

Diese CVaR-Implementierung gibt den **vorzeichenbehafteten Mittelwert des linken Rendite-Tails** zurück, keine positiv dargestellte Verlusthöhe. Mit der Tail-Fraktion `alpha` wird folgender Index berechnet:

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

Für nichtnegative Indizes entspricht `int` hier dem Abrunden. Entscheidend ist der Nenner `n`: Sind das alle Positionen des ursprünglichen Arrays oder nur die gültigen Beobachtungen, die tatsächlich in die Berechnung eingehen? Ein fehlender Slot kann die diskrete Cutoff-Grenze so verschieben, dass eine zusätzliche endliche Rendite ausgewählt wird.

Im Lehrbeispiel wird `alpha=0.10` bewusst als Entsprechung zu `cutoff=0.10` gewählt. Die Low-Level-Funktion nutzt standardmäßig `cutoff=0.05`; der Accessor liest bei ausgelassener Angabe `self.defaults["cutoff"]`. Die drei schlechtesten der 20 fiktiven Dezimalrenditen sind -7 %, -4 % und -3 %. Zusätzlich wird ein NaN eingefügt.

| Rechenregel | `n` | `k` | Berücksichtigte Renditen | Signierter Tail-Mittelwert |
| --- | ---: | ---: | --- | ---: |
| Alt: ursprüngliche Länge | 21 | 2 | -7 %, -4 %, -3 % | **-4,6667 %** |
| Neu: nur gültige Werte | 20 | 1 | -7 %, -4 % | **-5,5000 %** |
| Kontrolle ohne NaN | 20 | 1 | -7 %, -4 % | **-5,5000 %** |

Die zusätzliche -3-%-Beobachtung zieht den Tail-Mittelwert hier um etwa **0,83 Prozentpunkte** Richtung null. Dies ist keine allgemeine Aussage, dass die frühere Version Risiko immer unterschätzt. Stichprobenumfang, Fehlwertmuster und diskrete Grenze bestimmen den Effekt.

## Was Numba und Rust zwischen den Versionen geändert haben

Das GitHub-Tag `v1.1.2` zeigt direkt auf Commit [`f0d2afba7af8a6e6c1b02afde27c9a16413e86ff`](https://github.com/polakowo/vectorbt/commit/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff). Das ältere `v1.1.1`-Tag verweist auf einen anderen Commit. Der Vergleich bleibt dadurch auch bei späteren Änderungen in `main` stabil.

In einer Installation führt der Weg über [`ReturnsAccessor.cond_value_at_risk(cutoff=..., engine=...)`](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/vectorbt/returns/accessors.py#L900-L912) zur Funktion [`vectorbt.returns.dispatch.cond_value_at_risk`](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/vectorbt/returns/dispatch.py#L847-L867). Numba verwendet im Kern `cond_value_at_risk_1d_nb(returns, cutoff=0.05)`, Rust `cond_value_at_risk_1d(returns, cutoff)`. Auch die rollierende Berechnung nutzt die Einzelreihenfunktion.

Die relevante Änderung in [`vectorbt/returns/nb.py`](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/vectorbt/returns/nb.py#L634-L642), hier ohne Signatur und JIT-Dekorator:

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

**Die Formel blieb unverändert, aber das Array für die Längenberechnung nicht.** Die neue Version entfernt NaN-Werte, bevor sie auf ein leeres gültiges Array prüft und den Cutoff ermittelt. Wenn alle Einträge NaN sind, liefern **beide Versionen NaN**. Besonders relevant ist der gemischte Fall aus fehlenden und gültigen Renditen.

Im [alten Rust-Code](https://github.com/polakowo/vectorbt/blob/v1.1.1/rust/src/returns.rs#L764-L776) wurde der Original-Slice kopiert und NaN mit einem Vergleichsoperator hinter endliche Zahlen sortiert, der Cutoff aber weiterhin aus der vollen Länge berechnet. Die [neue Fassung](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/rust/src/returns.rs#L763-L770) filtert `!value.is_nan()` vor der Auswahl.

Numba nutzt `np.partition` und nicht `np.sort`. Gemäß der [NumPy-Dokumentation](https://numpy.org/doc/2.2/reference/generated/numpy.partition.html) werden NaN bei der Auswahl hinter gewöhnliche Zahlen gestellt. Darum wird das NaN hier nicht unter die drei schlechtesten Renditen gewählt, verschiebt aber den alten Index. Gefiltert wird mit `isnan`, **nicht mit `isfinite`**: Positive oder negative Unendlichkeit verschwinden dadurch nicht automatisch.

Die fest versionierten [Upstream-Regressionsfälle](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/tests/test_returns.py#L521-L564) enthalten NaN, leere bzw. vollständig fehlende Arrays, rollierende Fenster und einen optionalen Rust-Vergleich. Diese Tests wurden als Quelltext untersucht, **nicht** als Upstream-Lauf in meiner Umgebung ausgeführt. Details in [PR #879](https://github.com/polakowo/vectorbt/pull/879).

## Zweiter unabhängiger Fehler: der isclose-Testhelfer

Eine korrekt gewählte Tail-Stichprobe nützt wenig, wenn die Assertion beim Prüfen Zahlen gar nicht vergleicht. Die separate [PR #878](https://github.com/polakowo/vectorbt/pull/878) im gleichen Release korrigierte `tests/utils.py::isclose`. Die [alte Funktion](https://github.com/polakowo/vectorbt/blob/v1.1.1/tests/utils.py#L9-L16) begann mit:

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

Bei `1.0` und `2.0` ergibt `np.isnan` jeweils `False`. Weil `False == False` wahr ist, lieferte die Funktion vor dem eigentlichen Toleranzvergleich `True`. Der alte Vergleich `np.isinf(a) == np.isinf(b)` hatte denselben strukturellen Fehler. Die [korrigierte Implementierung](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/tests/utils.py#L9-L14) behandelt vorhandene NaNs/Unendlichkeiten separat und verwendet für normale endliche Werte die relative oder absolute Toleranz.

Es handelt sich um **zwei eigenständige Fixes im selben Release**. Es ist nicht belegt, dass der CVaR-Fehler speziell durch `isclose` verborgen wurde. Zur verlässlichen numerischen QA gehören beides: richtig ausgewählte Stichproben und Assertions, die wirklich Zahlen vergleichen.

## Vollständige Reproduktion mit Python-Standardbibliothek

`tail_mean` modelliert die alte Rust-Regel, NaN ans Ende zu setzen, und beide Zählweisen. `old_isclose` bildet nur den ersten fehlerhaften frühen Rücksprung des alten Testhelfers nach. Alle 20 künstlichen Renditen stehen im Array `CLEAN`. Die fünf `unittest`-Fälle prüfen die beiden Ergebnisse, Kontrolle ohne NaN, dessen Position, leere/all-NaN-Eingaben und den falschen Vergleich.

Der **vollständige Code wurde mit Python 3.13.5 tatsächlich ausgeführt**:

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

Als `cvar_nan_demo.py` speichern und starten:

```bash
python3 cvar_nan_demo.py
```

Aufgezeichnete Standardausgabe, 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
```

Ohne fehlende Werte stimmen beide Regeln überein; verschiebt man das NaN, bleibt die neue Berechnung gleich; bei null gültigen Werten kommt NaN statt einer erfundenen Null heraus. **5/5** steht nur für diese fünf selbst geschriebenen synthetischen Kontrollen.

## Worauf bei der QA einer Risikometrik zu achten ist

Der Umgang mit Fehlwerten gehört zum **semantischen Vertrag einer Kennzahl**. Neben CVaR sollten `n_raw`, `n_valid`, `n_nan`, Renditeeinheit, `cutoff`, Fensterlänge und minimale gültige Stichprobengröße nachvollziehbar sein. Falls das optionale Rust-Backend aktiviert ist, muss es auf identischen Eingaben mit überprüften Toleranzen gegen Numba abgeglichen werden.

NaN kann fehlende Kurse eines Anbieters, Handelsunterbrechungen, Zeiten vor der Börsennotierung, nicht endgültig bestimmte Renditen oder einen ETL-Fehler bedeuten. Das Entfernen vereinheitlicht den Nenner dieser Implementierung, beweist aber nicht, dass das Weglassen **nicht zufällig fehlender Daten** unverzerrt ist – besonders wenn in starken Verlustphasen öfter Kurse fehlen.

Ähnlich ist das Problem der [Trefferquoten bei wiederholter Auswahl derselben Aktie](/posts/quant-winrate-repeated-stock-picks/): Dort unterscheiden wir Signale, eindeutige Aktien und Auswahltermine. Hier müssen wir rohe Array-Positionen von Renditen unterscheiden, die wirklich zum linken Tail beitragen.

Ein Test des echten vectorbt-Pakets müsste beide Versionen in einer isolierten Umgebung installieren, die Numba-Tests ausführen und bei passender Rust-Erweiterung beide Engines sowie rollierende Werte vergleichen.

Bevor eine Strategie als überlegen gilt, sollte klar sein, ob ihre Risikokennzahl die richtigen Werte zählt und ob ihre Prüfungen Zahlen wirklich miteinander vergleichen.

---

*Evidenznotiz: vectorbt v1.1.2 erschien am 9. Oktober 2026. Der feste Quellcodevergleich und das synthetische Experiment unter Python 3.13.5 wurden am 18. Oktober dokumentiert. Referenz-Commit: `f0d2afba7af8a6e6c1b02afde27c9a16413e86ff`. Fiktive Lehrdaten, kein Backtest realer Märkte und keine Anlageberatung.*
