Okxlivro - Post Mortem
By Nikita Bishonen ⢠6 minutes read ā¢
Hello! Today Iām sharing my thoughts on writing convenient Rust libraries. This is the first post in the āPost Mortemā series, where Iāll be dissecting different projects.
Today on our table is a technical assignment I was given at one company after an interview. I donāt like wasting my personal time, so I usually refuse to go through this stage, but this project seemed interesting to me, so I completed it. I also received a rejection and feedback. Letās try to fix what didnāt please the reviewer and discuss the changes along the way.
As with the previous one, Iāll start this post with a brief clarification of terminology. If youāre confident in your knowledge of exchange trading specifics, feel free to skip to the core.
Portfolios, order books, and orders
If youāre not familiar with trading, I recommend studying this short video or this Wikipedia article before continuing reading. Below Iām presenting my understanding of the topic, which may be inaccurate or incomplete.
So, in Trading we have:
- Portfolio of orders
- Sell orders (Ask side Order)
- Trading pair identifier (what weāre exchanging for what), volume, and price
- Buy orders (Bid side Order)
- Sell orders (Ask side Order)
This results in an order book or book where we record all orders, and if they intersect (we have someone willing to sell one egg for a kilogram of potatoes, and another willing to buy one egg for a kilogram of potatoes), we can execute both orders.
![]()
Core
Weāre trying to apply the Module Visibility-Based Structure. The idea and main goal is to write a library for working with the OKX exchange, the API should be convenient to use, and the implementation should be extensible and maintainable.
I decided itās worth trying to write idiomatic but modern Rust code - using an asynchronous model, strict typing, and implementation hiding.
Here everything is fine, no issues were identified.
REST (in peace =)
Initially I made a fairly simple implementation:
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)
}And received a remark - that this is a brute force solution and data parsing goes simply through Value. Letās rewrite this with strict types:
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)
}Details can be viewed in the git repository, but the implementation itself is almost standard.
The difference is that I donāt do direct deserialization into public library types, but use mod inner {...}. This is done out of experience-based concerns that:
- The exchange has an unstable API, despite versioning, documentation description differs from reality, especially when there are no formal schemas;
- The exchange representation is not optimal, for example, an array of data is returned, but the element is always one, or the actual bids and asks are an array of four elements, one of which:
"0" is part of a deprecated feature and it is always "0".
Asks & Bids
Letās start with the simple - we use all standard library capabilities. Thereās a requirement for order lists, they should be sorted from best to worst.
But depending on the order side (buying/selling), we need to sort either by ascending (better to buy cheaper) or descending (better to sell more expensive) price.
Initially I implemented them such that the price difference was in using the Reverse wrapper:
/// 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>);The remark indicated that extending and maintaining would be inconvenient due to logic duplication when thereās no difference (only Reverse) between bids and asks.
I think the reviewer wanted something else, but I decided to go all in and sometimes duplicate logic, but further separate bids and asks:
pub struct AskPrice(Price);
pub struct BidPrice(Reverse<Price>);Letās make different prices, as well as structures for single bids and asks that we use in the realtime module:
// 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>));Iām countering the reviewerās remark about ālogic duplicationā by saying that general logic needs to be implemented at the Price level, and logic differences can be implemented separately for Bid and Ask. And the presence of strict typing at the library API level provides good user experience, since it wonāt be possible to accidentally confuse bids and asks.
Stream
Initially I used the approach through message sending channels, but then rewrote it to a version more ergonomic for library clients to use: changes
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);
}
)
}As we can see, this approach still requires additional macros, and async iterators are not native to the standard library. We continue to hope and wait.
The remark received here refers to the problem of connection management:
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 {It was necessary to extract this logic from the method, which is very logical. I love the pattern where all āresourcesā are passed as method arguments in minimal necessary forms. This of course forced me to struggle a bit with generics. But after ten minutes of jumping through the tungstenite code, I found the correct error types and brought the method signature to the following form, while splitting one method into two:
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>>>I also changed the return type to support extending to all possible data types for different subscriptions:
pub enum RealtimeData {
Books(BooksChannelData),
}I can say that I find Rust enums convenient and practical. The only thing Iām really missing is that enum variants donāt have āfirst-classā support in the languageās type system.
pub enum BooksChannelDelta {
Snapshot(SnapshotData),
Update(UpdateData),
}
pub struct SnapshotData(pub (Vec<Ask>, Vec<Bid>));
pub struct UpdateData(pub (Vec<Ask>, Vec<Bid>));For example, implementation of messages from the exchange - through a WebSocket connection to us can be delivered information both about portfolio updates and its snapshot state.
If we donāt use an enum, then the data itself are identical and the user can confuse them. In the implementation, I not only split them into variants, but also created new wrapper types for greater safety when using.
Error
I love the simple but incredibly useful derive_more library with procedural macros for all occasions, for example for creating error types:
#[derive(Debug, Display, Error, From)]
pub enum CompoundError {
Realtime { source: RealtimeError },
Rest { source: RestError },
}Here, thanks to the macros, the enum becomes a Rust error, can be displayed as a string and converted from low-level errors - all this without extra hand-written code. It exists - but is generated automatically.
Additional Materials
If youāre interested in āplaying aroundā with the example from this article, hereās the full project code.
Iāll also be grateful for feedback, please write your suggestions for improving the project in issues on GitLab.