1. Probar un sistema de trading es más difícil
Nivel 12: Probar, desplegar y dar el paso
Llegas aquí con un sistema que ya existe: estructura determinista sin efectos ocultos, estado idempotente gobernado por una máquina de estados, un orquestador que respeta el calendario, registro, métricas, alertas y reconciliación, e interruptores para los modos de fallo conocidos. Nada de eso se vuelve a explicar. La pregunta que abre este último nivel es otra y es incómoda: cómo demuestras que todo eso funciona antes de que te lo demuestre el mercado con dinero encima.
Por qué esto no se prueba como se prueba una web
Una prueba decente necesita tres cosas: entrada reproducible, unidad aislada del mundo y fallo barato. Un sistema de trading rompe las tres. El mercado no entrega dos veces los mismos datos, así que si tu prueba descarga precios ya no es una prueba, es un experimento: cuando se ponga roja no sabrás si has roto el código o si el mercado se ha portado distinto. La pieza que quieres probar habla con un bróker que no es tuyo, al que no puedes reiniciar ni pedirle que rechace una orden a las 10:31 para ver qué hace tu código. Y el fallo no cuesta un ticket de soporte, cuesta una posición.
De ahí salen las cuatro decisiones de este módulo, y ninguna es opcional: aislar la lógica pura, sustituir el bróker por un doble que tú controlas, provocar los casos raros a propósito y comparar cada versión con la anterior sobre los mismos datos.
La lógica pura se prueba con datos congelados
La parte que decide no lee del disco, no llama a una API y no mira el reloj. Recibe datos y estado, devuelve órdenes propuestas, y con la misma entrada devuelve siempre la misma salida. Si tu función de decisión consulta la hora por dentro, no tienes una prueba: tienes un resultado que caduca. Los datos van escritos a mano en el repositorio, con números elegidos para que el caso sea evidente.
import pandas as pd
from estrategia import decidir
CONFIG = {"ventana": 3, "unidades": 10}
def indice(n):
return pd.date_range("2024-03-01", periods=n, freq="B", tz="UTC")
def test_el_cruce_dispara_una_sola_compra():
velas = pd.DataFrame({"cierre": [100.0, 101.0, 99.0, 103.0, 104.0]}, index=indice(5))
ordenes = decidir(velas, posicion=0, config=CONFIG)
assert [(o.lado, o.cantidad) for o in ordenes] == [("compra", 10)]
def test_sin_senal_no_manda_nada():
velas = pd.DataFrame({"cierre": [100.0] * 5}, index=indice(5))
assert decidir(velas, posicion=0, config=CONFIG) == []Fíjate en la segunda prueba. Casi todo el mundo comprueba que la señal se dispara y se olvida de comprobar que no se dispara cuando no toca. En producción el caso frecuente es el segundo, y una orden espuria cuesta lo mismo que una orden buena.
Un bróker de mentira que se porta mal a propósito
Contra el bróker real no se prueba: es lento, tiene límites de peticiones y sus rechazos no se piden por encargo. Se prueba contra un doble que implementa la misma interfaz y al que tú le dictas el guion. Su valor no está en devolver "ejecutada", sino en devolver lo que el real devuelve un martes malo.
class BrokerFalso:
def __init__(self, guion):
self.guion = list(guion)
self.enviadas = []
def enviar(self, orden):
self.enviadas.append(orden)
if not self.guion:
raise AssertionError("el sistema mando mas ordenes de las previstas")
return self.guion.pop(0)
def test_la_ejecucion_parcial_no_infla_la_posicion():
broker = BrokerFalso([
{"estado": "parcial", "pedido": 10, "ejecutado": 4, "id": "A1"},
{"estado": "rechazada", "motivo": "fuera de horario", "id": "A2"},
])
motor = Motor(broker, posicion=0)
motor.ciclo(senal="compra 10")
assert motor.posicion == 4
motor.ciclo(senal="compra 6")
assert motor.posicion == 4
assert len(broker.enviadas) == 2Ese doble es también el sitio donde se comprueba la idempotencia del nivel 8: llama dos veces al mismo ciclo con el mismo identificador de intención y verifica que el bróker recibió una orden, no dos.
Los casos raros son el caso normal
- Ejecución parcial. Pides diez, te dan cuatro. Tu posición ya no es la que tu código cree.
- Orden rechazada. Fondos, precio fuera de banda, mercado cerrado, límite de peticiones. Un "no" también es una respuesta y hay que tratarla.
- Hueco en los datos. Falta una vela, o llegan dos con la misma marca de tiempo. Tu ventana móvil se calcula sobre menos observaciones de las que crees y nadie avisa.
- Respuesta que tarda demasiado. La peor: no sabes si la orden llegó. El caso obligatorio es tiempo de espera agotado seguido de consulta del estado real, nunca reenvío automático.
- Confirmación repetida. El bróker anuncia dos veces la misma ejecución. Si sumas, tu contabilidad se dobla y la reconciliación del nivel 10 se pasa el día gritando.
Comparar la versión nueva con la anterior
La última familia de pruebas no comprueba que el resultado sea correcto, sino que no ha cambiado sin que tú lo pidieras. Guardas la salida de la versión actual sobre un tramo fijo de datos y cada cambio posterior se mide contra ella.
import json, pathlib
REFERENCIA = pathlib.Path("pruebas/referencia_2019_2021.json")
def test_las_decisiones_no_cambian_sin_avisar():
salida = [o.como_dict() for o in reproducir("datos/tramo_2019_2021.parquet")]
esperado = json.loads(REFERENCIA.read_text(encoding="utf-8"))
assert salida == esperado, "cambian las decisiones: revisa antes de tocar la referencia"Cuando esa prueba se pone roja hay dos desenlaces legítimos: o has roto algo, o has cambiado la estrategia a propósito y actualizas la referencia en el mismo commit que lo explica. Actualizarla porque molestaba no es uno de ellos.
Escenario trabajado
No es un caso histórico, es un ejemplo montado con cifras concretas. Un sistema trocea las órdenes grandes en lotes. Una refactorización mueve el redondeo del tamaño de lote de un módulo a otro. Las 140 pruebas unitarias pasan. La prueba de comparación falla en 3 de 512 decisiones del tramo de referencia: un tamaño de 97 unidades pasa a trocearse en 50 y 47 en vez de 49 y 48. Parece cosmético. No lo es: el mismo redondeo aplicado al cierre de la posición dejaba una unidad huérfana, y esa unidad aparecía cada pocas semanas como descuadre en la reconciliación diaria, con su alerta correspondiente a las siete de la mañana. Un 0,6% de decisiones distintas y ninguna prueba unitaria capaz de verlo.