vectorbt 1.1.2: un NaN falsava il CVaR e isclose non confrontava i numeri
Una serie di rendimenti contiene un NaN, eppure il programma continua a restituire un CVaR (Conditional Value at Risk). Si potrebbe rinviare la pulizia dei dati, ma nel frattempo può essere cambiato qualcosa di più significativo: quanti rendimenti sono entrati nella media della coda delle perdite.
Ho confrontato il codice fissato di vectorbt v1.1.1 e v1.1.2. Con 20 rendimenti sintetici validi e un NaN, usando una soglia del 10%, la vecchia regola che conta la lunghezza originale n=21 produce -4,6667%; quella corretta, con n=20 valori validi, produce -5,5000%. I due rendimenti peggiori sono identici: cambia il numero dei valori selezionati.
Le prove sono di due tipi. Ho ispezionato le implementazioni Numba e Rust nei commit upstream, poi ho eseguito un modello sintetico scritto da me con la libreria standard Python. Non ho eseguito vectorbt, Numba, Rust o un backtest con prezzi di mercato reali. La differenza numerica non misura il rischio di un portafoglio effettivo.
Perché un NaN aggiunge un rendimento alla coda
Questa funzione CVaR restituisce la media con segno dei rendimenti nella coda sinistra, non una perdita rappresentata come valore positivo. Per una frazione alpha della distribuzione si calcola:
k = int((n - 1) * alpha)
signed_tail_mean = mean(按从小到大排序后前 k+1 条收益)
Dato che k non è negativo, int equivale qui a troncare verso il basso. La questione è quale n usare: il numero di posizioni nell’array originario oppure solo il numero di osservazioni valide che possono effettivamente entrare nella media. Contando un NaN da escludere si può oltrepassare una soglia discreta e selezionare un rendimento in più.
Nell’esempio ho scelto alpha=0.10, equivalente al parametro cutoff=0.10 upstream, proprio per rendere visibile la differenza. La funzione interna usa di default cutoff=0.05; l’accessor ricava il valore da self.defaults["cutoff"] se il parametro manca. Fra 20 rendimenti decimali inventati i tre peggiori sono -7%, -4% e -3%; aggiungiamo un NaN in un’altra posizione.
| Regola di conteggio | n | k | Rendimenti selezionati | Media firmata della coda |
|---|---|---|---|---|
| Vecchia: lunghezza originale | 21 | 2 | -7%, -4%, -3% | -4,6667% |
| Nuova: soli valori validi | 20 | 1 | -7%, -4% | -5,5000% |
| Controllo senza NaN | 20 | 1 | -7%, -4% | -5,5000% |
In questo caso il -3% aggiuntivo avvicina la media a zero di circa 0,83 punti percentuali. Non significa che ogni vecchia stima di CVaR sottostimasse il rischio: contano la dimensione del campione, il modello delle mancanze e il limite discreto della selezione.
I cambiamenti esatti in Numba e Rust
Il tag GitHub v1.1.2 punta direttamente al commit f0d2afba7af8a6e6c1b02afde27c9a16413e86ff, mentre v1.1.1 corrisponde a un altro commit precedente. Usare questi riferimenti impedisce che successive modifiche a main cambino il confronto.
In un’installazione si può partire da ReturnsAccessor.cond_value_at_risk(cutoff=..., engine=...), passare per vectorbt.returns.dispatch.cond_value_at_risk e arrivare a cond_value_at_risk_1d_nb(returns, cutoff=0.05) in Numba oppure cond_value_at_risk_1d(returns, cutoff) in Rust. Anche il CVaR mobile usa il calcolo per singola serie.
La modifica fondamentale in vectorbt/returns/nb.py, senza firma e decoratore JIT, è questa:
# 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 formula non è cambiata, è cambiato l’array su cui viene applicata. La nuova versione filtra prima i NaN, verifica se restano osservazioni e calcola poi l’indice. Un input composto solo da NaN restituisce NaN in entrambe le versioni; la differenza rilevante è nella combinazione di valori mancanti e validi.
La vecchia implementazione Rust copiava lo slice originale, metteva NaN dopo i numeri finiti tramite un comparatore ma calcolava cutoff_index sulla lunghezza grezza. La versione nuova filtra !value.is_nan() prima di selezionare la coda.
Numba usa np.partition, non un ordinamento completo np.sort. Secondo la documentazione NumPy, per la selezione i NaN vengono dopo i numeri ordinari: in questo caso il NaN non entra nei tre peggiori rendimenti, ma sposta l’indice precedente. Si usa isnan, non isfinite, quindi gli infiniti non sono automaticamente esclusi.
I test di regressione della versione fissata includono NaN, input vuoti o tutti NaN, finestre mobili e un confronto Rust opzionale. Ho verificato il codice di questi test, non eseguito la suite ufficiale nell’ambiente attuale. La correzione è descritta nella PR #879.
Secondo bug indipendente: il test helper isclose
La scelta corretta della coda non serve se il test che dovrebbe verificarla non confronta davvero i numeri. La PR #878, confluita nella stessa release, ha corretto tests/utils.py::isclose. Il vecchio helper iniziava così:
if np.isnan(a) == np.isnan(b):
return True
Con 1.0 e 2.0, entrambi i np.isnan danno False. Di conseguenza False == False è True e la funzione esce senza raggiungere il confronto con tolleranza. Il vecchio controllo np.isinf(a) == np.isinf(b) soffriva di un problema strutturale analogo. Nel codice corretto, NaN e infiniti vengono gestiti quando presenti; i valori finiti vengono confrontati con le tolleranze numeriche.
Questi sono due fix indipendenti presenti nella stessa versione. Non è dimostrato che il problema CVaR fosse stato nascosto proprio da questo isclose. Occorre verificare separatamente la selezione dei rendimenti e il funzionamento delle asserzioni.
Riproduzione Python completa e output registrato
tail_mean simula l’ordinamento dei NaN alla fine del vecchio percorso Rust e i due conteggi. old_isclose riproduce soltanto il primo ritorno anticipato errato. Nell’array CLEAN ci sono tutti i 20 rendimenti sintetici e cinque test unittest controllano i risultati, il caso senza NaN, le posizioni della mancanza, gli input vuoti e il falso positivo.
Questo è il programma completo eseguito realmente con 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)
Salvalo come cvar_nan_demo.py ed eseguilo:
python3 cvar_nan_demo.py
Output standard originale, codice di uscita 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
I controlli sono importanti: senza NaN i risultati coincidono, spostando NaN il nuovo calcolo non cambia e senza valori validi si ottiene NaN anziché uno zero inventato. 5/5 indica solo questi cinque controlli sintetici scritti per la dimostrazione.
QA delle metriche di rischio: cosa conviene conservare
Il trattamento dei mancanti è parte del contratto semantico dell’indicatore. Con ogni CVaR bisognerebbe poter recuperare n_raw, n_valid, n_nan, le unità dei rendimenti, cutoff, la lunghezza della finestra mobile e il minimo di osservazioni valide. Se si abilita il backend Rust opzionale, va confrontato con Numba sugli stessi input e con tolleranze verificate.
Un NaN può significare un prezzo non ricevuto dal fornitore, una sospensione delle negoziazioni, un periodo prima della quotazione, dati non ancora definitivi oppure un errore ETL. Rimuoverlo rende coerente il conteggio di questa implementazione ma non prova che escludere dati mancanti in modo non casuale sia statisticamente innocuo, specialmente durante le perdite più marcate.
Un problema affine compare nel precedente articolo sul denominatore delle selezioni ripetute della stessa azione. Là distinguo eventi, titoli diversi e date di selezione; qui le posizioni originali dell’array e i rendimenti che partecipano davvero alla media della coda sinistra.
Per verificare il pacchetto reale servirebbe installare v1.1.1 e v1.1.2 in un ambiente isolato, eseguire i test Numba e, se la versione dell’estensione è compatibile, confrontare Rust e i risultati su finestre mobili.
Prima di valutare un eventuale vantaggio della strategia, è necessario capire se le statistiche di rischio contino i campioni corretti e se le asserzioni usate per validarle confrontino effettivamente i numeri.
Nota sulle evidenze: vectorbt v1.1.2 è stato pubblicato il 9 ottobre 2026. Il confronto delle sorgenti fissate e l’esperimento sintetico con Python 3.13.5 sono stati registrati il 18 ottobre. Commit di riferimento: f0d2afba7af8a6e6c1b02afde27c9a16413e86ff. Dati didattici inventati, non backtest di mercato né consulenza finanziaria.