1. El estado es el problema: qué guardar, dónde y por qué
Nivel 8: Estado y ejecución fiable
Un backtest no tiene estado. Arranca, recorre un vector de precios y termina. Si lo matas a la mitad lo vuelves a lanzar y no ha pasado nada. Un sistema en producción es justo lo contrario: existe para acumular estado a lo largo del tiempo, y ese estado vale dinero. Si lo pierdes no pierdes una ejecución, pierdes la capacidad de saber qué tienes en el mercado.
De qué está hecho el estado
Cuando decimos "el estado" de un sistema de trading estamos hablando de cinco cosas distintas, y conviene no mezclarlas porque cada una se guarda y se recupera de forma diferente:
- Posiciones abiertas. Cuántos títulos de cada símbolo tienes y a qué precio medio entraste.
- Órdenes vivas. Lo que has mandado y todavía no ha terminado: aceptadas, esperando en el libro, parcialmente ejecutadas.
- Efectivo y poder de compra. Cuánto queda realmente disponible una vez descontadas las retenciones de las órdenes que siguen vivas.
- Última barra procesada. El sello temporal de la última vela sobre la que corriste el modelo. Sin este dato, al reiniciar no sabes si tienes que reprocesar o no, y acabas emitiendo la misma señal dos veces o saltándote una.
- Señales pendientes. Decisiones ya tomadas que aún no se han convertido en orden enviada. Es el trozo de estado que más se olvida y el que más caro sale.
Por qué la memoria del proceso no vale
Un proceso se muere. No es una hipótesis remota: se muere porque despliegas una versión nueva, porque el sistema operativo lo mata al quedarse sin memoria, porque una excepción no capturada sube hasta arriba, porque el servidor se reinicia por un parche del kernel, o porque alguien lo para con la mano. Y aquí está lo importante: el mercado no se entera de que tu Python ha muerto. La posición sigue abierta, las órdenes siguen en el libro y las ejecuciones siguen produciéndose mientras tu proceso no existe.
Un diccionario en memoria tiene además un defecto peor que desaparecer: miente en silencio. Mientras el proceso vive, esa variable parece verdad absoluta, y nadie comprueba nunca si sigue coincidiendo con la realidad.
El bróker es la fuente de verdad; tu base de datos es una caché
Esta es la regla que ordena todo el nivel: el bróker es la fuente de verdad de las posiciones y de las órdenes; tu base de datos es una caché y un diario de intenciones. Ante cualquier discrepancia gana el bróker, siempre, sin discusión. Tu trabajo no es tener razón, es detectar la diferencia y dejar de operar hasta entenderla. El proceso formal de comparación y ajuste tiene nombre propio, se llama conciliación, y se trata a fondo en el nivel 10; aquí basta con que asumas quién manda.
Ahora bien, hay información que el bróker no tiene y que solo existe en tu lado:
- Por qué mandaste esa orden: qué estrategia, qué señal, qué barra la generó.
- Qué señales has decidido y todavía no has enviado.
- Hasta dónde has procesado el flujo de datos.
- Tus identificadores de cliente y el vínculo entre cada orden y su estrategia.
De ahí sale la regla práctica: pregunta siempre al bróker las posiciones, las órdenes abiertas y el efectivo; guarda en disco todo aquello que el bróker no pueda devolverte nunca.
Escribir en disco sin dejar el fichero a medias
Abrir un fichero en modo escritura lo vacía de inmediato. Si el proceso muere entre ese vaciado y el final del volcado, te queda un fichero truncado o vacío, y al arrancar el sistema no encuentra estado corrupto: encuentra ninguno. La solución es no escribir nunca encima del bueno.
import json
import os
import tempfile
def guardar_atomico(ruta, datos):
directorio = os.path.dirname(os.path.abspath(ruta))
fd, temporal = tempfile.mkstemp(dir=directorio, suffix='.tmp')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump(datos, f, ensure_ascii=False, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(temporal, ruta)
except BaseException:
if os.path.exists(temporal):
os.remove(temporal)
raiseTres detalles que no son adorno. El temporal se crea en el mismo directorio porque el renombrado solo es atómico dentro del mismo sistema de ficheros. El fsync fuerza los bytes al disco de verdad, no a la memoria intermedia del sistema operativo, antes de que el renombrado los declare oficiales. Y os.replace es atómico tanto en Linux como en Windows: cualquier lector verá el fichero viejo entero o el nuevo entero, nunca una mezcla.
Mejor todavía: que lo haga una base de datos
Si vas a escribir varias cosas que deben cambiar juntas, un JSON se te queda corto enseguida. SQLite viene en la biblioteca estándar, no necesita servidor y te da transacciones y durabilidad hechas.
import sqlite3
con = sqlite3.connect('estado.db', timeout=30)
con.execute('PRAGMA journal_mode=WAL')
con.execute('PRAGMA synchronous=FULL')
con.execute("""
CREATE TABLE IF NOT EXISTS ordenes (
client_order_id TEXT PRIMARY KEY,
simbolo TEXT NOT NULL,
lado TEXT NOT NULL,
cantidad REAL NOT NULL,
estado TEXT NOT NULL,
barra TEXT NOT NULL,
broker_id TEXT,
creada_en TEXT NOT NULL
)""")
con.commit()Fíjate en la primera columna: es clave primaria. Esa restricción, que parece una nota al pie, es el pilar de todo el módulo siguiente.
Escenario trabajado: cuarenta segundos de ceguera
Escenario construido para el curso, no un caso documentado. Despliegas a las 15:44:10 y el proceso tarda cuarenta segundos en volver. En ese hueco, una orden de compra de 300 títulos que ya estaba en el libro se ejecuta parcialmente: 120 títulos. El proceso arranca, lee su fichero local, que se guardó antes del despliegue y dice "0 ejecutado, orden viva", y decide que sigue plano. La siguiente señal calcula el tamaño desde cero y manda otra compra de 300. Resultado: 420 títulos donde el modelo creía tener 300, un 40% de exposición extra que ninguna regla de riesgo autorizó, y todo por confiar en una caché de cuarenta segundos de antigüedad en lugar de preguntar. El fallo no fue del mercado ni del bróker: fue creer que un fichero sabe lo que pasa fuera.