1use rust_decimal::prelude::ToPrimitive;
32use rust_decimal::Decimal;
33use serde::{Deserialize, Serialize};
34use wickra_core::{CrossSection, DerivativesTick, Level, Member, OrderBook, Side, Trade};
35
36use crate::error::{Error, Result};
37use crate::events::{OrderBookSnapshot, TradePrint};
38use crate::symbol::Symbol;
39use crate::types::OrderSide;
40
41fn to_f64(value: Decimal) -> f64 {
46 value.to_f64().unwrap_or(f64::NAN)
47}
48
49fn core_side(side: OrderSide) -> Side {
51 match side {
52 OrderSide::Buy => Side::Buy,
53 OrderSide::Sell => Side::Sell,
54 }
55}
56
57pub fn trade_from_print(print: &TradePrint) -> Result<Trade> {
65 Trade::new(
66 to_f64(print.price),
67 to_f64(print.quantity),
68 core_side(print.aggressor),
69 print.timestamp,
70 )
71 .map_err(|e| Error::Deserialization(e.to_string()))
72}
73
74pub fn order_book_from_snapshot(snapshot: &OrderBookSnapshot) -> Result<OrderBook> {
82 let to_levels = |levels: &[crate::events::BookLevel]| -> Result<Vec<Level>> {
83 levels
84 .iter()
85 .map(|level| {
86 Level::new(to_f64(level.price), to_f64(level.quantity))
87 .map_err(|e| Error::Deserialization(e.to_string()))
88 })
89 .collect()
90 };
91 OrderBook::new(to_levels(&snapshot.bids)?, to_levels(&snapshot.asks)?)
92 .map_err(|e| Error::Deserialization(e.to_string()))
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct BreadthMember {
98 pub change: Decimal,
100 pub volume: Decimal,
102 pub new_high: bool,
104 pub new_low: bool,
106}
107
108pub fn cross_section(members: &[BreadthMember], timestamp: i64) -> Result<CrossSection> {
116 let members = members
117 .iter()
118 .map(|member| {
119 Member::new(
120 to_f64(member.change),
121 to_f64(member.volume),
122 member.new_high,
123 member.new_low,
124 )
125 })
126 .collect();
127 CrossSection::new(members, timestamp).map_err(|e| Error::Deserialization(e.to_string()))
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct FundingRate {
133 pub symbol: Symbol,
135 pub rate: Decimal,
137 pub mark_price: Decimal,
139 pub timestamp: i64,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct OpenInterest {
146 pub symbol: Symbol,
148 pub open_interest: Decimal,
150 pub timestamp: i64,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct Liquidation {
157 pub symbol: Symbol,
159 pub side: OrderSide,
163 pub price: Decimal,
165 pub quantity: Decimal,
167 pub timestamp: i64,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173pub struct LongShortRatio {
174 pub symbol: Symbol,
176 pub long_size: Decimal,
178 pub short_size: Decimal,
180 pub timestamp: i64,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186pub struct MarkIndex {
187 pub symbol: Symbol,
189 pub mark_price: Decimal,
191 pub index_price: Decimal,
193 pub timestamp: i64,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
208pub enum DerivativesChannel {
209 Funding,
211 MarkIndex,
213 Liquidations,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219pub enum DerivativesFeed {
220 Funding(FundingRate),
222 OpenInterest(OpenInterest),
224 Liquidation(Liquidation),
226 LongShortRatio(LongShortRatio),
228 MarkIndex(MarkIndex),
230}
231
232#[derive(Debug, Clone, Default)]
241pub struct DerivativesTickBuilder {
242 funding_rate: f64,
243 mark_price: f64,
244 index_price: f64,
245 futures_price: f64,
246 open_interest: f64,
247 long_size: f64,
248 short_size: f64,
249 long_liquidation: f64,
250 short_liquidation: f64,
251 timestamp: i64,
252}
253
254impl DerivativesTickBuilder {
255 #[must_use]
257 pub fn new() -> Self {
258 Self::default()
259 }
260
261 pub fn apply(&mut self, feed: &DerivativesFeed) {
263 match feed {
264 DerivativesFeed::Funding(funding) => {
265 self.funding_rate = to_f64(funding.rate);
266 self.mark_price = to_f64(funding.mark_price);
267 self.timestamp = funding.timestamp;
268 }
269 DerivativesFeed::OpenInterest(oi) => {
270 self.open_interest = to_f64(oi.open_interest);
271 self.timestamp = oi.timestamp;
272 }
273 DerivativesFeed::Liquidation(liq) => {
274 let notional = to_f64(liq.price * liq.quantity);
275 match liq.side {
276 OrderSide::Sell => self.long_liquidation += notional,
277 OrderSide::Buy => self.short_liquidation += notional,
278 }
279 self.timestamp = liq.timestamp;
280 }
281 DerivativesFeed::LongShortRatio(ratio) => {
282 self.long_size = to_f64(ratio.long_size);
283 self.short_size = to_f64(ratio.short_size);
284 self.timestamp = ratio.timestamp;
285 }
286 DerivativesFeed::MarkIndex(mark) => {
287 self.mark_price = to_f64(mark.mark_price);
288 self.index_price = to_f64(mark.index_price);
289 self.futures_price = to_f64(mark.mark_price);
291 self.timestamp = mark.timestamp;
292 }
293 }
294 }
295
296 pub fn reset_flows(&mut self) {
298 self.long_liquidation = 0.0;
299 self.short_liquidation = 0.0;
300 }
301
302 pub fn build(&self) -> Result<DerivativesTick> {
310 DerivativesTick::new(
311 self.funding_rate,
312 self.mark_price,
313 self.index_price,
314 self.futures_price,
315 self.open_interest,
316 self.long_size,
317 self.short_size,
318 0.0,
321 0.0,
322 self.long_liquidation,
323 self.short_liquidation,
324 self.timestamp,
325 )
326 .map_err(|e| Error::Deserialization(e.to_string()))
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use crate::events::BookLevel;
334 use rust_decimal_macros::dec;
335
336 fn sym() -> Symbol {
337 Symbol::new("BTC", "USDT")
338 }
339
340 #[test]
341 fn trade_print_converts_to_core_trade() {
342 let print = TradePrint {
343 symbol: sym(),
344 price: dec!(20000.5),
345 quantity: dec!(0.25),
346 aggressor: OrderSide::Sell,
347 timestamp: 1,
348 };
349 let trade = trade_from_print(&print).unwrap();
350 assert!((trade.price - 20000.5).abs() < 1e-9);
351 assert!((trade.size - 0.25).abs() < 1e-12);
352 assert_eq!(trade.side, Side::Sell);
353 assert_eq!(trade.timestamp, 1);
354 }
355
356 #[test]
357 fn snapshot_converts_to_core_order_book() {
358 let snapshot = OrderBookSnapshot {
359 symbol: sym(),
360 last_update_id: 7,
361 bids: vec![
362 BookLevel::new(dec!(100), dec!(2)),
363 BookLevel::new(dec!(99), dec!(3)),
364 ],
365 asks: vec![
366 BookLevel::new(dec!(101), dec!(1)),
367 BookLevel::new(dec!(102), dec!(4)),
368 ],
369 timestamp: 0,
370 };
371 let book = order_book_from_snapshot(&snapshot).unwrap();
372 assert!((book.best_bid().unwrap().price - 100.0).abs() < 1e-9);
373 assert!((book.best_ask().unwrap().price - 101.0).abs() < 1e-9);
374 assert_eq!(book.mid(), Some(100.5));
375 }
376
377 #[test]
378 fn crossed_snapshot_is_rejected() {
379 let snapshot = OrderBookSnapshot {
380 symbol: sym(),
381 last_update_id: 0,
382 bids: vec![BookLevel::new(dec!(102), dec!(1))],
383 asks: vec![BookLevel::new(dec!(101), dec!(1))],
384 timestamp: 0,
385 };
386 assert!(matches!(
387 order_book_from_snapshot(&snapshot).unwrap_err(),
388 Error::Deserialization(_)
389 ));
390 }
391
392 #[test]
393 fn breadth_universe_converts_to_cross_section() {
394 let members = [
395 BreadthMember {
396 change: dec!(1.5),
397 volume: dec!(1000),
398 new_high: true,
399 new_low: false,
400 },
401 BreadthMember {
402 change: dec!(-0.5),
403 volume: dec!(500),
404 new_high: false,
405 new_low: true,
406 },
407 ];
408 let section = cross_section(&members, 42).unwrap();
409 assert_eq!(section.advancers(), 1);
410 assert_eq!(section.decliners(), 1);
411 assert_eq!(section.timestamp, 42);
412 }
413
414 #[test]
415 fn empty_universe_is_rejected() {
416 assert!(matches!(
417 cross_section(&[], 0).unwrap_err(),
418 Error::Deserialization(_)
419 ));
420 }
421
422 #[test]
423 fn derivatives_channels_fold_into_a_tick() {
424 let mut builder = DerivativesTickBuilder::new();
425 builder.apply(&DerivativesFeed::MarkIndex(MarkIndex {
426 symbol: sym(),
427 mark_price: dec!(20000),
428 index_price: dec!(19995),
429 timestamp: 10,
430 }));
431 builder.apply(&DerivativesFeed::Funding(FundingRate {
432 symbol: sym(),
433 rate: dec!(-0.0001),
434 mark_price: dec!(20010),
435 timestamp: 11,
436 }));
437 builder.apply(&DerivativesFeed::OpenInterest(OpenInterest {
438 symbol: sym(),
439 open_interest: dec!(1234.5),
440 timestamp: 12,
441 }));
442 builder.apply(&DerivativesFeed::LongShortRatio(LongShortRatio {
443 symbol: sym(),
444 long_size: dec!(60),
445 short_size: dec!(40),
446 timestamp: 13,
447 }));
448 builder.apply(&DerivativesFeed::Liquidation(Liquidation {
450 symbol: sym(),
451 side: OrderSide::Sell,
452 price: dec!(20000),
453 quantity: dec!(0.5),
454 timestamp: 14,
455 }));
456
457 let tick = builder.build().unwrap();
458 assert!((tick.funding_rate + 0.0001).abs() < 1e-12);
459 assert!((tick.mark_price - 20010.0).abs() < 1e-9);
460 assert!((tick.index_price - 19995.0).abs() < 1e-9);
461 assert!((tick.open_interest - 1234.5).abs() < 1e-9);
462 assert!((tick.long_size - 60.0).abs() < 1e-9);
463 assert!((tick.long_liquidation - 10000.0).abs() < 1e-9);
464 assert!(tick.short_liquidation.abs() < 1e-9);
465 assert_eq!(tick.timestamp, 14);
466 }
467
468 #[test]
469 fn short_liquidation_accumulates_and_resets() {
470 let mut builder = DerivativesTickBuilder::new();
471 builder.apply(&DerivativesFeed::MarkIndex(MarkIndex {
472 symbol: sym(),
473 mark_price: dec!(100),
474 index_price: dec!(100),
475 timestamp: 1,
476 }));
477 for _ in 0..2 {
479 builder.apply(&DerivativesFeed::Liquidation(Liquidation {
480 symbol: sym(),
481 side: OrderSide::Buy,
482 price: dec!(100),
483 quantity: dec!(1),
484 timestamp: 2,
485 }));
486 }
487 assert!((builder.build().unwrap().short_liquidation - 200.0).abs() < 1e-9);
488 builder.reset_flows();
489 assert!(builder.build().unwrap().short_liquidation.abs() < 1e-9);
490 }
491
492 #[test]
493 fn build_before_a_price_channel_is_rejected() {
494 assert!(matches!(
496 DerivativesTickBuilder::new().build().unwrap_err(),
497 Error::Deserialization(_)
498 ));
499 }
500}