Saltar a contenido

NautilusTrader frente a VectorTA

El banco de pruebas existe para comparar dos formas muy distintas de calcular un backtest: un motor event-driven que simula el mercado evento a evento (NautilusTrader) y una biblioteca vectorizada que calcula indicadores sobre arrays completos (VectorTA). Este capítulo explica qué es cada uno, en qué es bueno, dónde tropieza y qué se descubrió ejecutando los binarios, no leyendo su documentación.

Qué vas a aprender

  • Qué son NautilusTrader 1.231.0 y VectorTA 0.2.8 y qué filosofía sigue cada uno.
  • Cuándo conviene cada motor y qué límites tiene.
  • El hallazgo central: cómo conseguir un fill next-open en Nautilus.
  • Por qué la cuenta es MARGIN.
  • Qué pasa con el AVX2 de VectorTA (no acelera).
  • Las semillas de calentamiento de cada indicador y el tratamiento de los NaN de entrada.

1. Dos filosofías

flowchart TB
    subgraph VEC["VectorTA — vectorizado"]
        A1[array close completo] --> A2["ema(close, 20)"] --> A3[array ema completo]
    end
    subgraph EVT["NautilusTrader — event-driven"]
        B1[Bar t] --> B2[on_bar: actualizar indicador] --> B3[decidir] --> B4[orden] --> B5[matching engine] --> B6[OrderFilled]
        B6 --> B1
    end
VectorTA 0.2.8 (vector-ta==0.2.8) NautilusTrader 1.231.0 (nautilus-trader==1.231.0)
Qué es Biblioteca Rust con enlaces Python; el catálogo del proyecto le cuenta 346 indicadores con carril VectorTA Plataforma de trading con motor de backtest y live, núcleo Rust/Cython
Modelo Función sobre array: f(close) → array Eventos: barras, quotes, órdenes, fills, posiciones, cuenta
Carriles batch (ema, ema_batch) y stream (EmaStream.update) Indicador incremental (update_raw/handle_bar) dentro de una estrategia
Ejecución No tiene: el proyecto le añade su propio ledger Tiene matching engine, venue, cuenta, comisiones
Fortaleza Velocidad bruta en indicadores; barridos de parámetros Realismo del flujo: órdenes, cuentas, eventos, mismo código en live
Límite Sin ejecución; variantes de fórmula propias; NaN silencioso Coste por evento; semillas propias; instrumentación costosa

Analogía

VectorTA es una calculadora científica: le das la columna entera y te devuelve la columna resultado. Nautilus es un simulador de vuelo: cada instante pasa por los instrumentos, el piloto decide y el avión responde. La calculadora es rapidísima; el simulador reproduce mejor lo que pasaría de verdad.

El proyecto no elige uno: fija un contrato común (datos, features, política, ejecución, contabilidad) y mide cada motor contra él.


2. Hallazgo 1: el fill next-open en Nautilus

El contrato temporal del proyecto (SIM-S) es sencillo de enunciar: la decisión tomada al cierre de la barra t se ejecuta a la apertura de la barra t+1. Ni en el cierre de t (miraría el futuro) ni en la apertura de t+2 (doble desplazamiento).

Conseguirlo en Nautilus no es obvio. La sonda docs/nt_probe.py probó cuatro configuraciones con un fixture mínimo: barra 0 con cierre 100 y barra 1 con apertura 110.

Configuración Precio de fill Veredicto
bar_execution=True, sin latencia 100 (cierre de la barra de decisión) No es next-open
bar_execution=True, LatencyModel(1ns) 111 (cierre de la barra siguiente) No es next-open
bar_execution=False, quotes de apertura, orden en on_bar sin fill (no hay libro al enviar) Inválido
bar_execution=False, quote sintética a open+1ns, orden en on_quote_tick 110 Diseño canónico SIM-S
sequenceDiagram
    participant E as Motor Nautilus
    participant S as Estrategia SIM-S
    E->>S: on_bar(t) — cierre 100
    S->>S: decide objetivo (+1), guarda intención
    E->>S: on_quote_tick(open[t+1] + 1 ns)<br/>bid = ask = 110 (k = 0)
    S->>E: orden de mercado por el delta
    E->>S: OrderFilled a 110 ✔

Detalles del diseño (en finazbench/adapters/nt_strategy_base.py y nt_common.py):

  • Para cada barra i ≥ 1 se emite una QuoteTick en market_open_ns[i] + 1 ns con bid = open − k·tick y ask = open + k·tick, siendo k = half_spread_ticks + slippage_ticks. Con k = 0 el fill es el open exacto.
  • El desplazamiento de 1 ns es solo una clave de desempate de reproducción (replay_ts_shift_ns=1, declarado en run_metrics); no llega a la tabla de fills, cuyo event_ts es market_open_ns exacto.
  • La barra 0 no lleva quote: ninguna decisión previa puede ejecutarse en su apertura.

El caso negativo se mantiene vivo

tests/adapters/test_next_open_probe.py afirma explícitamente que bar_execution=True liquida a 100, para que nadie lo reintroduzca «porque parece más simple».


3. Hallazgo 2: cuenta MARGIN y OMS NETTING

Ajuste Valor Motivo
AccountType MARGIN Para permitir cortos. No se habilitan cortos sobre una cuenta CASH que no los admite para luego compararla con un ledger que sí.
OmsType NETTING Una posición neta por instrumento: eso significa un objetivo en {−1, 0, +1}. Con HEDGING una reversión abriría dos posiciones opuestas.
bar_execution False Las barras informativas no reescriben el libro.
fee_model HalfEvenFeeModel (por defecto) Nautilus redondea la comisión half-up; el oráculo, half-even (ver capítulo del ledger).
logging ERROR, bypass_logging=True Un log por barra falsea el caudal de eventos.

Variante long-only

NtIntentReplayAdapter(account_type=AccountType.CASH) es posible, pero debe declararse en ambos motores y los objetivos deben quedarse en {0, +1}. No se usa en la fase A.


4. Hallazgo 3: el AVX2 de VectorTA no acelera

El wheel de PyPI vector-ta 0.2.8 solo trae el kernel escalar: kernel="avx2" o "avx512" existen como valores aceptados pero lanzan «not compiled in this build». Que un símbolo exista no prueba que funcione (DEV-02).

El proyecto construyó su propio wheel (finaz/bench:0.2.8-avx2-x86-64-v3, misma versión 0.2.8) para medirlo. El resultado de WP-08b, con la sonda ABBA en el mismo proceso sobre 1 000 000 de barras:

Carga Escalar (ms) AVX2 (ms) Speedup
sma single 1,621 1,623 0,999
ema single 2,510 2,498 1,005
rsi single 3,844 3,858 0,996
atr single 3,779 3,791 0,997
ema_grid (32 periodos) 244,875 282,995 0,865
rsi_grid (32 periodos) 335,238 317,343 1,056

Conclusión: single ≈ 1,00 y batch 0,87–1,06. Los indicadores recursivos (EMA, RMA, RSI) tienen una dependencia barra a barra que ninguna instrucción vectorial rompe. Se detalla en el capítulo de benchmarks.


5. Hallazgo 4: semillas de calentamiento

Cada motor decide a su manera cuándo un indicador empieza a ser válido y con qué valor arranca. Es la fuente número uno de divergencias.

Indicador Canónico (first_valid) VectorTA batch VectorTA stream Nautilus
sma(20) 19 19 19 19
ema(20) 19, semilla SMA valores desde 0 (media corrida, vta_running_mean_masked), coincide desde 19; la máscara la pone el proyecto NaN hasta 19, semilla SMA 19 (period_minus_1, máscara del proyecto), semilla = primer valor (nt_seed_first_value): el indicador nativo arranca en el primer cierre, no en la SMA
rsi(14) 14 (Wilder) 14 14 13, escala 0..1 (nt_init_period_minus_1_scaled_0_1)
atr(14) 13 (TR0 = H0−L0) 13 13 13, semilla distinta (nt_seed_first_value_sma_inner)
adx(14) (dmi) 26 candidata del wheel adx/di: 27 (VARIANT, no usada; medida en docs/FEATURES.md). La fila vectorta/dmi de capabilities.json es custom_reference y observa 26 AdxStream: candidata, no usada no disponible: la fila nautilus/dmi de capabilities.json tiene available: false (1.231.0 no ofrece ADX nativo; DirectionalMovement no calcula ADX)

Dos clases de diferencia

  • Semilla distinta, converge (ema, rma en Nautilus; el repositorio incluye también atr y rsi): la diferencia decae exponencialmente, de 1e-1 en la barra 19 a menos de 1e-9 a las 5 000 barras (docs/FEATURES.md). Ojo: el rsi de Nautilus con su ma_type por defecto usa media exponencial y no converge (ver Ejemplos numéricos); y el propio provider_nautilus.py dice que atr no converge con el ma_type por defecto.
  • Definición distinta, no converge nunca (bbands de Nautilus centrada en el precio típico; donchian inclusivo): ninguna tolerancia lo arregla.

El RSI de Nautilus devuelve 0..1; el proveedor del proyecto lo multiplica por 100 (conversión de unidad, no ajuste numérico). Sin eso, un umbral de 30 nunca se cruzaría.


6. Hallazgo 5: los NaN de entrada

VectorTA ignora un NaN sin avisar

Si la serie de entrada contiene un NaN, vector_ta devuelve un número plausible y falso. Por eso los tres proveedores del proyecto (canonical, vectorta, nautilus) rechazan NaN e infinitos en la entrada con un ValueError que nombra la columna y el índice. Hay un test que lo acredita: test_parity.py::test_vectorta_ignoraria_el_nan_sin_la_comprobacion.

import numpy as np
from finazbench.features.registry import build_spec

spec = build_spec("ema", period=20)      # único constructor legítimo
close = np.array([10.0, np.nan, 10.2])
# provider.compute(bars, spec) → ValueError: columna 'close', índice 1

7. Otros detalles verificados en el binario

Construir 632k barras de Nautilus: qué vía es la rápida
Vía 100k barras
Bar.from_raw_arrays_to_list (elegida) 0,059 s
Bucle Python Bar(...) 0,140 s
BarDataWranglerV2.from_pandas + Bar.from_pyo3_list 0,330 s

La firma de QuoteTick.from_raw_arrays_to_list sugiere enteros, pero el binario exige float64; y los arrays deben ser escribibles (los de Polars zero-copy no lo son).

El PortfolioAnalyzer cuadrático

Nautilus acumula cada trade con pd.concat de una Series de un elemento: coste cuadrático. Con 1 000 fills sobre 50 000 barras, simulate pasa de 2,43 s a 5,77 s. El proyecto lo neutraliza (portfolio_analyzer: "disabled", declarado) porque solo alimenta estadísticas nativas que el banco no usa; un test comprueba que fills y equity no cambian ni un céntimo.

Precisión de 128 bits

El build usa FIXED_PRECISION = 16: un tamaño de 100 000 unidades en crudo es 10²¹ y desborda int64. Por eso no hay vía «raw entera» para las quotes.


Resumen

  • VectorTA calcula indicadores sobre arrays (rápido, sin ejecución); Nautilus simula eventos (realista, más caro).
  • El fill next-open solo es correcto con bar_execution=False + QuoteTick sintética a open+1ns
  • orden en on_quote_tick.
  • Cuenta MARGIN con OMS NETTING para cortos y reversiones en una sola posición.
  • El AVX2 del build propio de VectorTA 0.2.8 no acelera estas rutas.
  • Las semillas difieren (ema, rsi 14 vs 13, adx 26 vs 27): el proyecto fija una canónica y declara cada variante.
  • Los NaN de entrada se rechazan siempre, porque VectorTA los ignoraría en silencio.

Para practicar

  1. Ejecuta mentalmente la sonda: barras con cierres [100, 111, 120] y aperturas [100, 110, 120], orden de compra en la barra 0. ¿A qué precio se llena en cada una de las cuatro configuraciones?
  2. ¿Por qué una EMA con semilla «primer valor» y otra con semilla SMA acaban coincidiendo, y una Bollinger centrada en el precio típico nunca coincide con la del cierre?
  3. Explica por qué una reversión de +1 a −1 con OMS HEDGING no sería comparable con el ledger.
  4. Busca en runs/env/capabilities.json la fila de rsi para Nautilus y anota su observed_first_valid y su variante.