Una serie de rendimientos incluye un NaN y el panel de riesgos sigue mostrando CVaR (Conditional Value at Risk). Puede parecer un problema de limpieza que resolveremos después, pero quizá ya haya cambiado cuántos rendimientos se incluyen en la media de la cola de pérdidas.

Comparé el código fijado de vectorbt v1.1.1 y v1.1.2. En un ejemplo de 20 rendimientos sintéticos válidos y un NaN, con corte del 10 %, la regla antigua con longitud original n=21 da -4,6667 %, mientras que la nueva, con n=20 observaciones válidas, da -5,5000 %. Los dos rendimientos válidos más negativos no cambiaron; sí lo hizo el número de valores promediados.

Las fuentes y el experimento son cosas distintas: inspeccioné el código Numba y Rust de las dos versiones, pero ejecuté únicamente un modelo propio escrito con la biblioteca estándar de Python. No ejecuté vectorbt, Numba, Rust ni un backtest con cotizaciones reales. Esta diferencia no representa el riesgo de una cartera concreta.

Un NaN puede añadir un rendimiento más a la cola

La implementación devuelve la media con signo de la cola izquierda de rendimientos, no una pérdida transformada en positiva. Si alpha indica la fracción de la cola, el índice se calcula mediante:

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

Para un índice no negativo, int equivale a truncar hacia abajo. El problema es qué significa n: ¿todas las posiciones del array inicial o solamente los rendimientos válidos que realmente participan en la estadística? Si contamos una posición NaN que después debe omitirse, el corte discreto puede seleccionar un valor válido adicional.

El ejemplo utiliza alpha=0.10 deliberadamente, equivalente al parámetro cutoff=0.10 del proyecto, para mostrar el salto. La función de bajo nivel usa cutoff=0.05 por defecto; el accesor lee self.defaults["cutoff"] si no recibe el argumento. De los 20 rendimientos decimales ficticios, los tres peores son -7 %, -4 % y -3 %. Añadimos un NaN en otra posición.

ReglankRendimientos elegidosMedia firmada de cola
Antigua: longitud original212-7 %, -4 %, -3 %-4,6667 %
Nueva: solo valores válidos201-7 %, -4 %-5,5000 %
Control sin NaN201-7 %, -4 %-5,5000 %

Incluir el -3 % adicional acerca la media a cero aproximadamente 0,83 puntos porcentuales en este caso. No demuestra que todas las estimaciones antiguas de CVaR subestimaran el riesgo: depende del tamaño de muestra, del patrón de ausencias y del corte discreto.

Qué cambió exactamente en Numba y Rust

La etiqueta de GitHub v1.1.2 apunta directamente al commit f0d2afba7af8a6e6c1b02afde27c9a16413e86ff; v1.1.1 es un commit anterior distinto. Así el análisis no depende de futuras modificaciones de main.

Para localizar el cálculo en una instalación, se parte de ReturnsAccessor.cond_value_at_risk(cutoff=..., engine=...), se pasa por vectorbt.returns.dispatch.cond_value_at_risk y se llega a cond_value_at_risk_1d_nb(returns, cutoff=0.05) en Numba o cond_value_at_risk_1d(returns, cutoff) en Rust. El CVaR móvil también emplea las funciones unidimensionales.

La diferencia esencial en vectorbt/returns/nb.py, omitiendo la firma y el decorador JIT, es:

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

La fórmula sigue igual, pero cambia el array sobre el que se aplica. La nueva versión elimina NaN antes de comprobar si quedan observaciones y calcular el índice. Con un array formado solo por NaN, ambas versiones devuelven NaN. La corrección importa sobre todo cuando se mezclan valores ausentes y válidos.

El Rust antiguo copiaba el segmento original, situaba los NaN al final mediante un comparador y calculaba cutoff_index con toda la longitud. El código corregido filtra !value.is_nan() antes de la selección.

Numba utiliza np.partition, no una ordenación completa np.sort. Según la documentación de NumPy, los NaN quedan detrás de los números normales en esta selección. Por eso este NaN no entra entre los tres peores rendimientos, aunque incremente el índice antiguo. El filtro es isnan, no isfinite: no presupongamos que elimina también los infinitos.

Los tests de regresión fijados cubren NaN, arrays vacíos o totalmente ausentes, ventanas móviles y comprobación opcional de paridad con Rust. Lo comprobado aquí es el código de dichos tests, no una ejecución local de la suite oficial. Véase PR #879.

Segundo error, independiente: el helper isclose

Que el CVaR seleccione bien los datos no basta si la aserción no compara realmente los números. La otra PR #878, integrada en el mismo release, corrigió tests/utils.py::isclose. El código anterior empezaba así:

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

Al pasar 1.0 y 2.0, ambas llamadas a np.isnan son False. Así, False == False resulta True, y la función termina antes de comprobar la tolerancia numérica. El antiguo np.isinf(a) == np.isinf(b) contenía un problema parecido. El helper nuevo trata NaN e infinitos cuando existen, y aplica tolerancias a valores finitos.

Son dos correcciones independientes del mismo release. No se ha demostrado que el error de CVaR estuviera encubierto por este isclose. El programa debe elegir la cola adecuada y sus pruebas deben comparar números de verdad.

Reproducción completa con Python

tail_mean modela la colocación de NaN al final del Rust antiguo y las dos reglas para contar observaciones. old_isclose reproduce solo el primer retorno prematuro erróneo. En CLEAN aparecen los 20 rendimientos inventados. Las cinco pruebas unittest comprueban las medias, el control sin NaN, la posición de la ausencia, entradas vacías y el falso positivo.

Este es el script completo ejecutado con Python 3.13.5, sin dependencias externas:

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

Guárdalo como cvar_nan_demo.py y ejecútalo:

python3 cvar_nan_demo.py

Salida original registrada, código de salida 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

Los controles importan: sin NaN ambos cálculos coinciden; cambiar la posición del NaN no altera la versión corregida; sin observaciones válidas se devuelve NaN, no un cero inventado. 5/5 se refiere únicamente a estas cinco comprobaciones sintéticas escritas para el ejemplo.

Qué revisar en el QA de una métrica de riesgo real

El tratamiento de ausencias forma parte del contrato semántico del indicador. Además del CVaR, conviene conservar n_raw, n_valid, n_nan, unidades de rendimiento, cutoff, tamaño de ventana y número mínimo de observaciones válidas. Si se utiliza el motor Rust opcional, hay que cotejar sus resultados con Numba sobre los mismos datos y tolerancias verificadas.

Un NaN puede señalar un precio ausente del proveedor, una suspensión de negociación, un periodo previo a la cotización, rendimientos sin liquidar o un fallo de ETL. Eliminarlo arregla el recuento de esta implementación, pero no garantiza neutralidad estadística si los datos faltan de forma no aleatoria, por ejemplo durante las pérdidas más severas.

El problema se parece al denominador de la tasa de aciertos con selecciones repetidas de la misma acción. Allí diferenciábamos eventos, títulos únicos y fechas. Aquí hay que distinguir posiciones originales del array de rendimientos realmente incluidos en la cola izquierda.

Una verificación del vectorbt real exigiría instalar las dos versiones fijadas en un entorno aislado, ejecutar la suite Numba y, cuando exista una extensión compatible, contrastar Rust y los resultados móviles.

Antes de atribuir ventaja a una estrategia, conviene verificar que la métrica cuente las observaciones correctas y que sus aserciones comparen los valores que reciben.


Nota de evidencia: vectorbt v1.1.2 se publicó el 9 de octubre de 2026. La inspección del código fijo y el experimento sintético con Python 3.13.5 se registraron el 18 de octubre. Commit de referencia: f0d2afba7af8a6e6c1b02afde27c9a16413e86ff. Datos didácticos ficticios, no backtest de mercado ni asesoramiento de inversión.