Okxlivro - Post Mortem

Por Nikita Bishonen6 minutos de leitura



Olá! Hoje compartilho meus pensamentos sobre escrever bibliotecas Rust convenientes. Este é o primeiro post da série “Post Mortem”, onde farei uma análise técnica de diferentes projetos.

Hoje teremos no nosso crivo uma especificação técnica que me deram em uma empresa após uma entrevista. Não gosto de desperdiçar meu tempo pessoal, então geralmente recuso-me a passar por essa etapa, mas aqui o projeto parecia-me interessante e o completei. E também recebi uma recusa e feedback.

Vamos tentar corrigir o que desagradou ao revisor e, ao mesmo tempo, discutir as mudanças.

Como o anterior, este post começarei com uma pequena esclarecimento de conceitos. Se você tem certeza do seu conhecimento sobre a especificidade do comércio em bolsa, pule diretamente para a essência.

Portfólios, Order Books e Ordens

Se você não está familiarizado com o comércio, recomendo ler antes um pequeno vídeo sobre isso ou uma artigo na Wikipédia. A seguir, apresento minha compreensão do tema, que pode ser imprecisa ou incompleta.

Então, no Comércio temos:

Obtém-se um livro de ordens ou order book onde registramos todas as ordens e se elas se cruzam (temos alguém querendo vender um ovo por um quilo de batata e outro querendo comprar um ovo por um quilo de batata), podemos executar ambas as ordens.

Order Book

Essência

Tentamos aplicar a Estrutura Baseada na Visibilidade dos Módulos. A ideia e tarefa principal é escrever uma biblioteca para trabalhar com a exchange OKX, a API deve ser conveniente de usar e a implementação deve ser extensível e mantível.

Decidi tentar escrever código Rust idiomático, mas moderno - usando modelo assíncrono, tipagem estrita e ocultação de implementação.

Tudo aqui está bom, nenhum comentário foi identificado.

REST (em paz =)

Inicialmente fiz uma implementação bastante simples:

pub async fn orderbook_snapshot(instrument_id: &str) -> Result<Orderbook, RestError> {
    let response: serde_json::Value = Client::new()
        .get(format!("{MARKET_BOOKS_REST_URL}?instId={instrument_id}&sz=5"))
        .send()
        .await?
        .json()
        .await?;
    let asks = response["data"][0]["asks"]
        .as_array()
        .unwrap_or(&vec![])
        .iter()
        .map(Order::try_from)
        .try_collect()?;
    let bids = response["data"][0]["bids"]
        .as_array()
        .unwrap_or(&vec![])
        .iter()
        .map(Order::try_from)
        .try_collect()?;
    let mut orderbook = Orderbook::default();
    orderbook.apply_snapshot(SnapshotData((asks, bids)));
    Ok(orderbook)
}

E recebi um comentário - que a solução direta e o parsing de dados passa apenas por Value. Vamos reescrever isso para tipos estritos:

pub async fn orderbook_snapshot(instrument_id: &str, client: &Client) -> Result<Orderbook, RestError> {
    let response: inner::ApiResponse = client
        .get(format!("{MARKET_BOOKS_REST_URL}?instId={instrument_id}&sz=5"))
        .send()
        .await?
        .json()
        .await?;
    let [data] = response.data;
    let asks = data.asks.into_iter().map(Ask::from).collect();
    let bids = data.bids.into_iter().map(Bid::from).collect();
    let mut orderbook = Orderbook::default();
    orderbook.apply_snapshot(SnapshotData((asks, bids)));
    Ok(orderbook)
}

Os detalhes podem ser vistos no git, mas a própria implementação é quase padrão. A diferença é que não faço desserialização direta em tipos públicos da biblioteca, mas uso mod inner {...}. Isso foi feito a partir de uma experiência baseada em medo de que:

  1. A exchange tem uma API instável, apesar da versionamento, descrição na documentação e realidade diferem, especialmente quando não há nenhum esquema formal;
  2. A representação da exchange não é ótima, por exemplo, retorna um array de dados, mas o elemento é sempre um, ou os próprios lances e ofertas são um array de quatro elementos um dos quais: "0" é parte de um recurso descontinuado e sempre é "0".

Asks & Bids

Começarei com o simples, usamos todas as possibilidades da biblioteca padrão. Existe um requisito para listas de ordens, elas devem ser ordenadas do melhor para o pior.

Mas dependendo do lado da ordem (compramos/vendemos), precisa-se ordenar por preço de forma crescente (melhor comprar mais barato) ou decrescente (melhor vender mais caro).

Inicialmente implementei-as dessa forma, onde a diferença de preços era no uso do envoltório Reverse:

/// Mapa ordenado de Low to high price ([`Price`], [`Amount`]) de Asks.
pub struct Asks(BTreeMap<Price, Amount>);
// Mapa ordenado de High to low price ([`Price`], [`Amount`]) de Bids.
pub struct Bids(BTreeMap<Reverse<Price>, Amount>);

O comentário indicava que seria inconveniente expandir e manter devido à duplicação de lógica na ausência de diferenças (apenas Reverse) entre lances e ofertas.

Me parece que o revisor queria algo diferente, mas decidi ir “all in” e, às vezes, duplicar a lógica, mas separar ofertas e lances ainda mais:

pub struct AskPrice(Price);
pub struct BidPrice(Reverse<Price>);

Criamos preços diferentes, bem como estruturas para lances e ofertas únicos, que usamos no módulo realtime:

// lib.rs
pub struct Ask {
    pub price: AskPrice,
    pub amount: Amount,
}
pub struct Bid {
    pub price: BidPrice,
    pub amount: Amount,
}

// realtime.rs
pub struct SnapshotData(pub (Vec<Ask>, Vec<Bid>));
pub struct UpdateData(pub (Vec<Ask>, Vec<Bid>));

Paro o comentário do revisor sobre “duplicação de lógica” afirmando que a lógica comum precisa ser implementada no nível Price, e as diferenças de lógicas podem ser implementadas separadamente para Bid e Ask. E a presença de tipagem estrita no nível da API da biblioteca fornece uma boa experiência de usuário, pois não será possível confundir acidentalmente um lance com uma oferta.

Stream

Inicialmente usei a abordagem por canais de envio de mensagens, mas depois reescrevi para a opção mais ergonômica para usuários da biblioteca: alterações

pub fn orderbook_updates(instrument_id: &str) -> Pin<Box<impl Stream<Item = Result<RealtimeData, RealtimeError>>>> {
  ...
  Box::pin(stream! {
    ...
    while let Some(msg) = read.next().await {
      ...
      yield Ok(data);
    }
  )
}

como vemos, esta abordagem ainda requer macros adicionais e iteradores assíncronos não são algo nativo da biblioteca padrão, continuamos esperando e esperando.

O comentário recebido aqui refere-se ao problema de gerenciamento de conexões:

pub fn orderbook_updates(instrument_id: &str) -> Pin<Box<impl Stream<Item = Result<RealtimeData, RealtimeError>>>> {
    ..
    Box::pin(stream! {
        let (ws_stream, _) = tokio_tungstenite::connect_async(WS_URL).await?;
        let (mut write, mut read) = ws_stream.split();
        write
            .send(Message::Text(message_string.into()))
            .await?;
        while let Some(msg) = read.next().await {

Era necessário extrair esta lógica do método, o que é muito lógico, amo o padrão quando todos os “recursos” são passados como argumentos do método, nas formas mínimas necessárias. Isso certamente me fez sofrer um pouco com generics. Mas após dez minutos pulando pelo código tungstenite encontrei os tipos corretos para erros e levei a assinatura do método para a seguinte forma, simultaneamente dividindo um método em dois:

pub async fn subscribe<S>(channels_with_criterias: &[ChannelWithCriteria], write: &mut S) -> Result<(), RealtimeError>
...
pub fn transform_stream<T>(read: &mut T) -> Pin<Box<impl Stream<Item = Item>>>

Também alterei o tipo retornado para suportar extensão para todos os tipos de dados possíveis para diferentes assinaturas:

pub enum RealtimeData {
    Books(BooksChannelData),
}

Posso dizer que enumerações em Rust são convenientes e práticas, o único que me falta muito - é que os variantes de enumerações têm suporte “de primeira classe” no sistema de tipos da linguagem.

pub enum BooksChannelDelta {
    Snapshot(SnapshotData),
    Update(UpdateData),
}
pub struct SnapshotData(pub (Vec<Ask>, Vec<Bid>));
pub struct UpdateData(pub (Vec<Ask>, Vec<Bid>));

Por exemplo, a implementação de mensagens da exchange - por conexão WebSocket a nós pode ser entregue informação tanto sobre a atualização do portfólio quanto sobre um instantâneo do seu estado. Se não usarmos uma enumeração, então os próprios dados são idênticos e o usuário pode confundi-los. Na implementação não apenas dividi-os em variantes, mas também criei novos tipos envoltórios para maior segurança no uso.

Error

Adoro uma biblioteca simples, mas extremamente útil derive_more com macros procedurais para todos os casos de uso, por exemplo, para criar tipos de erros:

#[derive(Debug, Display, Error, From)]
pub enum CompoundError {
    Realtime { source: RealtimeError },
    Rest { source: RestError },
}

Aqui, graças às macros, a enumeração torna-se um erro Rust, pode ser impressa como string e convertida de erros de nível inferior - tudo isso sem código extra escrito manualmente. Ele existe - mas é gerado automaticamente.

Materiais Adicionais

Se você estiver interessado em “jogar” com o exemplo deste artigo, aqui está o código completo do projeto. Também ficarei grato por feedback, por favor escreva suas sugestões de melhoria do projeto nas issues do gitlab.

(End of file - total 228 lines)