Esecuzione affidabile degli ordini crypto: come si costruisce davvero
L'affidabilità nell'esecuzione degli ordini crypto nasce da macchine a stati, chiavi di idempotenza e riconciliazione ossessiva. Lezioni da MadaiOps.
Di Vitor Lima
La maggior parte dei prodotti di trading crypto vende velocità e un grafico. La parte difficile non è disegnare le candele. È riuscire a rispondere, in qualsiasi millisecondo, a una domanda ingannevolmente semplice: qual è lo stato reale del mio ordine in questo momento? Quando abbiamo iniziato a costruire MadaiOps, la nostra app interna per ordini e operazioni di trading, quella domanda ha organizzato tutto il resto. La velocità è una funzionalità. La verità è il prodotto. L'affidabilità nell'esecuzione degli ordini crypto è ciò che hai quando l'app riesce a rispondere onestamente a quella domanda anche mentre la piattaforma si sta degradando, e un'app che mostra un'esecuzione mai avvenuta — o ne nasconde una realmente avvenuta — distrugge la fiducia in un modo che nessun guadagno di latenza potrà mai ricomprare.
Questo articolo parla dell'ingegneria che rende affidabile un'app di ordini: la macchina a stati, l'idempotenza, la riconciliazione via WebSocket e le modalità di guasto che emergono solo sotto carico. Abbiamo opinioni nette su tutto questo, perché sono le scelte noiose quelle che ti tengono a galla.
Modella l'ordine come una macchina a stati esplicita
La migliore decisione che abbiamo preso è stata smettere di trattare un ordine come una riga da UPDATE e iniziare a trattarlo come una macchina a stati finiti con transizioni nominali ed enumerate. Un ordine vive in stati come PENDING_NEW, WORKING, PARTIALLY_FILLED, FILLED, CANCEL_REQUESTED, CANCELED, REJECTED ed EXPIRED. Ogni transizione è una funzione dello stato corrente più un evento in ingresso, e le transizioni illegali vengono rifiutate rumorosamente invece di essere assorbite in silenzio.
Il ritorno: l'ambiguità diventa un problema in fase di validazione invece di un incidente alle 2 di notte. Un ordine FILLED non può mai tornare a WORKING. Un evento di esecuzione che arriva per un ordine CANCELED non è "impossibile" — è una race condition reale, e la macchina a stati ti obbliga a decidere cosa significa. Di solito significa che l'annullamento ha perso la corsa, che l'ordine si è effettivamente eseguito e che il tuo CANCELED locale era sbagliato. Modella gli ordini come righe mutabili e quell'evento sovrascrive semplicemente qualcosa; non te ne accorgi finché non lo intercetta la riconciliazione — o un utente.
La lezione
Enumera i tuoi stati prima di scrivere una singola chiamata API. Se non riesci a disegnare il diagramma su una lavagna, non hai ancora capito il tuo dominio, e l'exchange te lo insegnerà nel modo più duro. Memorizza le transizioni, non solo lo stato corrente. Un log di eventi in sola aggiunta di ciò che è accaduto batte una singola colonna di stato mutabile, perché il log è riproducibile e la colonna è un'ipotesi.
L'idempotenza non è opzionale
Le reti fanno retry. La tua stessa logica di retry fa retry. Un utente tocca due volte "Compra". Ognuna di queste cose può inviare lo stesso ordine due volte, e su un exchange un ordine duplicato è denaro reale. La difesa è una chiave di idempotenza generata dal client — la maggior parte degli exchange la chiama clientOrderId o newClientOrderId — associata a ogni ordine nel momento esatto in cui l'utente conferma, prima che la richiesta lasci il dispositivo o il server.
La regola che imponiamo: la chiave viene generata una sola volta, persistita localmente prima della chiamata di rete e riutilizzata identica a ogni retry. Se il primo tentativo va in timeout senza risposta, il retry porta con sé la stessa chiave, e l'exchange o crea l'ordine una volta sola o restituisce quello già esistente. In entrambi i casi convergi su un unico ordine. Genera la chiave dopo un fallimento e avrai costruito una macchina per produrre ordini duplicati.
Questo vale anche per annullamenti e modifiche. Anche una richiesta di annullamento dovrebbe essere idempotente: annullare un ordine già annullato è un no-op che restituisce successo, non un errore che innesca un altro ciclo di retry. Trattiamo "lo stato finale desiderato è già vero" come successo, ovunque. Sembra banale. Elimina un'intera categoria di tempeste di retry.
La lezione
Persisti la chiave di idempotenza prima di agire, non dopo aver avuto successo. Lo spazio tra "l'ho inviato" e "so che ha funzionato" è esattamente il punto in cui il denaro svanisce, e la chiave è l'unica cosa che lo chiude.
Riconcilia senza sosta: il WebSocket è un indizio, non la verità
Gli exchange inviano gli aggiornamenti degli ordini via WebSocket, e sono meravigliosi quando funzionano: esecuzioni in meno di un secondo, quantità di esecuzione parziale in tempo reale, conferme di annullamento. Ma un WebSocket è uno stream che puoi perdere. Verrai disconnesso. I messaggi arrivano fuori ordine, si perdono durante una riconnessione, oppure semplicemente non arrivano mai perché la piattaforma è degradata. Se lo stato del tuo ordine è solo quello che il socket ti ha detto per ultimo, la tua app è convinta di avere ragione ed è nel torto nell'istante esatto in cui il socket ha un singhiozzo.
Perciò eseguiamo due loop. Il loop veloce consuma il WebSocket e applica gli eventi in modo ottimistico. Il loop lento interroga l'API REST — GET /order e gli endpoint degli ordini aperti — a intervalli regolari e dopo ogni riconnessione, poi riconcilia quello snapshot con lo stato locale. REST è la fonte di verità; il socket è un indizio a bassa latenza. Quando i due non concordano, vince REST e registriamo la divergenza, perché una divergenza è una segnalazione di bug che il sistema ha scritto per te.
Il sequenziamento conta. La maggior parte degli exchange marca gli aggiornamenti con un numero di sequenza monotòno o con un updateTime. Scartiamo qualsiasi evento più vecchio dell'ultimo applicato per quell'ordine, il che rende innocua la consegna fuori ordine. Alla riconnessione la mossa corretta è: risottoscrivere, tirare immediatamente uno snapshot REST completo per colmare il vuoto e solo dopo tornare a fidarsi dello stream in tempo reale. Salta lo snapshot e perdi l'esecuzione avvenuta durante i due secondi in cui eri offline.
La lezione
Non lasciare mai che un feed in tempo reale sia l'unico a scrivere lo stato. Il socket ti rende veloce; il loop di riconciliazione ti rende corretto. Ti servono entrambi, e quando sono in conflitto, corretto batte veloce ogni singola volta.
Esecuzioni parziali e disconnessioni: le due cose che ti mettono a nudo
Le esecuzioni parziali sono il punto in cui le app ingenue mentono in silenzio. Un ordine da 1,0 BTC che esegue 0,3, poi 0,4, poi si blocca non è "aperto" e non è "concluso". È PARTIALLY_FILLED con 0,7 eseguiti e 0,3 rimanenti, e ognuno di quei numeri va tracciato a partire dai singoli eventi di esecuzione, non dedotto da un unico campo di stato. Accumuliamo la quantità eseguita e il prezzo medio ponderato per volume a partire dalle esecuzioni discrete, così la posizione è sempre ricostruibile dalle primitive. Un utente che vede "eseguito" deve sapere eseguito quanto, a quale prezzo medio. Qualsiasi cosa in meno è una mezza verità.
Le disconnessioni aggravano tutto questo. Cadi offline a metà esecuzione e il WebSocket non ti ha mai parlato dello 0,4, quindi il tuo stato locale è obsoleto. È esattamente per questo che esiste la disciplina riconnetti-poi-snapshot: lo snapshot REST restituisce la quantità eseguita cumulativa, e il loop di riconciliazione colma il vuoto senza che l'utente veda mai un numero sbagliato. La modalità di guasto contro cui progettiamo non è "l'app è andata in crash" — i crash sono onesti. È "l'app ha continuato a girare mostrando qualcosa di falso".
La lezione
Traccia la quantità eseguita cumulativa come fatto primario e deriva tutto il resto da essa. Non memorizzare mai la quantità "rimanente" come campo indipendente che modifichi. Derivala (ordinato − eseguito) in modo che non possa divergere dalla realtà.
Onesto sotto pressione batte furbo
C'è una tentazione persistente di fare i furbi: prevedere le esecuzioni prima della conferma, nascondere gli errori "transitori", ammorbidire una riconnessione perché la UI non tremoli mai. Abbiamo imparato a resistere quasi sempre. Un operatore di trading non vuole una bugia levigata; vuole sapere, onestamente, che la connessione è appena caduta e che lo stato si sta riconciliando. Uno stato visibile "riconnessione in corso, verifica ordini" vale più di una UI impeccabile che poi si scopre mostrare la verità di ieri.
In concreto, portiamo l'incertezza in superficie invece di coprirla. Quando lo stato locale e l'exchange non si riconciliano da un po', l'app lo dice. Quando un ordine è CANCEL_REQUESTED ma non ancora confermato come annullato, mostra esattamente questo, non un prematuro CANCELED. La UI ottimistica va benissimo per un'app di to-do. Per il denaro, l'ottimismo è una passività che paghi in ticket di supporto e fiducia persa.
La lezione
Nel dubbio, mostra il dubbio. La cosa più rassicurante che un'app finanziaria possa fare sotto stress è rifiutarsi di tirare a indovinare.
Pensiero finale
Niente di tutto questo è affascinante. Non esiste una demo in cui "le nostre chiavi di idempotenza vengono persistite prima della chiamata di rete" strappi un applauso. Ma l'affidabilità non è una funzionalità che aggiungi dopo. È l'insieme delle decisioni che prendi presto e che ti rifiuti di compromettere, e poi difendi ogni volta che una scorciatoia sembra allettante. Costruisci il sistema che dice la verità quando tutto va a fuoco, e le parti veloci e belle si prenderanno cura di sé da sole.