Un matching engine spot est une petite machine à états entourée de contraintes dures : price-time priority, exécutions partielles, une queue de latence qui ne bouge pas sous charge en rafale. Voici comment sont construits l'order book, la boucle d'appariement, le journal et le harnais de replay, et où le langage cesse d'aider.
Un matching engine de bourse spot a un rôle étroit. Il prend un flux ordonné de commandes - nouvel ordre, annulation, remplacement - les applique à un order book sous price-time priority, et émet un flux ordonné d'événements : trades, mises à jour du carnet, rejets, accusés de réception.
Toute la difficulté vient de trois contraintes empilées par-dessus ce rôle : le résultat doit être identique à chaque replay de la même entrée, le reliquat d'un ordre partiellement exécuté doit garder sa place dans la file, et la queue de latence ne doit pas bouger quand une rafale arrive.
Le carnet, c'est deux côtés, chacun une collection de niveaux ordonnée par prix. Un niveau n'est pas un nombre - c'est une file d'ordres en attente à ce prix, dans l'ordre d'arrivée. L'appariement touche constamment le meilleur niveau et rarement les niveaux profonds : la structure est choisie pour ce schéma d'accès plutôt que pour l'élégance.
La conséquence des files intrusives et des handles d'index est qu'un ordre en attente ne bouge jamais en mémoire tant qu'il vit. Sa position dans la file est une propriété de ses liens, pas de l'endroit où il se trouve, et c'est ce qui rend les exécutions partielles peu coûteuses ensuite.
Un ordre agressif entrant parcourt le côté opposé depuis le meilleur prix vers l'intérieur. À chaque niveau, il parcourt la file FIFO depuis la tête. Il s'arrête quand le prix du niveau n'est plus acceptable pour l'ordre entrant ou quand la quantité entrante atteint zéro.
Un ordre en attente partiellement exécuté garde sa place. Son reliquat reste en tête de sa file avec sa séquence d'arrivée d'origine, car une exécution change une quantité et rien d'autre. Un ordre agressif partiellement exécuté qui est une simple limite devient un ordre en attente en fin de son propre niveau de prix, avec une nouvelle séquence d'arrivée - il est arrivé maintenant, pas plus tôt.
La sémantique des types d'ordres relève de décisions prises à la frontière de cette boucle, pas à l'intérieur. Immediate-or-cancel abandonne le reliquat au lieu de le poser. Fill-or-kill fait d'abord une passe à blanc et exécute en entier ou rejette. Post-only rejette si l'ordre croiserait à l'arrivée. Garder cela hors de la boucle fait que la boucle reste le seul endroit où l'état du carnet change.
La prévention du self-trade, les quantités minimales et la validation du tick et du lot ont aussi leur place avant la boucle. Un ordre qui atteint l'appariement a déjà été prouvé bien formé : la boucle n'a donc aucune branche d'erreur pour la ralentir ou prêter à désaccord.
La latence moyenne est rarement le problème. Le problème, c'est la pire observation pendant une rafale, au moment où le moteur compte le plus et où une pause stop-the-world a le plus de chances de tomber. Sous un runtime managé doté d'un garbage collector, cette pause est planifiée par le collecteur et non par vous, et elle tombe au milieu de la rafale qui a produit les déchets.
L'allocation manuelle est une version réduite du même problème. Un allocateur généraliste peut parcourir des free lists, prendre un verrou ou demander plus de mémoire au noyau, et c'est cet appel-là qui apparaît dans la queue de latence. Le remède est le même dans les deux cas : ne pas allouer du tout sur le hot path.
Un moteur est déterministe quand la même séquence d'entrée produit la même séquence de sortie, octet pour octet, sur une autre machine et un an plus tard. Tout ce qui lit l'horloge murale, l'ordonnancement des threads ou l'ordre d'itération d'un hash à l'intérieur du chemin d'appariement brise cette propriété.
Les horodatages sont donc une entrée, pas quelque chose que le moteur lit de lui-même. Le séquenceur horodate une commande quand il l'accepte, et la boucle d'appariement traite l'horodatage comme une donnée. L'aléa, s'il en faut, vient d'un générateur à graine dont la graine fait partie du journal.
La reprise n'est pas une fonctionnalité ajoutée une fois l'appariement au point. Le moteur écrit un journal en append-only des commandes acceptées dans l'ordre de séquence, et le carnet en mémoire n'est rien d'autre que le résultat du repliement de ce journal. Reconstruire après un crash, c'est le rejouer.
Le moteur écrit le journal, mais la durabilité est une propriété du chemin de stockage et du nombre de machines qui détiennent l'enregistrement avant que l'accusé de réception ne parte. C'est une décision de réplication et de matériel, et c'est là que le temps de reprise se gagne ou se perd réellement.
Le déterminisme est ce qui rend le moteur testable. Comme la même entrée donne la même sortie, une session capturée est un test de non-régression, et une défaillance trouvée une fois se reproduit exactement au lieu d'être pourchassée.
Un modèle de référence vaut plus qu'il n'y paraît. Deux implémentations écrites à partir de la même spécification divergent exactement là où la spécification était ambiguë, et les règles d'appariement sont pleines d'ambiguïté aux bords - limites croisées, reliquats nuls, annulations en course avec des exécutions.
Rust supprime une catégorie de problème plutôt que d'accélérer la boucle par lui-même. Il n'y a pas de garbage collector, donc aucune pause n'est planifiée dans votre dos. L'ownership fait de la discipline d'écrivain unique quelque chose que le compilateur impose au lieu de quelque chose qu'une revue de code doit remarquer. Les handles de slab et les liens intrusifs, sources d'erreurs dans un langage sans lifetimes, sont vérifiables ici. Les panics sur dépassement d'entier dans les builds de debug attrapent une classe de bug qui corrompt silencieusement un carnet.
La limite honnête, c'est que l'essentiel de ce qui détermine la latence de queue n'est pas le langage :
Rust coûte aussi quelque chose. Le borrow checker ralentit les premières semaines d'une conception encore mouvante, l'écosystème pour les protocoles spécifiques aux places de marché est plus mince que dans des langages plus anciens, et les blocs unsafe autour des structures lock-free demandent la même discipline de revue que le code équivalent ailleurs. Le choisir est une décision sur la queue de latence et sur la sûreté mémoire dans un cœur à écrivain unique, pas une décision de confort pour les développeurs.
amBrain construit des infrastructures de trading à Erevan, en Arménie, depuis 2019, avec des hot paths en Rust - données de marché livrées en moins de 5 ms, contrôles de risque pre-trade en moins de 1 ms. Si vous concevez un matching engine et voulez parcourir la structure du carnet, le format du journal ou le harnais de replay, cette conversation vaut la peine d'être tenue avant la première ligne du hot path.
Notre équipe d'ingénierie est spécialisée dans les solutions FinTech. Discutons de la façon dont nous pouvons donner vie à votre projet.