use rust_decimal::prelude::ToPrimitive;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use wickra_core::{CrossSection, DerivativesTick, Level, Member, OrderBook, Side, Trade};
use crate::error::{Error, Result};
use crate::events::{OrderBookSnapshot, TradePrint};
use crate::symbol::Symbol;
use crate::types::OrderSide;
fn to_f64(value: Decimal) -> f64 {
value.to_f64().unwrap_or(f64::NAN)
}
fn core_side(side: OrderSide) -> Side {
match side {
OrderSide::Buy => Side::Buy,
OrderSide::Sell => Side::Sell,
}
}
pub fn trade_from_print(print: &TradePrint) -> Result<Trade> {
Trade::new(
to_f64(print.price),
to_f64(print.quantity),
core_side(print.aggressor),
print.timestamp,
)
.map_err(|e| Error::Deserialization(e.to_string()))
}
pub fn order_book_from_snapshot(snapshot: &OrderBookSnapshot) -> Result<OrderBook> {
let to_levels = |levels: &[crate::events::BookLevel]| -> Result<Vec<Level>> {
levels
.iter()
.map(|level| {
Level::new(to_f64(level.price), to_f64(level.quantity))
.map_err(|e| Error::Deserialization(e.to_string()))
})
.collect()
};
OrderBook::new(to_levels(&snapshot.bids)?, to_levels(&snapshot.asks)?)
.map_err(|e| Error::Deserialization(e.to_string()))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BreadthMember {
pub change: Decimal,
pub volume: Decimal,
pub new_high: bool,
pub new_low: bool,
}
pub fn cross_section(members: &[BreadthMember], timestamp: i64) -> Result<CrossSection> {
let members = members
.iter()
.map(|member| {
Member::new(
to_f64(member.change),
to_f64(member.volume),
member.new_high,
member.new_low,
)
})
.collect();
CrossSection::new(members, timestamp).map_err(|e| Error::Deserialization(e.to_string()))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FundingRate {
pub symbol: Symbol,
pub rate: Decimal,
pub mark_price: Decimal,
pub timestamp: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OpenInterest {
pub symbol: Symbol,
pub open_interest: Decimal,
pub timestamp: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Liquidation {
pub symbol: Symbol,
pub side: OrderSide,
pub price: Decimal,
pub quantity: Decimal,
pub timestamp: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LongShortRatio {
pub symbol: Symbol,
pub long_size: Decimal,
pub short_size: Decimal,
pub timestamp: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MarkIndex {
pub symbol: Symbol,
pub mark_price: Decimal,
pub index_price: Decimal,
pub timestamp: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DerivativesChannel {
Funding,
MarkIndex,
Liquidations,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DerivativesFeed {
Funding(FundingRate),
OpenInterest(OpenInterest),
Liquidation(Liquidation),
LongShortRatio(LongShortRatio),
MarkIndex(MarkIndex),
}
#[derive(Debug, Clone, Default)]
pub struct DerivativesTickBuilder {
funding_rate: f64,
mark_price: f64,
index_price: f64,
futures_price: f64,
open_interest: f64,
long_size: f64,
short_size: f64,
long_liquidation: f64,
short_liquidation: f64,
timestamp: i64,
}
impl DerivativesTickBuilder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn apply(&mut self, feed: &DerivativesFeed) {
match feed {
DerivativesFeed::Funding(funding) => {
self.funding_rate = to_f64(funding.rate);
self.mark_price = to_f64(funding.mark_price);
self.timestamp = funding.timestamp;
}
DerivativesFeed::OpenInterest(oi) => {
self.open_interest = to_f64(oi.open_interest);
self.timestamp = oi.timestamp;
}
DerivativesFeed::Liquidation(liq) => {
let notional = to_f64(liq.price * liq.quantity);
match liq.side {
OrderSide::Sell => self.long_liquidation += notional,
OrderSide::Buy => self.short_liquidation += notional,
}
self.timestamp = liq.timestamp;
}
DerivativesFeed::LongShortRatio(ratio) => {
self.long_size = to_f64(ratio.long_size);
self.short_size = to_f64(ratio.short_size);
self.timestamp = ratio.timestamp;
}
DerivativesFeed::MarkIndex(mark) => {
self.mark_price = to_f64(mark.mark_price);
self.index_price = to_f64(mark.index_price);
self.futures_price = to_f64(mark.mark_price);
self.timestamp = mark.timestamp;
}
}
}
pub fn reset_flows(&mut self) {
self.long_liquidation = 0.0;
self.short_liquidation = 0.0;
}
pub fn build(&self) -> Result<DerivativesTick> {
DerivativesTick::new(
self.funding_rate,
self.mark_price,
self.index_price,
self.futures_price,
self.open_interest,
self.long_size,
self.short_size,
0.0,
0.0,
self.long_liquidation,
self.short_liquidation,
self.timestamp,
)
.map_err(|e| Error::Deserialization(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::events::BookLevel;
use rust_decimal_macros::dec;
fn sym() -> Symbol {
Symbol::new("BTC", "USDT")
}
#[test]
fn trade_print_converts_to_core_trade() {
let print = TradePrint {
symbol: sym(),
price: dec!(20000.5),
quantity: dec!(0.25),
aggressor: OrderSide::Sell,
timestamp: 1,
};
let trade = trade_from_print(&print).unwrap();
assert!((trade.price - 20000.5).abs() < 1e-9);
assert!((trade.size - 0.25).abs() < 1e-12);
assert_eq!(trade.side, Side::Sell);
assert_eq!(trade.timestamp, 1);
}
#[test]
fn snapshot_converts_to_core_order_book() {
let snapshot = OrderBookSnapshot {
symbol: sym(),
last_update_id: 7,
bids: vec![
BookLevel::new(dec!(100), dec!(2)),
BookLevel::new(dec!(99), dec!(3)),
],
asks: vec![
BookLevel::new(dec!(101), dec!(1)),
BookLevel::new(dec!(102), dec!(4)),
],
timestamp: 0,
};
let book = order_book_from_snapshot(&snapshot).unwrap();
assert!((book.best_bid().unwrap().price - 100.0).abs() < 1e-9);
assert!((book.best_ask().unwrap().price - 101.0).abs() < 1e-9);
assert_eq!(book.mid(), Some(100.5));
}
#[test]
fn crossed_snapshot_is_rejected() {
let snapshot = OrderBookSnapshot {
symbol: sym(),
last_update_id: 0,
bids: vec![BookLevel::new(dec!(102), dec!(1))],
asks: vec![BookLevel::new(dec!(101), dec!(1))],
timestamp: 0,
};
assert!(matches!(
order_book_from_snapshot(&snapshot).unwrap_err(),
Error::Deserialization(_)
));
}
#[test]
fn breadth_universe_converts_to_cross_section() {
let members = [
BreadthMember {
change: dec!(1.5),
volume: dec!(1000),
new_high: true,
new_low: false,
},
BreadthMember {
change: dec!(-0.5),
volume: dec!(500),
new_high: false,
new_low: true,
},
];
let section = cross_section(&members, 42).unwrap();
assert_eq!(section.advancers(), 1);
assert_eq!(section.decliners(), 1);
assert_eq!(section.timestamp, 42);
}
#[test]
fn empty_universe_is_rejected() {
assert!(matches!(
cross_section(&[], 0).unwrap_err(),
Error::Deserialization(_)
));
}
#[test]
fn derivatives_channels_fold_into_a_tick() {
let mut builder = DerivativesTickBuilder::new();
builder.apply(&DerivativesFeed::MarkIndex(MarkIndex {
symbol: sym(),
mark_price: dec!(20000),
index_price: dec!(19995),
timestamp: 10,
}));
builder.apply(&DerivativesFeed::Funding(FundingRate {
symbol: sym(),
rate: dec!(-0.0001),
mark_price: dec!(20010),
timestamp: 11,
}));
builder.apply(&DerivativesFeed::OpenInterest(OpenInterest {
symbol: sym(),
open_interest: dec!(1234.5),
timestamp: 12,
}));
builder.apply(&DerivativesFeed::LongShortRatio(LongShortRatio {
symbol: sym(),
long_size: dec!(60),
short_size: dec!(40),
timestamp: 13,
}));
builder.apply(&DerivativesFeed::Liquidation(Liquidation {
symbol: sym(),
side: OrderSide::Sell,
price: dec!(20000),
quantity: dec!(0.5),
timestamp: 14,
}));
let tick = builder.build().unwrap();
assert!((tick.funding_rate + 0.0001).abs() < 1e-12);
assert!((tick.mark_price - 20010.0).abs() < 1e-9);
assert!((tick.index_price - 19995.0).abs() < 1e-9);
assert!((tick.open_interest - 1234.5).abs() < 1e-9);
assert!((tick.long_size - 60.0).abs() < 1e-9);
assert!((tick.long_liquidation - 10000.0).abs() < 1e-9);
assert!(tick.short_liquidation.abs() < 1e-9);
assert_eq!(tick.timestamp, 14);
}
#[test]
fn short_liquidation_accumulates_and_resets() {
let mut builder = DerivativesTickBuilder::new();
builder.apply(&DerivativesFeed::MarkIndex(MarkIndex {
symbol: sym(),
mark_price: dec!(100),
index_price: dec!(100),
timestamp: 1,
}));
for _ in 0..2 {
builder.apply(&DerivativesFeed::Liquidation(Liquidation {
symbol: sym(),
side: OrderSide::Buy,
price: dec!(100),
quantity: dec!(1),
timestamp: 2,
}));
}
assert!((builder.build().unwrap().short_liquidation - 200.0).abs() < 1e-9);
builder.reset_flows();
assert!(builder.build().unwrap().short_liquidation.abs() < 1e-9);
}
#[test]
fn build_before_a_price_channel_is_rejected() {
assert!(matches!(
DerivativesTickBuilder::new().build().unwrap_err(),
Error::Deserialization(_)
));
}
}