Oluwa.io
Todos los escritos
·8 min de lecturaingenieríacriptofiabilidadtrading

Cómo construir una ejecución de órdenes cripto que no falle

La fiabilidad al ejecutar órdenes cripto nace de máquinas de estado, claves de idempotencia y una reconciliación implacable. Lecciones de MadaiOps.

Por Vitor Lima

La mayoría de los productos de trading cripto venden velocidad y un gráfico. La parte difícil no es dibujar las velas. Es poder responder, en cualquier milisegundo, una pregunta engañosamente simple: ¿cuál es el estado real de mi orden en este momento? Cuando empezamos a construir MadaiOps, nuestra app interna de órdenes y operaciones de trading, esa pregunta lo organizó todo. La velocidad es una funcionalidad. La verdad es el producto. La fiabilidad en la ejecución de órdenes cripto es lo que tienes cuando la app puede responder esa pregunta con honestidad incluso mientras el exchange se degrada, y una app que muestra un fill que no ocurrió — u oculta uno que sí ocurrió — destruye la confianza de una forma que ninguna ganancia en latencia puede recuperar.

Este artículo trata sobre la ingeniería que hace que una app de órdenes sea confiable: la máquina de estado, la idempotencia, la reconciliación sobre WebSockets y los modos de fallo que solo aparecen bajo carga. Tenemos opiniones firmes sobre todo ello, porque las decisiones aburridas son las que te mantienen solvente.

Modela la orden como una máquina de estado explícita

La mejor decisión que tomamos fue dejar de tratar una orden como una fila a la que se le hace UPDATE y empezar a tratarla como una máquina de estados finita con transiciones nombradas y enumeradas. Una orden vive en estados como PENDING_NEW, WORKING, PARTIALLY_FILLED, FILLED, CANCEL_REQUESTED, CANCELED, REJECTED y EXPIRED. Cada transición es una función del estado actual más un evento entrante, y las transiciones ilegales se rechazan de forma ruidosa en lugar de absorberse en silencio.

La recompensa: la ambigüedad se convierte en una preocupación en tiempo de validación en lugar de un incidente a las 2 de la madrugada. Una orden FILLED nunca puede volver a WORKING. Un evento de fill que llega para una orden CANCELED no es "imposible" — es una race real, y la máquina de estado te obliga a decidir qué significa. Normalmente significa que la cancelación perdió la carrera, que la orden en realidad se ejecutó, y que tu CANCELED local estaba equivocado. Si modelas las órdenes como filas mutables, ese evento simplemente sobrescribe algo; y nunca te enteras hasta que la reconciliación — o un usuario — lo detecta.

La conclusión

Enumera tus estados antes de escribir una sola llamada a la API. Si no puedes dibujar el diagrama en una pizarra, todavía no entiendes tu dominio, y el exchange te lo enseñará por las malas. Almacena las transiciones, no solo el estado actual. Un registro de eventos de solo anexado (append-only) de lo que ocurrió le gana a una única columna de estado mutable, porque el registro es reproducible y la columna es una conjetura.

La idempotencia no es opcional

Las redes reintentan. Tu propia lógica de reintentos reintenta. Un usuario toca dos veces "Comprar". Cualquiera de estas cosas puede enviar la misma orden dos veces, y en un exchange una orden duplicada es dinero real. La defensa es una clave de idempotencia generada por el cliente — la mayoría de los exchanges la llaman clientOrderId o newClientOrderId — adjunta a cada orden en el momento en que el usuario confirma, antes de que la petición salga siquiera del dispositivo o del servidor.

La regla que aplicamos: la clave se genera una sola vez, se persiste localmente antes de la llamada de red y se reutiliza literalmente en cada reintento. Si el primer intento agotó el tiempo de espera sin respuesta, el reintento lleva la misma clave, y el exchange o bien crea la orden una vez o bien devuelve la que ya existe. En cualquier caso convergís hacia una única orden. Genera la clave después de un fallo y habrás construido una máquina de crear órdenes duplicadas.

Esto se extiende a las cancelaciones y las modificaciones. Una petición de cancelación también debería ser idempotente: cancelar una orden ya cancelada es una operación sin efecto (no-op) que devuelve éxito, no un error que dispara otro bucle de reintentos. Tratamos "el estado final deseado ya es cierto" como éxito en todas partes. Suena trivial. Elimina una categoría entera de tormentas de reintentos.

La conclusión

Persiste la clave de idempotencia antes de actuar, no después de tener éxito. La brecha entre "lo envié" y "sé que funcionó" es exactamente donde desaparece el dinero, y la clave es lo único que la cierra.

Reconcilia sin descanso: el WebSocket es una pista, no la verdad

Los exchanges envían actualizaciones de órdenes por WebSockets, y son maravillosos cuando funcionan: fills en menos de un segundo, cantidades de fills parciales en vivo, confirmaciones de cancelación. Pero un WebSocket es un stream que puedes perderte. Te vas a desconectar. Los mensajes llegan desordenados, se pierden durante una reconexión o simplemente nunca llegan porque el exchange está degradado. Si el estado de tu orden es únicamente lo último que te dijo el socket, tu app está confiadamente equivocada en el instante en que el socket hipa.

Por eso corremos dos bucles. El bucle rápido consume el WebSocket y aplica los eventos de forma optimista. El bucle lento hace polling a la API REST — GET /order y los endpoints de órdenes abiertas — a intervalos y tras cada reconexión, y luego reconcilia ese snapshot contra el estado local. REST es la fuente de la verdad; el socket es una pista de baja latencia. Cuando discrepan, gana REST y registramos la divergencia, porque una divergencia es un reporte de bug que el sistema escribió por ti.

La secuenciación importa. La mayoría de los exchanges sellan las actualizaciones con un número de secuencia monótono o un updateTime. Descartamos cualquier evento más antiguo que el último aplicado para esa orden, lo que vuelve inofensiva la entrega desordenada. Al reconectar, la jugada correcta es: volver a suscribirse, tirar de inmediato de un snapshot REST completo para rellenar el hueco, y entonces volver a confiar en el stream en vivo. Sáltate el snapshot y te pierdes el fill que ocurrió durante los dos segundos que estuviste desconectado.

La conclusión

Nunca dejes que un feed en tiempo real sea tu único escritor de estado. El socket te hace rápido; el bucle de reconciliación te hace correcto. Necesitas ambos, y cuando entran en conflicto, ser correcto le gana a ser rápido siempre.

Fills parciales y desconexiones: los dos que te dejan expuesto

Los fills parciales son donde las apps ingenuas mienten en silencio. Una orden de 1.0 BTC que ejecuta 0.3, luego 0.4, y después se cuelga no está "abierta" ni está "terminada". Está PARTIALLY_FILLED con 0.7 ejecutados y 0.3 restantes, y cada uno de esos números tiene que rastrearse a partir de los eventos de fill individuales, no inferirse de un único campo de estado. Acumulamos la cantidad ejecutada y el precio promedio ponderado por volumen a partir de los fills discretos, de modo que la posición siempre sea reconstruible a partir de primitivas. Un usuario que ve "ejecutada" necesita saber cuánto se ejecutó y a qué precio promedio. Cualquier cosa menos es una verdad a medias.

Las desconexiones agravan esto. Quédate offline en mitad de un fill y el WebSocket nunca te contó lo del 0.4, así que tu estado local queda obsoleto. Precisamente por esto existe la disciplina de reconectar-y-luego-snapshot: el snapshot REST devuelve la cantidad ejecutada acumulada, y el bucle de reconciliación cierra el hueco sin que el usuario llegue a ver nunca un número equivocado. El modo de fallo contra el que diseñamos no es "la app se cayó" — las caídas son honestas. Es "la app siguió funcionando y mostró algo falso".

La conclusión

Rastrea la cantidad ejecutada acumulada como el hecho principal y deriva todo lo demás a partir de ella. Nunca almacenes "restante" como un campo independiente que mutas. Derívalo (ordenado − ejecutado) para que no pueda desincronizarse de la realidad.

Ser veraz bajo presión le gana a ser ingenioso

Existe una tentación persistente de ser ingenioso: predecir fills antes de la confirmación, ocultar errores "transitorios", disimular una reconexión para que la UI nunca parpadee. Hemos aprendido a resistir casi toda ella. Un operador de trading no quiere una mentira suave; quiere saber, con honestidad, que la conexión acaba de caerse y que el estado se está reconciliando. Un estado visible de "reconectando, verificando órdenes" vale más que una UI impecable que resulta estar mostrando la verdad de ayer.

En concreto, exponemos la incertidumbre en lugar de taparla. Cuando el estado local y el exchange no se han reconciliado recientemente, la app lo dice. Cuando una orden está CANCEL_REQUESTED pero aún no se ha confirmado su cancelación, muestra exactamente eso, no un CANCELED prematuro. La UI optimista está bien para una app de tareas pendientes. Para el dinero, el optimismo es un pasivo que pagas en tickets de soporte y confianza perdida.

La conclusión

Ante la duda, muestra la duda. Lo más tranquilizador que puede hacer una app financiera bajo estrés es negarse a adivinar.

Reflexión final

Nada de esto es glamuroso. No hay una demo donde "nuestras claves de idempotencia se persisten antes de la llamada de red" arranque aplausos. Pero la fiabilidad no es una funcionalidad que añades después. Es el conjunto de decisiones que tomas temprano y te niegas a comprometer, y luego defiendes cada vez que un atajo parece tentador. Construye el sistema que dice la verdad cuando todo está en llamas, y las partes rápidas y bonitas se cuidan solas.