Okxlivro - Post Mortem
От Nikita Bishonen • 5 минуты чтения •
Привет! Сегодня я делюсь своими мыслями о написании удобных Раст библиотек. Это первый пост из серии “Post Mortem”, где я буду препарировать разные проекты. Сегодня на нашем столе будет техническое задание, которое мне дали в одной из компаний после собеседования. Я не люблю тратить своё личное время, поэтому обычно отказываюсь от прохождения этого этапа, но здесь проект показался мне интересным и я его выполнил. А также получил отказ и обратную связь. Попробуем исправить то, что не понравилось проверяющему и заодно обсудить изменения.
Как и предыдущий, этот пост я начну с небольшой ликвидации безграмотности. Если вы уверены в своих знаниях специфики биржевой торговли, то смело прыгайте к сути.
Портфели, стаканы и заказы
Если вы не знакомы с торгами, то рекомендую перед дальнейшим прочтением изучить небольшое видео об этом или статью на Википедии. Далее я излагаю своё понимание темы, которoe может оказаться неточным или неполным.
Итак, в Торгах мы имеем:
- Портфель заказов
- Заказы на продажу (Ask side Order)
- Идентификатор торговой пары (что на что меняю), объём и цена
- Заказы на покупку (Bid side Order)
- Заказы на продажу (Ask side Order)
Получается такая книга или стакан где мы записываем все заказы и если они пересекаются (у нас есть желающий продать одно яйцо за килограм картошки и другой желающий купить одно яйцо за килограм картошки), то мы можем исполнить оба заказа.
![]()
Суть
Пробуем применять Структуру на Основе Видимости Модулей. Идеей и основной задачей является написание библиотеки для работы с 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 {...}. Это сделано из основанного на опыте опасения что:
- Биржа имеет нестабильный API, несмотря на версионирование, описание в документации и реальность различаются, особенно когда нету никаких формальных схем;
- Биржевое представление неоптимально, например там возвращается массив данных, но элемент всегда один, или сами ставки и предложения это массив из четырёх элементов один из которых:
"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.