lightcone 0.5.2

Rust SDK for the Lightcone Protocol — unified native + WASM client
Documentation
//! Trade state containers — app-owned, SDK-provided update logic.

use super::Trade;
use crate::shared::OrderBookId;
use std::collections::VecDeque;

/// Rolling trade history buffer for an orderbook.
///
/// The app owns instances of this type. The SDK provides update methods.
#[derive(Debug, Clone)]
pub struct TradeHistory {
    pub orderbook_id: OrderBookId,
    trades: VecDeque<Trade>,
    max_size: usize,
}

impl TradeHistory {
    pub fn new(orderbook_id: OrderBookId, max_size: usize) -> Self {
        Self {
            orderbook_id,
            trades: VecDeque::with_capacity(max_size),
            max_size,
        }
    }

    /// Push a new trade, inserting in descending sequence order.
    ///
    /// Trades with `sequence > 0` are inserted at the correct position so the
    /// buffer stays sorted by sequence (newest first). Trades with `sequence == 0`
    /// (e.g. from REST) are prepended as before.
    pub fn push(&mut self, trade: Trade) {
        // Treat a zero-capacity history as disabled.
        if self.max_size == 0 {
            return;
        }
        if trade.sequence == 0 {
            // REST trades do not carry ordering metadata, so preserve the legacy
            // "latest first" behavior and let normal capacity eviction apply.
            self.trades.push_front(trade);
            if self.trades.len() > self.max_size {
                self.trades.pop_back();
            }
            return;
        }
        // Find the first retained trade that is older than the incoming
        // sequence. Inserting there keeps the buffer sorted newest-first.
        let position = self
            .trades
            .iter()
            .position(|existing| existing.sequence < trade.sequence)
            .unwrap_or(self.trades.len());
        if self.trades.len() >= self.max_size && position == self.trades.len() {
            // The buffer is full and the new trade is older than everything we
            // already keep, so dropping it preserves the newest retained window.
            return;
        }
        self.trades.insert(position, trade);
        if self.trades.len() > self.max_size {
            // The new trade landed inside the retained window, so evict the
            // oldest trade at the tail to restore capacity.
            self.trades.pop_back();
        }
    }

    /// Replace all trades (e.g. from a REST fetch).
    pub fn replace(&mut self, trades: Vec<Trade>) {
        self.trades.clear();
        for trade in trades.into_iter().take(self.max_size) {
            self.trades.push_back(trade);
        }
    }

    pub fn trades(&self) -> &VecDeque<Trade> {
        &self.trades
    }

    pub fn latest(&self) -> Option<&Trade> {
        self.trades.front()
    }

    pub fn clear(&mut self) {
        self.trades.clear();
    }

    pub fn len(&self) -> usize {
        self.trades.len()
    }

    pub fn is_empty(&self) -> bool {
        self.trades.is_empty()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::shared::{OrderBookId, Side};
    use chrono::Utc;
    use rust_decimal::Decimal;

    fn make_trade(id: &str, price: f64, size: f64, sequence: u64) -> Trade {
        Trade {
            orderbook_id: OrderBookId::from("ob1"),
            trade_id: id.to_string(),
            cursor_id: id.parse().ok(),
            timestamp: Utc::now(),
            price: Decimal::try_from(price).unwrap(),
            size: Decimal::try_from(size).unwrap(),
            side: Side::Bid,
            sequence,
        }
    }

    #[test]
    fn test_push_adds_trades() {
        let mut th = TradeHistory::new(OrderBookId::from("ob1"), 10);
        th.push(make_trade("t1", 50.0, 5.0, 0));
        th.push(make_trade("t2", 51.0, 3.0, 0));
        assert_eq!(th.len(), 2);
        assert_eq!(th.latest().unwrap().trade_id, "t2");
    }

    #[test]
    fn test_rolling_buffer_evicts_oldest() {
        let mut th = TradeHistory::new(OrderBookId::from("ob1"), 3);
        th.push(make_trade("t1", 50.0, 1.0, 0));
        th.push(make_trade("t2", 51.0, 2.0, 0));
        th.push(make_trade("t3", 52.0, 3.0, 0));
        assert_eq!(th.len(), 3);
        th.push(make_trade("t4", 53.0, 4.0, 0));
        assert_eq!(th.len(), 3);
        let ids: Vec<_> = th.trades().iter().map(|t| t.trade_id.as_str()).collect();
        assert_eq!(ids, ["t4", "t3", "t2"]);
    }

    #[test]
    fn test_push_reorders_by_sequence() {
        let mut th = TradeHistory::new(OrderBookId::from("ob1"), 10);
        // Arrive out of order: seq 3, then 1, then 2
        th.push(make_trade("t3", 52.0, 3.0, 3));
        th.push(make_trade("t1", 50.0, 1.0, 1));
        th.push(make_trade("t2", 51.0, 2.0, 2));
        let ids: Vec<_> = th.trades().iter().map(|t| t.trade_id.as_str()).collect();
        assert_eq!(ids, ["t3", "t2", "t1"]);
    }

    #[test]
    fn test_push_drops_older_sequence_when_full() {
        let mut th = TradeHistory::new(OrderBookId::from("ob1"), 3);
        th.push(make_trade("t5", 54.0, 5.0, 5));
        th.push(make_trade("t4", 53.0, 4.0, 4));
        th.push(make_trade("t3", 52.0, 3.0, 3));

        th.push(make_trade("t2", 51.0, 2.0, 2));

        let ids: Vec<_> = th.trades().iter().map(|t| t.trade_id.as_str()).collect();
        assert_eq!(ids, ["t5", "t4", "t3"]);
    }

    #[test]
    fn test_replace_clears_and_fills() {
        let mut th = TradeHistory::new(OrderBookId::from("ob1"), 10);
        th.push(make_trade("t1", 50.0, 1.0, 0));
        th.replace(vec![
            make_trade("a", 49.0, 1.0, 0),
            make_trade("b", 50.0, 2.0, 0),
        ]);
        assert_eq!(th.len(), 2);
        // replace uses push_back, so first vec element is at front (latest)
        assert_eq!(th.latest().unwrap().trade_id, "a");
    }
}