ndaxrs 0.1.0

Rust client library for the NDAX cryptocurrency exchange API
Documentation
// Copyright (C) 2026 ndaxrs Art Morozov
// SPDX-License-Identifier: GPL-3.0-only

//! WebSocket data types for market data.

use std::{collections::BTreeMap, time::Instant};

use rust_decimal::Decimal;

/// A single entry in an order book at a specific price level.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct BookEntry {
  /// Price at this level.
  pub price:    Decimal,
  /// Quantity at this price level.
  pub quantity: Decimal,
}

impl BookEntry {
  /// Create a new book entry.
  pub fn new(price: Decimal, quantity: Decimal) -> Self {
    Self {
      price,
      quantity,
    }
  }
}

/// Order book data for an instrument.
///
/// Bids are stored in a BTreeMap and accessed in descending order (highest
/// price first). Asks are stored in a BTreeMap and accessed in ascending order
/// (lowest price first).
#[derive(Debug, Clone, Default)]
pub struct BookData {
  /// Bids keyed by price. Use `.iter().next_back()` for highest (best) bid.
  pub bids:        BTreeMap<Decimal, Decimal>,
  /// Asks keyed by price. Use `.iter().next()` for lowest (best) ask.
  pub asks:        BTreeMap<Decimal, Decimal>,
  /// Last update time.
  pub last_update: Option<Instant>,
}

impl BookData {
  /// Create a new empty order book.
  pub fn new() -> Self { Self::default() }

  /// Get the best bid (highest price) and its quantity.
  pub fn best_bid(&self) -> Option<(Decimal, Decimal)> {
    self.bids.iter().next_back().map(|(&p, &q)| (p, q))
  }

  /// Get the best ask (lowest price) and its quantity.
  pub fn best_ask(&self) -> Option<(Decimal, Decimal)> {
    self.asks.iter().next().map(|(&p, &q)| (p, q))
  }

  /// Get the spread (best_ask - best_bid).
  pub fn spread(&self) -> Option<Decimal> {
    match (self.best_bid(), self.best_ask()) {
      (Some((bid, _)), Some((ask, _))) => Some(ask - bid),
      _ => None,
    }
  }

  /// Update or remove a bid at the given price.
  ///
  /// If quantity is zero, the price level is removed.
  pub fn update_bid(&mut self, price: Decimal, qty: Decimal) {
    if qty.is_zero() {
      self.bids.remove(&price);
    } else {
      self.bids.insert(price, qty);
    }
    self.last_update = Some(Instant::now());
  }

  /// Update or remove an ask at the given price.
  ///
  /// If quantity is zero, the price level is removed.
  pub fn update_ask(&mut self, price: Decimal, qty: Decimal) {
    if qty.is_zero() {
      self.asks.remove(&price);
    } else {
      self.asks.insert(price, qty);
    }
    self.last_update = Some(Instant::now());
  }

  /// Clear all entries from the order book.
  pub fn clear(&mut self) {
    self.bids.clear();
    self.asks.clear();
    self.last_update = None;
  }

  /// Get the mid price.
  pub fn mid_price(&self) -> Option<Decimal> {
    match (self.best_bid(), self.best_ask()) {
      (Some((bid, _)), Some((ask, _))) => Some((bid + ask) / Decimal::from(2)),
      _ => None,
    }
  }
}

/// Level 1 ticker snapshot for an instrument.
#[derive(Debug, Clone, Default)]
pub struct Level1Snapshot {
  /// Instrument ID.
  pub instrument_id: u64,
  /// Best bid price.
  pub best_bid:      Decimal,
  /// Best ask price.
  pub best_ask:      Decimal,
  /// Best bid quantity.
  pub best_bid_qty:  Decimal,
  /// Best ask quantity.
  pub best_ask_qty:  Decimal,
  /// Last traded price.
  pub last_price:    Decimal,
  /// Last traded quantity.
  pub last_qty:      Decimal,
  /// 24-hour volume.
  pub volume:        Decimal,
  /// Timestamp (POSIX milliseconds).
  pub timestamp:     Option<i64>,
}

impl Level1Snapshot {
  /// Get the mid price.
  pub fn mid_price(&self) -> Decimal {
    (self.best_bid + self.best_ask) / Decimal::from(2)
  }

  /// Get the spread.
  pub fn spread(&self) -> Decimal { self.best_ask - self.best_bid }
}

/// Trade data from the WebSocket stream.
#[derive(Debug, Clone, Default)]
pub struct TradeData {
  /// Trade ID.
  pub trade_id:      u64,
  /// Instrument ID.
  pub instrument_id: u64,
  /// Trade price.
  pub price:         Decimal,
  /// Trade quantity.
  pub quantity:      Decimal,
  /// Order side (0=buy, 1=sell).
  pub side:          u8,
  /// Trade timestamp.
  pub timestamp:     i64,
}

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn test_book_data_bid_ordering() {
    let mut book = BookData::new();
    book.update_bid(Decimal::from(100), Decimal::from(1));
    book.update_bid(Decimal::from(102), Decimal::from(2));
    book.update_bid(Decimal::from(99), Decimal::from(3));

    // Best bid should be highest price (102)
    let (price, qty) = book.best_bid().unwrap();
    assert_eq!(price, Decimal::from(102));
    assert_eq!(qty, Decimal::from(2));

    // Verify descending order when iterating in reverse
    let prices: Vec<_> = book.bids.iter().rev().map(|(&p, _)| p).collect();
    assert_eq!(
      prices,
      vec![Decimal::from(102), Decimal::from(100), Decimal::from(99)]
    );
  }

  #[test]
  fn test_book_data_ask_ordering() {
    let mut book = BookData::new();
    book.update_ask(Decimal::from(103), Decimal::from(1));
    book.update_ask(Decimal::from(101), Decimal::from(2));
    book.update_ask(Decimal::from(105), Decimal::from(3));

    // Best ask should be lowest price (101)
    let (price, qty) = book.best_ask().unwrap();
    assert_eq!(price, Decimal::from(101));
    assert_eq!(qty, Decimal::from(2));

    // Verify ascending order
    let prices: Vec<_> = book.asks.iter().map(|(&p, _)| p).collect();
    assert_eq!(
      prices,
      vec![Decimal::from(101), Decimal::from(103), Decimal::from(105)]
    );
  }

  #[test]
  fn test_book_data_update_removes_zero_qty() {
    let mut book = BookData::new();
    book.update_bid(Decimal::from(100), Decimal::from(10));
    assert_eq!(book.bids.len(), 1);

    // Setting qty to zero should remove the entry
    book.update_bid(Decimal::from(100), Decimal::ZERO);
    assert!(book.bids.is_empty());

    book.update_ask(Decimal::from(101), Decimal::from(5));
    assert_eq!(book.asks.len(), 1);

    book.update_ask(Decimal::from(101), Decimal::ZERO);
    assert!(book.asks.is_empty());
  }

  #[test]
  fn test_book_data_best_bid_ask() {
    let mut book = BookData::new();

    // Empty book
    assert!(book.best_bid().is_none());
    assert!(book.best_ask().is_none());
    assert!(book.spread().is_none());

    book.update_bid(Decimal::from(100), Decimal::from(10));
    book.update_ask(Decimal::from(102), Decimal::from(5));

    let (bid_price, bid_qty) = book.best_bid().unwrap();
    assert_eq!(bid_price, Decimal::from(100));
    assert_eq!(bid_qty, Decimal::from(10));

    let (ask_price, ask_qty) = book.best_ask().unwrap();
    assert_eq!(ask_price, Decimal::from(102));
    assert_eq!(ask_qty, Decimal::from(5));

    assert_eq!(book.spread(), Some(Decimal::from(2)));
    assert_eq!(book.mid_price(), Some(Decimal::from(101)));
  }

  #[test]
  fn test_book_data_clear() {
    let mut book = BookData::new();
    book.update_bid(Decimal::from(100), Decimal::from(10));
    book.update_ask(Decimal::from(101), Decimal::from(5));
    assert!(!book.bids.is_empty());
    assert!(!book.asks.is_empty());

    book.clear();
    assert!(book.bids.is_empty());
    assert!(book.asks.is_empty());
    assert!(book.last_update.is_none());
  }

  #[test]
  fn test_book_entry() {
    let entry = BookEntry::new(Decimal::from(100), Decimal::from(10));
    assert_eq!(entry.price, Decimal::from(100));
    assert_eq!(entry.quantity, Decimal::from(10));
  }

  #[test]
  fn test_level1_snapshot() {
    let snapshot = Level1Snapshot {
      instrument_id: 1,
      best_bid:      Decimal::from(100),
      best_ask:      Decimal::from(102),
      best_bid_qty:  Decimal::from(10),
      best_ask_qty:  Decimal::from(5),
      last_price:    Decimal::from(101),
      last_qty:      Decimal::from(1),
      volume:        Decimal::from(1000),
      timestamp:     Some(1234567890),
    };

    assert_eq!(snapshot.mid_price(), Decimal::from(101));
    assert_eq!(snapshot.spread(), Decimal::from(2));
  }
}