Saltar a contenido

El ledger SIM-S: contabilidad exacta

El ledger es el libro de cuentas del backtest: convierte una secuencia de objetivos («quiero estar largo», «quiero estar plano») en fills, caja, posición y equity. Parece la parte aburrida, pero es la que decide si dos motores «dan lo mismo» al céntimo. Este capítulo explica cómo funciona el ledger de referencia, por qué no usa coma flotante para el dinero y cómo se calcula a mano un caso completo.

Qué vas a aprender

  • Las piezas: oráculo reference_ledger.py, ledger NumPy (sim_s_numpy.py) y ledger Numba (sim_s_numba.py).
  • Qué son target_lots, el delta, la reversión y reset_flat.
  • Cómo se cuantizan precios a tick y comisiones a la unidad mínima (half-even).
  • Los escenarios de coste (zero, 5bps) y dónde se aplica el spread.
  • Un ejemplo numérico paso a paso (100 → 110, reversión y comisión).
  • Las tablas de salida (fills, positions, equity) y las cifras de rendimiento.

1. Las piezas

Módulo Qué es
finazbench/ledger/reference_ledger.py Oráculo en Decimal: lento y exacto. Es el juez; no se toca.
finazbench/ledger/sim_s_numpy.py Ledger NumPy: vectorizado, enteros exactos, lotes {−1, 0, +1}
finazbench/ledger/sim_s_numba.py Ledger Numba: @njit(cache=True), admite sizing por fracción de equity
finazbench/ledger/quantize.py Cuantización de barras a tick antes de ejecutar
finazbench/ledger/sizing.py Tamaño de posición (§3.2), truncando hacia cero con Decimal
finazbench/ledger/reports.py Tablas fills, positions, equity en Polars
finazbench/ledger/portfolio.py Ledger de cartera (Q01–Q03, WeightIntentArrays)
flowchart LR
    T["targets por barra<br/>{-1, 0, +1}"] --> Q["quantity t = target t-1<br/>si t es operable"]
    Q --> D[delta = diff quantity]
    D --> P[fill_price = open ± k·tick]
    P --> C[comisión half-even<br/>en unidades mínimas]
    C --> K[cash = cumsum]
    K --> E[equity = cash + qty · close]

2. Conceptos del contrato

Concepto Significado
target_lots Conjunto de objetivos admitidos. En la API: [-1, 0, 1] (corto, plano, largo).
quantity[t] Posición tras la apertura de t: quantity[t] = target[t−1] si t es operable.
delta[t] Orden ejecutada: quantity[t] − quantity[t−1].
Reversión Pasar de +1 a −1 es una orden de delta −2, con comisión de dos unidades.
reset_flat Cada ventana empieza plana, con la caja inicial. Es el único modo implementado.
continue Continuar desde un checkpoint causal: no implementado (409/NotImplementedError).
Barra no operable is_tradable=False: el delta pendiente se ejecuta en la primera apertura operable posterior (D-25).

Por qué el ledger NumPy no necesita bucle

Con objetivos discretos la cantidad no depende de la caja. Basta un desplazamiento (quantity = target desplazado una barra), un diff para el delta y un cumsum para la caja. El sizing por fracción de equity sí es recursivo, y por eso vive en el ledger Numba.


3. Dinero sin coma flotante

3.1 Precios en ticks

Los precios canónicos de NVDA vienen ajustados por splits con hasta seis decimales (7.040797) y no están en la rejilla del tick. El ledger rechaza un precio desalineado en vez de redondearlo en silencio. Por eso el runner llama a quantize_bars_for_execution (política half_even_then_ohlc_repair) antes del adaptador y declara lo que movió:

Serie Precios movidos % Desvío máximo
KO 1d 1.831 / 5.428 33,7 % 0,5 ticks
NVDA 1d 4.953 / 6.508 76,1 % 0,5 ticks
NVDA 1min (60.000 barras) 233.675 / 240.000 97,4 % 0,5 ticks

3.2 Comisiones en unidades mínimas, con empate al par

La comisión exacta es una fracción de enteros:

commission_minor = |delta| · price_ticks · tick_num · fee_num · quantum_den
                   ─────────────────────────────────────────────────────────
                                   tick_den · fee_den

y se redondea con half-even (empate al par) usando solo aritmética entera:

q, r = divmod(num, den)
si 2r > den                  → q + 1
si 2r < den                  → q
si 2r == den y q es impar    → q + 1     (empate: al par)
si 2r == den y q es par      → q

Empates que distinguen half-even de half-up

Precio Cant. Tarifa Bruto Half-even (canónico) Half-up (Nautilus nativo)
50,00 1 5 pb 0,025 0,02 0,03
250,00 1 5 pb 0,125 0,12 0,13
110,00 1 5 pb 0,055 0,06 0,06
130,00 1 5 pb 0,065 0,06 0,07
Por eso los carriles Nautilus inyectan HalfEvenFeeModel: calcula en Decimal, cuantiza half-even
y después construye el Money.

La lección del bug

Para probar una regla de redondeo hay que construir el empate, no esperar a tropezar con él. Un property test con precios aleatorios casi nunca cae exactamente en …5.

Precondición: tick_size / money_quantum debe ser entero (tick 0,01 y quantum 0,01 → 1). Si no lo es, se rechaza (DEV-16).


4. Costes: zero y 5bps

Escenario Comisión Spread/slippage
zero 0 0 ticks
5bps 5 puntos básicos del notional, half-even según contrato (half_spread_ticks, slippage_ticks)

Son parámetros de ensayo, no tarifas de mercado. El spread se aplica una sola vez, en el precio de fill (open ± k·tick); no se vuelve a descontar del PnL (hay un test, test_costes_sin_doble_descuento).


5. Ejemplo numérico paso a paso

Usamos el fixture de aceptación §16.1 (tests/ledger/test_sim_s_acceptance.py::test_reversion_ejemplo_16_1): caja inicial 1.000, coste zero, tick 0,01.

Barra open close Objetivo decidido al cierre
0 100 100 +1
1 110 108 −1
2 105 103 0
3 95 96 0
sequenceDiagram
    participant P as Política
    participant L as Ledger
    P->>L: cierre 0: objetivo +1
    L->>L: open 1 = 110 → compra 1 (delta +1)<br/>caja 1000 − 110 = 890
    P->>L: cierre 1: objetivo −1
    L->>L: open 2 = 105 → vende 2 (delta −2, reversión)<br/>caja 890 + 210 = 1100
    P->>L: cierre 2: objetivo 0
    L->>L: open 3 = 95 → compra 1 (delta +1)<br/>caja 1100 − 95 = 1005
Barra Fill (delta @ precio) Posición Caja Equity = caja + pos·close
0 — (la barra 0 no puede ejecutar nada) 0 1.000 1.000
1 +1 @ 110 +1 890 890 + 108 = 998
2 −2 @ 105 −1 1.100 1.100 − 103 = 997
3 +1 @ 95 0 1.005 1.005

Fills: [+1, −2, +1]. Equity final 1.005 (en unidades mínimas, 100_500), que es lo que exige el test.

Y con comisión

Con tarifa 0,1 % y todos los precios en 100 (test_reversion_es_delta_dos_con_costes_de_dos_unidades): la compra inicial paga 1 × 100 × 0,001 = 0,10 y la reversión paga 2 × 100 × 0,001 = 0,20. Coste total 0,30. Si la reversión se contara como una sola unidad, saldría 0,20: es el error que este caso detecta.

Posición final abierta

Aperturas [100, 110, 120], cierres [100, 115, 130], objetivos [1, 1, 1]: un solo fill (compra a 110). No hay venta final: caja 890 y equity 890 + 130 = 1.020.


6. Tablas de salida

Los cuatro carriles devuelven el mismo CanonicalResult con cuatro tablas:

Tabla Columnas clave
intents objetivo por barra, decision_index_valid, motivo (REASON_WARMUP, REASON_NOT_TRADABLE…)
fills signal_index, fill_index, event_ts_ns, side, qty, price_ticks, commission_minor, order_id_canonical
positions posición por barra
equity cash_minor, equity_minor por barra

Los fills son columnares (FillColumns, DEV-21): materializar un objeto Python por fill costaba más que toda la aritmética. order_id_canonical = f"{asset_id}-{contract_hash}-{signal_index}" tiene la misma forma en el ledger y en Nautilus, así que compararlos es comparar cadenas.

Cash canónico frente a saldo nativo

Con cuenta MARGIN, Nautilus mantiene el notional fuera del efectivo y su saldo no coincide con el cash de adquisición. El proyecto construye canonical_cash y canonical_equity desde los fills (cash_after = cash_before − delta·mult·price − commission) y conserva el saldo nativo en run_metrics sin sustituirlo.


7. Sizing del ledger Numba

Con invest_fraction, el ledger Numba calcula cantidades según la equity. rebalance_each_bar decide qué pasa con la posición abierta:

Modo (P01 long-only, NVDA 1d, 242 cambios) Fills Coste total Equity final
Tamaño congelado (False, defecto) 242 667,25 45.889,27
Rebalanceo continuo (True) 476 656,24 44.880,58

Menos coste con más fills parece contradictorio: al recortar posición cuando la equity baja, el rebalanceo mantiene notionales más pequeños y la comisión es proporcional al notional. Son dos contratos distintos y hay que declarar cuál se usó.


8. Rendimiento

Sobre NVDA 1min (632.095 barras), P01 20/50, 12.576 fills (runs/env/ledger_timings_dev.json, medidas de desarrollo con máquina compartida):

Medida Mediana
Ledger NumPy (contabilidad) 48,4 ms
Ledger Numba, sizing 0,98 98,1 ms

En la campaña WP-08b, simulate_ns del pipeline completo fue 12,6 ms para 12.576 fills, el 2,6 % del pipeline de P01 (487 ms): por eso un ledger en Rust se declaró innecesario.


Resumen

  • El oráculo Decimal es el juez; el ledger NumPy y el Numba deben coincidir con él al céntimo.
  • La orden es el delta de objetivos; una reversión +1 → −1 es −2 con comisión doble.
  • Precios en ticks enteros, comisiones en unidades mínimas con half-even construido en enteros.
  • Costes zero/5bps son de ensayo; el spread se cobra una vez, en el fill.
  • Ejemplo §16.1: fills [+1, −2, +1], equity final 1.005.
  • El ledger no es el cuello de botella (2,6 % del pipeline).

Para practicar

  1. Repite el ejemplo §16.1 con escenario 5bps y calcula la comisión de cada fill en céntimos (recuerda el empate al par).
  2. ¿Qué comisión cobra half-even y cuál half-up a 1 unidad de 170,00 con 5 pb? ¿Y a 150,00? (Solo uno de los dos casos distingue las reglas.)
  3. Con objetivos [1, 1, 0, 0] y la barra 1 marcada is_tradable=False, ¿en qué barra se ejecuta la compra? Justifícalo con D-25.
  4. Explica por qué redondear los precios antes de calcular la EMA cambiaría las señales de P01.