Como Entregamos Execução Confiável de Ordens Cripto
Confiabilidade na execução de ordens cripto vem de máquinas de estado, chaves de idempotência e reconciliação incansável. Lições da construção do MadaiOps.
Por Vitor Lima
A maioria dos produtos de trading cripto vende velocidade e um gráfico. A parte difícil não é desenhar os candles. É conseguir responder, a qualquer milissegundo, uma pergunta enganosamente simples: qual é o verdadeiro estado da minha ordem agora? Quando começamos a construir o MadaiOps, nosso app interno de ordens e operações de trading, essa pergunta organizou tudo. Velocidade é uma funcionalidade. Verdade é o produto. Confiabilidade na execução de ordens cripto é o que você tem quando o app consegue responder essa pergunta honestamente mesmo enquanto a exchange está degradando, e um app que mostra uma execução que não aconteceu — ou esconde uma que aconteceu — destrói a confiança de um jeito que nenhum ganho de latência consegue recomprar.
Este post é sobre a engenharia que torna um app de ordens confiável: a máquina de estado, a idempotência, a reconciliação sobre WebSockets e os modos de falha que só aparecem sob carga. Temos opiniões fortes sobre tudo isso, porque as escolhas chatas são as que mantêm você solvente.
Modele a ordem como uma máquina de estado explícita
A melhor decisão que tomamos foi parar de tratar uma ordem como uma linha que você faz UPDATE e começar a tratá-la como uma máquina de estado finita, com transições nomeadas e enumeradas. Uma ordem vive em estados como PENDING_NEW, WORKING, PARTIALLY_FILLED, FILLED, CANCEL_REQUESTED, CANCELED, REJECTED e EXPIRED. Cada transição é uma função do estado atual mais um evento de entrada, e transições ilegais são rejeitadas em voz alta, em vez de absorvidas em silêncio.
O retorno: a ambiguidade vira uma preocupação de tempo de validação, e não um incidente às 2 da manhã. Uma ordem FILLED nunca pode voltar para WORKING. Um evento de execução chegando para uma ordem CANCELED não é "impossível" — é uma corrida real, e a máquina de estado obriga você a decidir o que aquilo significa. Normalmente significa que o cancelamento perdeu a corrida, a ordem de fato foi executada, e o seu CANCELED local estava errado. Modele ordens como linhas mutáveis e esse evento simplesmente sobrescreve alguma coisa; você nunca percebe até que a reconciliação — ou um usuário — pegue o erro.
O aprendizado
Enumere seus estados antes de escrever uma única chamada de API. Se você não consegue desenhar o diagrama no quadro branco, você ainda não entende o seu domínio, e a exchange vai te ensinar do jeito difícil. Armazene as transições, não apenas o estado atual. Um log de eventos append-only do que aconteceu vence uma única coluna de status mutável, porque o log é reproduzível e a coluna é um chute.
Idempotência não é opcional
Redes fazem retry. Sua própria lógica de retry faz retry. Um usuário dá dois toques em "Comprar". Qualquer um desses pode enviar a mesma ordem duas vezes, e numa exchange uma ordem duplicada é dinheiro de verdade. A defesa é uma chave de idempotência gerada pelo cliente — a maioria das exchanges chama de clientOrderId ou newClientOrderId — anexada a cada ordem no momento em que o usuário confirma, antes de a requisição sequer sair do dispositivo ou do servidor.
A regra que impomos: a chave é gerada uma vez, persistida localmente antes da chamada de rede, e reutilizada literalmente em cada retry. Se a primeira tentativa deu timeout sem resposta, o retry carrega a mesma chave, e a exchange ou cria a ordem uma vez ou retorna a que já existe. De qualquer forma, você converge para uma única ordem. Gere a chave depois de uma falha e você construiu uma máquina de ordens duplicadas.
Isso se estende a cancelamentos e modificações. Um pedido de cancelamento também deve ser idempotente: cancelar uma ordem já cancelada é uma operação sem efeito que retorna sucesso, não um erro que dispara mais um loop de retry. Tratamos "o estado final desejado já é verdadeiro" como sucesso em todos os lugares. Parece trivial. Elimina uma categoria inteira de tempestades de retry.
O aprendizado
Persista a chave de idempotência antes de agir, não depois de ter sucesso. A brecha entre "eu enviei" e "eu sei que funcionou" é exatamente onde o dinheiro desaparece, e a chave é a única coisa que fecha essa brecha.
Reconcilie incansavelmente: o WebSocket é uma dica, não a verdade
Exchanges enviam atualizações de ordens via WebSockets, e elas são maravilhosas quando funcionam: execuções em menos de um segundo, quantidades de execução parcial ao vivo, confirmações de cancelamento. Mas um WebSocket é um fluxo que você pode perder. Você vai ser desconectado. Mensagens chegam fora de ordem, são descartadas durante uma reconexão, ou simplesmente nunca chegam porque a exchange está degradada. Se o estado da sua ordem é apenas o que o socket te disse por último, seu app está confiantemente errado no instante em que o socket engasga.
Então rodamos dois loops. O loop rápido consome o WebSocket e aplica eventos de forma otimista. O loop lento faz polling da API REST — GET /order e os endpoints de ordens abertas — em um intervalo e após cada reconexão, e então reconcilia aquele snapshot contra o estado local. O REST é a fonte da verdade; o socket é uma dica de baixa latência. Quando eles discordam, o REST vence e registramos a divergência, porque uma divergência é um relatório de bug que o próprio sistema escreveu para você.
O sequenciamento importa. A maioria das exchanges carimba as atualizações com um número de sequência monotônico ou um updateTime. Descartamos qualquer evento mais antigo que o último aplicado para aquela ordem, o que torna a entrega fora de ordem inofensiva. Na reconexão, o movimento correto é: reinscrever, puxar imediatamente um snapshot REST completo para preencher a lacuna, e então voltar a confiar no fluxo ao vivo. Pule o snapshot e você perde a execução que aconteceu durante os dois segundos em que estava offline.
O aprendizado
Nunca deixe um feed em tempo real ser o único escritor do seu estado. O socket te deixa rápido; o loop de reconciliação te deixa correto. Você precisa dos dois, e quando eles conflitam, correto vence rápido todas as vezes.
Execuções parciais e desconexões: as duas que te expõem
Execuções parciais são onde apps ingênuos mentem discretamente. Uma ordem de 1.0 BTC que executa 0.3, depois 0.4, e então trava não está "aberta" e não está "concluída". Está PARTIALLY_FILLED com 0.7 executados e 0.3 restantes, e cada um desses números precisa ser rastreado a partir dos eventos de execução individuais, não inferido de um único campo de status. Acumulamos a quantidade executada e o preço médio ponderado por volume a partir das execuções discretas, para que a posição seja sempre reconstruível a partir das primitivas. Um usuário que vê "executado" precisa saber executado quanto, a que preço médio. Qualquer coisa aquém disso é uma meia-verdade.
Desconexões agravam isso. Caia offline no meio de uma execução e o WebSocket nunca te contou sobre os 0.4, então seu estado local está desatualizado. É exatamente por isso que existe a disciplina de reconectar-e-tirar-snapshot: o snapshot REST retorna a quantidade executada acumulada, e o loop de reconciliação fecha a lacuna sem que o usuário jamais veja um número errado. O modo de falha contra o qual projetamos não é "o app travou" — travamentos são honestos. É "o app continuou rodando e mostrou algo falso".
O aprendizado
Rastreie a quantidade executada acumulada como o fato primário e derive tudo o mais a partir dela. Nunca armazene "restante" como um campo independente que você muta. Derive-o (ordenado − executado) para que ele não possa sair de sincronia com a realidade.
Ser verdadeiro sob pressão vence ser esperto
Existe uma tentação persistente de ser esperto: prever execuções antes da confirmação, esconder erros "transitórios", suavizar uma reconexão para que a interface nunca pisque. Aprendemos a resistir à maior parte disso. Um operador de trading não quer uma mentira suave; ele quer saber, honestamente, que a conexão acabou de cair e o estado está reconciliando. Um estado visível de "reconectando, verificando ordens" vale mais do que uma interface impecável que acaba mostrando a verdade de ontem.
Concretamente, expomos a incerteza em vez de escondê-la. Quando o estado local e a exchange não reconciliam há um tempo, o app diz isso. Quando uma ordem está CANCEL_REQUESTED mas ainda não foi confirmada como cancelada, ele mostra exatamente isso, não um CANCELED prematuro. Interface otimista é ótimo para um app de lista de tarefas. Para dinheiro, otimismo é um passivo que você paga em tickets de suporte e confiança perdida.
O aprendizado
Na dúvida, mostre a dúvida. A coisa mais tranquilizadora que um app financeiro pode fazer sob estresse é se recusar a adivinhar.
Reflexão final
Nada disso é glamouroso. Não existe demo em que "nossas chaves de idempotência são persistidas antes da chamada de rede" ganhe aplausos. Mas confiabilidade não é uma funcionalidade que você adiciona depois. É o conjunto de decisões que você toma cedo e se recusa a comprometer, e então defende toda vez que um atalho parece tentador. Construa o sistema que diz a verdade quando tudo está pegando fogo, e as partes rápidas e bonitas se cuidam sozinhas.