Un matching engine spot es una pequeña máquina de estados rodeada de restricciones duras: prioridad precio-tiempo, ejecuciones parciales, una latencia de cola que no se mueve bajo ráfagas de carga. Así se construyen el libro de órdenes, el bucle de emparejamiento, el journal y el arnés de replay, y aquí es donde el lenguaje deja de ayudar.
El matching engine de un exchange spot tiene un trabajo acotado. Toma un flujo ordenado de comandos - nueva orden, cancelación, reemplazo -, los aplica a un libro de órdenes bajo prioridad precio-tiempo y emite un flujo ordenado de eventos: operaciones, actualizaciones del libro, rechazos y acuses de recibo.
Todo lo difícil viene de tres restricciones apiladas sobre ese trabajo: el resultado debe ser idéntico en cada replay de la misma entrada, el remanente de una orden parcialmente ejecutada debe conservar su lugar en la cola, y la latencia de cola no debe moverse cuando llega una ráfaga.
El libro son dos lados, cada uno una colección de niveles ordenada por precio. Un nivel no es un número: es una cola de órdenes en reposo a ese precio, en orden de llegada. El emparejamiento toca el mejor nivel constantemente y los niveles profundos rara vez, así que la estructura se elige por ese patrón de acceso y no por elegancia.
La consecuencia de las colas intrusivas y los handles de índice es que una orden en reposo nunca se mueve en memoria mientras vive. Su posición en la cola es una propiedad de sus enlaces, no del lugar donde esté ubicada, y eso es lo que abarata las ejecuciones parciales después.
Una orden agresiva entrante recorre el lado opuesto desde el mejor precio hacia dentro. En cada nivel recorre la cola FIFO desde el frente. Se detiene cuando el precio del nivel deja de ser aceptable para la orden entrante o cuando la cantidad entrante llega a cero.
Una orden en reposo parcialmente ejecutada conserva su lugar. Su remanente permanece al frente de su cola con su secuencia de llegada original, porque una ejecución cambia una cantidad y nada más. Una orden agresiva parcialmente ejecutada que sea un limit simple pasa a ser una orden en reposo al final de su propio nivel de precio, con una nueva secuencia de llegada: llegó ahora, no antes.
La semántica de los tipos de orden se decide en la frontera de este bucle, no dentro de él. Immediate-or-cancel descarta el remanente en lugar de dejarlo en reposo. Fill-or-kill hace primero una pasada en seco y o ejecuta entera o rechaza. Post-only rechaza si la orden cruzaría al llegar. Mantener esto fuera del bucle hace que el bucle siga siendo el único sitio donde cambia el estado del libro.
La prevención de self-trade, las cantidades mínimas y la validación de tick y lote también van antes del bucle. Una orden que llega al emparejamiento ya está probada como bien formada, así que el bucle no tiene ramas de error que lo ralenticen o sobre las que discrepar.
La latencia media rara vez es el problema. El problema es la peor observación durante una ráfaga, que es cuando el engine más importa y cuando es más probable que caiga una pausa stop-the-world. Bajo un runtime gestionado con garbage collector, esa pausa la programa el recolector y no tú, y aterriza en mitad de la ráfaga que generó la basura.
La asignación manual es una versión menor del mismo problema. Un asignador de propósito general puede recorrer free lists, tomar un lock o pedir más memoria al kernel, y la llamada que lo hace es la que aparece en la cola. La solución es la misma en ambos casos: no asignar memoria en el hot path.
Un engine es determinista cuando la misma secuencia de entrada produce la misma secuencia de salida, byte a byte, en otra máquina y un año después. Cualquier cosa dentro de la ruta de emparejamiento que lea el reloj de pared, la planificación de hilos o el orden de iteración de un hash rompe esa propiedad.
Por tanto, las marcas de tiempo son una entrada, no algo que el engine lea por su cuenta. El secuenciador sella un comando cuando lo acepta, y el bucle de emparejamiento trata el sello como un dato. La aleatoriedad, si hace falta alguna, viene de un generador con semilla, y esa semilla forma parte del journal.
La recuperación no es una funcionalidad añadida después de que el emparejamiento funcione. El engine escribe un journal append-only de comandos aceptados en orden de secuencia, y el libro en memoria no es más que el resultado de plegar ese journal. Reconstruir tras una caída significa reproducirlo.
El engine escribe el journal, pero la durabilidad es una propiedad de la ruta de almacenamiento y de cuántas máquinas tienen el registro antes de que salga el acuse de recibo. Esa es una decisión de replicación y de hardware, y ahí es donde realmente se gana o se pierde el tiempo de recuperación.
El determinismo es lo que hace testeable el engine. Como la misma entrada da la misma salida, una sesión capturada es un test de regresión, y un fallo encontrado una vez puede reproducirse exactamente en lugar de perseguirse.
Un modelo de referencia vale más de lo que parece. Dos implementaciones escritas a partir de la misma especificación discrepan justo donde la especificación era ambigua, y las reglas de emparejamiento están llenas de ambigüedad en los bordes: límites cruzados, remanentes en cero, cancelaciones compitiendo con ejecuciones.
Rust elimina una categoría de problema en lugar de hacer el bucle más rápido por sí mismo. No hay garbage collector, así que no se programa ninguna pausa a tus espaldas. La propiedad convierte la disciplina de un solo escritor en algo que impone el compilador en vez de algo que una revisión de código tiene que detectar. Los handles de slab y los enlaces intrusivos, propensos a errores en un lenguaje sin lifetimes, aquí son verificables. Los panics por desbordamiento de enteros en las builds de debug atrapan una clase de fallo que corrompe un libro en silencio.
El límite honesto es que la mayor parte de lo que determina la latencia de cola no es el lenguaje:
Rust también cuesta algo. El borrow checker frena las primeras semanas de un diseño que todavía se mueve, el ecosistema de protocolos específicos de exchanges es más pobre que en lenguajes más antiguos, y los bloques unsafe alrededor de estructuras lock-free necesitan la misma disciplina de revisión que el código equivalente en cualquier otro sitio. Elegirlo es una decisión sobre la latencia de cola y sobre la seguridad de memoria en un núcleo de un solo escritor, no una decisión sobre la comodidad del desarrollador.
amBrain construye infraestructura de trading en Ereván, Armenia, desde 2019, con los hot paths en Rust: datos de mercado entregados en menos de 5 ms, verificaciones de riesgo pre-trade en menos de 1 ms. Si estás diseñando un matching engine y quieres repasar la estructura del libro, el formato del journal o el arnés de replay, esa conversación vale la pena antes de escribir la primera línea del hot path.
Nuestro equipo de ingeniería se especializa en soluciones de FinTech. Hablemos de cómo podemos hacer realidad tu proyecto.