Okxlivro - Post Mortem

От Nikita Bishonen5 минуты чтения



Привет! Сегодня я делюсь своими мыслями о написании удобных Раст библиотек. Это первый пост из серии “Post Mortem”, где я буду препарировать разные проекты. Сегодня на нашем столе будет техническое задание, которое мне дали в одной из компаний после собеседования. Я не люблю тратить своё личное время, поэтому обычно отказываюсь от прохождения этого этапа, но здесь проект показался мне интересным и я его выполнил. А также получил отказ и обратную связь. Попробуем исправить то, что не понравилось проверяющему и заодно обсудить изменения.

Как и предыдущий, этот пост я начну с небольшой ликвидации безграмотности. Если вы уверены в своих знаниях специфики биржевой торговли, то смело прыгайте к сути.

Портфели, стаканы и заказы

Если вы не знакомы с торгами, то рекомендую перед дальнейшим прочтением изучить небольшое видео об этом или статью на Википедии. Далее я излагаю своё понимание темы, которoe может оказаться неточным или неполным.

Итак, в Торгах мы имеем:

Получается такая книга или стакан где мы записываем все заказы и если они пересекаются (у нас есть желающий продать одно яйцо за килограм картошки и другой желающий купить одно яйцо за килограм картошки), то мы можем исполнить оба заказа.

Стакан

Суть

Пробуем применять Структуру на Основе Видимости Модулей. Идеей и основной задачей является написание библиотеки для работы с OKX биржей, API должен быть удобен в использовании, а реализация в расширении и поддержкe.

Я решил что стоит пробовать писать идеоматичный, но современный Раст код - использующий асинхронную модель, строгую типизацию и сокрытие реализации.

Здесь всё хорошо, никаких замечаний выявлено не было.

REST (in peace =)

Изначально я сделал довольно простую реализацию:

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)
}

И получил замечание - что решение в лоб и парсинг данных идёт просто через Value. Перепишем это на строгие типы:

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)
}

Детали можно посмотреть в git, но сама реализация почти стандартная. Отличие в том, что я не делаю прямую десериализацию в публичные типы библиотеки, а использую mod inner {...}. Это сделано из основанного на опыте опасения что:

  1. Биржа имеет нестабильный API, несмотря на версионирование, описание в документации и реальность различаются, особенно когда нету никаких формальных схем;
  2. Биржевое представление неоптимально, например там возвращается массив данных, но элемент всегда один, или сами ставки и предложения это массив из четырёх элементов один из которых: "0" is part of a deprecated feature and it is always "0".

Asks & Bids

Начну с простого, используем все возможности стандартной библиотеки. Есть требование к спискам заказов, они должны быть отсортированы от лучшего к худшему. Но в зависимости от стороны заказа (покупаем\продаем), сортировать нужно либо по возрастанию (лучше купить дешевле), либо по убыванию (лучше продать дороже) цены.

Изначально я реализовал их таким образом, что различие цен было в использовании обёртки Reverse:

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

Замечание указывало на то, что расширять и поддерживать будет неудобно из-за дублирования логики при отсутствии различий (только Reverse) между ставками и предложениями. Мне кажется проверяющий хотел бы чего-то другого, но я решил пойти all in и иногда дублировать логику, но разграничить предложения и ставки ещё больше:

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

Делаем разные цены, а также структуры для единичных ставок и предложений, которые используем в модуле 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>));

Парирую же замечание проверяющая о “дублировании логики” тем, что общую логику нужно реализовывать на уровне Price, а различия логик можно реализовывать для Bid и Ask раздельно. А наличие строгой типизиации на уровне API библиотеки даёт хороший пользовательский опыт, поскольку не получится случайно спутать местами ставку и предложение.

Stream

Изначально я использовал подход через каналы отправки сообщений, но затем переписал на вариант, более эргономичный для использования клиентами библиотеки: изменения

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);
    }
  )
}

как мы видим, данный подход ещё требует дополнительных макросов и асинхронные итераторы не являются чем-то родным стандартной библиотеке, продолжаем надееться и ждать. Замечание полученное здесь относится к проблеме управления соединениями:

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 {

Необходимо было выносить эту логику из метода, что очень логично, я люблю паттерн, когда все “ресурсы” передаются как аргументы метода, в минимально необходимых формах. Это конечно заставило меня немного помучаться с обобщениями. Но спустя десять минут прыганий по коду tungstenite я нашёл верные типы для ошибок и привёл сигнатуру метода к следующему виду, параллельно разбив один метод на два:

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>>>

Также я изменил возвращаемый тип, чтобы поддержать расширение на все возможные типы данных для разных подписок:

pub enum RealtimeData {
    Books(BooksChannelData),
}

Могу сказать что перечисления в Раст я нахожу удобными и практичными, единственное чего мне сильно не хватает - чтобы варианты перечислений имели “перво классную” поддержку в системе типов языка.

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

Например реализация сообщений от биржи - по WebSocket соединению к нам может быть доставлена информация как об обновлении портфеля, так и слепок его состояния. Если не использовать перечисление, то сами данные идентичны и пользователь может перепутать их. В реализации я не только разбил их на варианты, но и создал новые типы-обёртки для большей безопасности при использовании.

Error

Обожаю простую, но безумно полезную библиотеку derive_more с процедурными макросами вывода на все случаи жизни, например для создания типов ошибок:

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

Здесь, благодаря макросам, перечисление становится Раст ошибкой, может быть выведено в виде строки и сконвертировано из низкоуровневых ошибок - всё это без лишнего кода написанного от руки. Он есть - но генерируется автоматически.

Дополнительные материалы

Если вам интересно “поиграть” с примером из данной статьи, то вот полный код проекта. Также буду благодарен за обратную связь, пожалуйста пишите своим предложения по улучшению проекта в issues на gitlab.