Zuverlässige Krypto-Orderausführung, die wirklich hält
Zuverlässige Krypto-Orderausführung entsteht durch State Machines, Idempotenz-Keys und konsequente Abstimmung. Lektionen aus dem Bau von MadaiOps.
Von Vitor Lima
Die meisten Krypto-Trading-Produkte verkaufen Geschwindigkeit und einen Chart. Das Schwierige ist nicht, die Kerzen zu zeichnen. Es ist die Fähigkeit, in jeder Millisekunde eine trügerisch einfache Frage zu beantworten: Was ist gerade jetzt der wahre Zustand meiner Order? Als wir MadaiOps zu bauen begannen, unsere interne App für Orders und Trading-Operations, ordnete diese Frage alles andere unter. Geschwindigkeit ist ein Feature. Wahrheit ist das Produkt. Zuverlässige Krypto-Orderausführung ist das, was du hast, wenn die App diese Frage ehrlich beantworten kann, selbst während die Börse gerade schwächelt. Und eine App, die einen Fill anzeigt, der nie stattfand — oder einen verbirgt, der stattfand — zerstört Vertrauen auf eine Weise, die kein Latenz-Sieg je wieder zurückkaufen kann.
In diesem Beitrag geht es um das Engineering, das eine Orders-App vertrauenswürdig macht: die State Machine, Idempotenz, Reconciliation über WebSockets und die Fehlerbilder, die erst unter Last auftauchen. Wir haben zu all dem eine klare Meinung, denn es sind die langweiligen Entscheidungen, die dich zahlungsfähig halten.
Modelliere die Order als explizite State Machine
Die mit Abstand beste Entscheidung, die wir getroffen haben, war, eine Order nicht länger als Zeile zu behandeln, die man per UPDATE ändert, sondern als endliche State Machine mit benannten, aufgezählten Übergängen. Eine Order lebt in Zuständen wie PENDING_NEW, WORKING, PARTIALLY_FILLED, FILLED, CANCEL_REQUESTED, CANCELED, REJECTED und EXPIRED. Jeder Übergang ist eine Funktion aus dem aktuellen Zustand plus einem eingehenden Event, und unzulässige Übergänge werden lautstark zurückgewiesen, statt still absorbiert zu werden.
Der Gewinn: Mehrdeutigkeit wird zu einem Anliegen zur Validierungszeit statt zu einem Vorfall um zwei Uhr nachts. Eine FILLED-Order kann nie wieder zu WORKING zurückkehren. Ein Fill-Event, das für eine CANCELED-Order eintrifft, ist nicht "unmöglich" — es ist ein echtes Race, und die State Machine zwingt dich zu entscheiden, was es bedeutet. Meist bedeutet es, dass der Cancel das Race verloren hat, die Order tatsächlich ausgeführt wurde und dein lokales CANCELED falsch war. Modellierst du Orders als veränderliche Zeilen, überschreibt dieses Event einfach irgendetwas; du bemerkst es nie, bis die Reconciliation — oder ein Nutzer — es aufdeckt.
Das Fazit
Zähle deine Zustände auf, bevor du einen einzigen API-Call schreibst. Wenn du das Diagramm nicht auf ein Whiteboard zeichnen kannst, hast du deine Domäne noch nicht verstanden, und die Börse wird es dich auf die harte Tour lehren. Speichere die Übergänge, nicht nur den aktuellen Zustand. Ein Append-only-Event-Log dessen, was passiert ist, schlägt eine einzelne veränderliche Status-Spalte, denn das Log ist abspielbar und die Spalte ist eine Vermutung.
Idempotenz ist nicht optional
Netzwerke wiederholen. Deine eigene Retry-Logik wiederholt. Ein Nutzer tippt zweimal auf "Kaufen". Jedes davon kann dieselbe Order zweimal senden, und an einer Börse ist eine doppelte Order echtes Geld. Die Verteidigung ist ein clientseitig generierter Idempotenz-Key — die meisten Börsen nennen ihn clientOrderId oder newClientOrderId — der jeder Order in dem Moment angehängt wird, in dem der Nutzer sie bestätigt, bevor der Request je das Gerät oder den Server verlässt.
Die Regel, die wir durchsetzen: Der Key wird einmal generiert, lokal persistiert bevor der Netzwerk-Call passiert, und bei jedem Retry wortgleich wiederverwendet. Wenn der erste Versuch ohne Antwort in einen Timeout lief, trägt der Retry denselben Key, und die Börse erstellt die Order entweder einmal oder gibt die bestehende zurück. So oder so konvergierst du auf eine einzige Order. Generiere den Key nach einem Fehler, und du hast dir eine Maschine für doppelte Orders gebaut.
Das gilt auch für Cancels und Änderungen. Ein Cancel-Request sollte ebenfalls idempotent sein: das Stornieren einer bereits stornierten Order ist ein No-op, der Erfolg zurückgibt, kein Fehler, der eine weitere Retry-Schleife auslöst. Wir behandeln "der gewünschte Endzustand ist bereits erreicht" überall als Erfolg. Es klingt trivial. Es eliminiert eine ganze Kategorie von Retry-Stürmen.
Das Fazit
Persistiere den Idempotenz-Key, bevor du handelst, nicht nachdem du Erfolg hattest. Die Lücke zwischen "Ich habe es gesendet" und "Ich weiß, dass es geklappt hat" ist genau der Ort, an dem Geld verschwindet, und der Key ist das Einzige, das sie schließt.
Reconcile ohne Nachlassen: der WebSocket ist ein Hinweis, nicht die Wahrheit
Börsen pushen Order-Updates über WebSockets, und die sind wunderbar, wenn sie funktionieren: Fills im Sub-Sekunden-Bereich, Live-Mengen von Teil-Fills, Cancel-Bestätigungen. Aber ein WebSocket ist ein Stream, den du verpassen kannst. Du wirst getrennt werden. Nachrichten treffen in falscher Reihenfolge ein, gehen während eines Reconnects verloren oder kommen schlicht nie, weil die Börse geschwächt ist. Wenn dein Order-Zustand nur das ist, was der Socket dir zuletzt gesagt hat, ist deine App in dem Moment selbstbewusst falsch, in dem der Socket stolpert.
Also fahren wir zwei Loops. Der schnelle Loop konsumiert den WebSocket und wendet Events optimistisch an. Der langsame Loop pollt die REST-API — GET /order und die Open-Orders-Endpoints — in einem Intervall und nach jedem Reconnect, und stimmt diesen Snapshot dann gegen den lokalen Zustand ab. REST ist die Source of Truth; der Socket ist ein latenzarmer Hinweis. Wenn sie widersprechen, gewinnt REST, und wir loggen die Abweichung, denn eine Abweichung ist ein Bugreport, den das System für dich geschrieben hat.
Sequenzierung ist entscheidend. Die meisten Börsen stempeln Updates mit einer monotonen Sequenznummer oder einer updateTime. Wir verwerfen jedes Event, das älter ist als das zuletzt angewandte für diese Order, was Zustellung in falscher Reihenfolge harmlos macht. Beim Reconnect ist der richtige Zug: neu subscriben, sofort einen vollständigen REST-Snapshot ziehen, um die Lücke aufzufüllen, dann dem Live-Stream wieder vertrauen. Lässt du den Snapshot aus, verpasst du den Fill, der während der zwei Sekunden geschah, in denen du offline warst.
Das Fazit
Lass niemals einen Echtzeit-Feed den einzigen Schreiber deines Zustands sein. Der Socket macht dich schnell; der Reconcile-Loop macht dich korrekt. Du brauchst beides, und wenn sie in Konflikt geraten, schlägt korrekt jedes Mal schnell.
Teil-Fills und Verbindungsabbrüche: die beiden, die dich entlarven
Bei Teil-Fills lügen naive Apps still vor sich hin. Eine Order über 1,0 BTC, die 0,3 füllt, dann 0,4, dann hängen bleibt, ist nicht "offen" und sie ist nicht "erledigt". Sie ist PARTIALLY_FILLED mit 0,7 ausgeführt und 0,3 verbleibend, und jede einzelne dieser Zahlen muss aus den einzelnen Fill-Events nachverfolgt werden, nicht aus einem einzigen Status-Feld abgeleitet. Wir akkumulieren die ausgeführte Menge und den volumengewichteten Durchschnittspreis aus den diskreten Fills, sodass die Position immer aus Primitiven rekonstruierbar ist. Ein Nutzer, der "gefüllt" sieht, muss wissen, gefüllt in welcher Höhe, zu welchem Durchschnittspreis. Alles Geringere ist eine Halbwahrheit.
Verbindungsabbrüche verschärfen das. Fliegst du mitten im Fill offline, hat der WebSocket dir nie von den 0,4 erzählt, also ist dein lokaler Zustand veraltet. Genau dafür existiert die Reconnect-dann-Snapshot-Disziplin: Der REST-Snapshot liefert die kumulierte ausgeführte Menge zurück, und der Reconcile-Loop schließt die Lücke, ohne dass der Nutzer je eine falsche Zahl sieht. Das Fehlerbild, gegen das wir designen, ist nicht "die App ist abgestürzt" — Abstürze sind ehrlich. Es ist "die App lief weiter und zeigte etwas Falsches an".
Das Fazit
Verfolge die kumulierte ausgeführte Menge als primäre Tatsache und leite alles andere daraus ab. Speichere "verbleibend" nie als eigenständiges Feld, das du veränderst. Leite es ab (bestellt − ausgeführt), damit es nicht gegenüber der Realität auseinanderdriften kann.
Wahrhaftig unter Druck schlägt clever
Es gibt die beharrliche Versuchung, clever zu sein: Fills vor der Bestätigung vorhersagen, "vorübergehende" Fehler verbergen, einen Reconnect glattbügeln, damit die UI nie flackert. Wir haben gelernt, dem meisten davon zu widerstehen. Ein Trading-Operator will keine glatte Lüge; er will ehrlich wissen, dass die Verbindung gerade abgebrochen ist und der Zustand gerade abgeglichen wird. Ein sichtbarer Zustand "Reconnect läuft, Orders werden geprüft" ist mehr wert als eine nahtlose UI, die sich als Anzeige der Wahrheit von gestern entpuppt.
Konkret machen wir Unsicherheit sichtbar, statt sie zu übertünchen. Wenn lokaler Zustand und Börse sich zuletzt nicht abgeglichen haben, sagt die App das. Wenn eine Order CANCEL_REQUESTED ist, aber noch nicht als storniert bestätigt, zeigt sie genau das, kein verfrühtes CANCELED. Optimistische UI ist in Ordnung für eine To-do-App. Bei Geld ist Optimismus eine Bürde, die du in Support-Tickets und verlorenem Vertrauen bezahlst.
Das Fazit
Im Zweifel: zeige den Zweifel. Das Beruhigendste, was eine Finanz-App unter Stress tun kann, ist sich zu weigern, zu raten.
Abschließender Gedanke
Nichts davon ist glamourös. Es gibt keine Demo, in der "unsere Idempotenz-Keys werden vor dem Netzwerk-Call persistiert" Applaus bekommt. Aber Zuverlässigkeit ist kein Feature, das man später hinzufügt. Sie ist die Menge an Entscheidungen, die du früh triffst und dich weigerst zu kompromittieren, und die du dann jedes Mal verteidigst, wenn eine Abkürzung verlockend aussieht. Baue das System, das die Wahrheit sagt, wenn alles in Flammen steht, und die schnellen, hübschen Teile erledigen sich von selbst.