4. La orden como máquina de estados
Nivel 8: Estado y ejecución fiable
Una orden no es un evento, es un proceso con vida propia. Tratarla como un booleano ("mandada, sí o no") es el error que produce las posiciones fantasma. Modelarla como una máquina de estados te obliga a escribir de antemano todo lo que puede pasarle, y a que el código diga "no" cuando ocurra algo imposible en vez de aceptarlo en silencio.
Los estados y sus transiciones
Siete estados bastan para la inmensa mayoría de los sistemas:
- CREADA. La intención está en tu base de datos y todavía no ha salido a la red.
- ENVIADA. La petición salió; aún no sabes si el bróker la aceptó.
- ACEPTADA. El bróker la reconoce y vive en el libro.
- PARCIAL. Se ha ejecutado una parte de la cantidad. El resto sigue vivo.
- EJECUTADA, CANCELADA, RECHAZADA. Estados terminales: de aquí no se sale.
La ventaja de escribir las transiciones en una tabla, y no repartidas en condicionales por todo el código, es que la tabla se lee de un vistazo y se revisa en cinco minutos. Cuando alguien tenga que añadir un estado nuevo, verá exactamente desde dónde se puede llegar a él y a dónde puede ir.
Que los estados terminales sean terminales no es una obviedad, es una defensa. Un evento retrasado que intente devolver una orden EJECUTADA a PARCIAL debe rebotar contra una excepción, porque significa que algo llegó desordenado y que aplicarlo corrompería tu contabilidad.
TRANSICIONES = {
"CREADA": {"ENVIADA", "RECHAZADA"},
"ENVIADA": {"ACEPTADA", "RECHAZADA", "PARCIAL", "EJECUTADA"},
"ACEPTADA": {"PARCIAL", "EJECUTADA", "CANCELADA", "RECHAZADA"},
"PARCIAL": {"PARCIAL", "EJECUTADA", "CANCELADA"},
"EJECUTADA": set(), "CANCELADA": set(), "RECHAZADA": set(),
}
class TransicionIlegal(Exception):
pass
def transitar(actual, nuevo):
if nuevo not in TRANSICIONES[actual]:
raise TransicionIlegal(actual + " -> " + nuevo)
return nuevoLas ejecuciones parciales cambian la aritmética
Pides 500 títulos y te llenan 180. Tu posición ya no es la que el modelo cree, y la orden sigue viva por los 320 restantes. Dos reglas que evitan el 90% de los errores de contabilidad:
- La cantidad ejecutada se asigna, no se suma. Casi todos los brókers mandan el total acumulado en cada evento, no el incremento. Si acumulas sumando, cuentas varias veces las mismas acciones.
- Toda orden viva tiene cantidad pendiente, y esa cantidad es lo que hay que consultar antes de decidir nada nuevo sobre ese símbolo.
Y ahora el caso que arruina la tarde: cancelas una orden parcialmente ejecutada. La cancelación solo elimina la parte pendiente; lo ya ejecutado es tuyo para siempre. Si tu código trata la cancelación como "esta orden no ha pasado" y pone la posición a cero, acabas de crear una posición invisible de 180 títulos. Cancelar nunca deshace: solo detiene.
def aplicar_evento(fila, evento):
# filled_qty llega ACUMULADO: se asigna, jamas se suma.
if evento.filled_qty < fila["ejecutada"]:
raise ValueError("evento atrasado, se descarta")
fila["ejecutada"] = evento.filled_qty
fila["pendiente"] = fila["cantidad"] - evento.filled_qty
if fila["pendiente"] == 0:
destino = "EJECUTADA"
elif evento.estado == "canceled":
destino = "CANCELADA" # conservamos lo ya ejecutado
elif evento.filled_qty > 0:
destino = "PARCIAL"
else:
destino = "ACEPTADA"
if destino != fila["estado"]: # repetir el mismo evento no debe romper nada
fila["estado"] = transitar(fila["estado"], destino)
return filaEventos repetidos, atrasados y perdidos
Los avisos de ejecución llegan por un canal que no es fiable: puede repetir un mensaje, entregarlos fuera de orden o dejar de emitir sin cerrar la conexión. Por eso la función anterior hace dos cosas que parecen paranoia y no lo son. Descarta cualquier evento cuya cantidad acumulada sea menor que la ya registrada, porque solo puede ser un mensaje atrasado. Y como asigna en vez de sumar, procesar dos veces el mismo evento es inofensivo: es la misma idea de idempotencia del módulo dos, aplicada ahora a la entrada de datos en lugar de a la salida de órdenes.
Lo que ninguna de las dos defensas cubre es el evento que nunca llega. Contra eso solo hay una cosa: no confiar únicamente en el canal de avisos y consultar por REST el estado de toda orden que lleve demasiado tiempo sin moverse.
Órdenes huérfanas al arrancar
Una orden huérfana es la que existe en un lado y no en el otro. Al arrancar te vas a encontrar tres familias, y cada una tiene su respuesta:
- En tu base de datos como CREADA o ENVIADA, y el bróker no la conoce. Como el identificador es determinista, preguntas por él; si de verdad no existe, puedes reenviarla, siempre que la señal siga vigente.
- Viva en el bróker y sin fila en tu base de datos. Mira el prefijo del identificador: si es tuyo, adóptala creando la fila que falta con el estado que diga el bróker. Si no lo es, no la toques: ponla en cuarentena y avisa. Cancelar órdenes ajenas es una forma excelente de romper el sistema de otro.
- En estado no terminal desde hace horas. Sospecha de un evento perdido, no de una orden lenta. Consulta su estado real por REST antes de tomar cualquier decisión.
Regla de arranque: ninguna señal nueva se ejecuta hasta que todas las órdenes en estado no terminal se han resuelto. Un robot que empieza a operar con órdenes dudosas abiertas está apostando dinero a que su caché tenía razón, y ya sabes quién manda.
Escenario trabajado: la cancelación de las 17:20
Escenario construido para el curso. El sistema tiene una venta de 500 títulos que lleva media hora en el libro. A las 17:20 decide cancelarla para reemplazarla por otra más agresiva. En ese momento ya se habían ejecutado 180. El código marca CANCELADA y, al no distinguir estados, deja la posición del símbolo en 500 largos, cuando en realidad quedan 320. La orden de reemplazo se calcula sobre 500 y vende 500: 180 títulos de más, una posición corta de 180 que ninguna estrategia pidió y que nadie mira, porque el sistema cree estar plano. Con la máquina de estados anterior, la cancelación conserva la cantidad ejecutada, la posición baja a 320 y la orden de reemplazo sale por 320.