Skip to main content

digdigdig3_station/data/
ob_delta.rs

1use digdigdig3::core::types::{OrderbookDelta, StreamEvent};
2use serde::{Deserialize, Serialize};
3
4use crate::series::DataPoint;
5
6/// Incremental order-book delta — variable depth.
7///
8/// A delta carries the levels whose `size` changed since the previous
9/// frame. `size == 0.0` means the level was removed; otherwise the level
10/// was inserted (new price) or updated (existing price, new size). The
11/// consumer composes a live book by applying deltas on top of a snapshot.
12///
13/// Depth is whatever the exchange sent — `OrderbookDelta.bids/asks` is a
14/// full `Vec` from the connector, never truncated. The on-disk record is a
15/// fixed 20-byte header (`ts_ms` + blob pointer) plus a companion `.blob`
16/// file holding the variable-length level pairs, so no hard level cap leaks
17/// into either the channel `Event` or persistence.
18///
19/// Blob layout (LE):
20///   u32 bid_count | bid_count * (f64 price, f64 size)
21///   u32 ask_count | ask_count * (f64 price, f64 size)
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ObDeltaPoint {
24    pub ts_ms: i64,
25    /// Bid-side changes: (price, new_size). `new_size == 0.0` ⇒ remove.
26    pub bid_changes: Vec<(f64, f64)>,
27    /// Ask-side changes: (price, new_size). `new_size == 0.0` ⇒ remove.
28    pub ask_changes: Vec<(f64, f64)>,
29}
30
31/// Header = `ts_ms (8) | blob_off (8) | blob_len (4)` = 20 B.
32const TAIL_OFFSET: usize = 8;
33const HEADER_SIZE: usize = 20;
34
35impl ObDeltaPoint {
36    pub fn from_delta(d: &OrderbookDelta) -> Self {
37        // Full depth — no truncation. The exchange/connector decides how many
38        // levels a delta carries; we keep them all.
39        let bid_changes = d.bids.iter().map(|l| (l.price, l.size)).collect();
40        let ask_changes = d.asks.iter().map(|l| (l.price, l.size)).collect();
41        Self { ts_ms: d.timestamp, bid_changes, ask_changes }
42    }
43}
44
45/// Encode a side as `u32 count | count * (f64 price, f64 size)`.
46fn encode_side_blob(levels: &[(f64, f64)], out: &mut Vec<u8>) {
47    out.extend_from_slice(&(levels.len() as u32).to_le_bytes());
48    for (p, q) in levels {
49        out.extend_from_slice(&p.to_le_bytes());
50        out.extend_from_slice(&q.to_le_bytes());
51    }
52}
53
54/// Decode a side written by [`encode_side_blob`]. Returns the parsed levels
55/// and the number of bytes consumed, or `None` on a malformed slice.
56fn decode_side_blob(blob: &[u8]) -> Option<(Vec<(f64, f64)>, usize)> {
57    if blob.len() < 4 {
58        return None;
59    }
60    let count = u32::from_le_bytes(blob[0..4].try_into().ok()?) as usize;
61    let need = 4 + count * 16;
62    if blob.len() < need {
63        return None;
64    }
65    let mut levels = Vec::with_capacity(count);
66    let mut off = 4;
67    for _ in 0..count {
68        let p = f64::from_le_bytes(blob[off..off + 8].try_into().ok()?);
69        let q = f64::from_le_bytes(blob[off + 8..off + 16].try_into().ok()?);
70        levels.push((p, q));
71        off += 16;
72    }
73    Some((levels, need))
74}
75
76impl DataPoint for ObDeltaPoint {
77    const RECORD_SIZE: usize = HEADER_SIZE;
78
79    fn encode(&self, out: &mut [u8]) {
80        // Header only: ts_ms. The (blob_off, blob_len) tail at TAIL_OFFSET is
81        // patched by DiskStore after encode runs.
82        out[0..8].copy_from_slice(&(self.ts_ms as u64).to_le_bytes());
83    }
84
85    fn decode(bytes: &[u8]) -> Option<Self> {
86        // Header-only decode (blob unavailable): timestamp survives, levels
87        // are empty. The blob path (decode_blob) reconstructs the levels.
88        if bytes.len() != Self::RECORD_SIZE {
89            return None;
90        }
91        Some(Self {
92            ts_ms: u64::from_le_bytes(bytes[0..8].try_into().ok()?) as i64,
93            bid_changes: Vec::new(),
94            ask_changes: Vec::new(),
95        })
96    }
97
98    fn timestamp_ms(&self) -> i64 { self.ts_ms }
99
100    fn from_stream_event(ev: &StreamEvent) -> Option<Self> {
101        if let StreamEvent::OrderbookDelta { delta, .. } = ev {
102            Some(Self::from_delta(delta))
103        } else {
104            None
105        }
106    }
107
108    fn encode_blob(&self) -> Option<Vec<u8>> {
109        let mut out = Vec::with_capacity(
110            8 + (self.bid_changes.len() + self.ask_changes.len()) * 16,
111        );
112        encode_side_blob(&self.bid_changes, &mut out);
113        encode_side_blob(&self.ask_changes, &mut out);
114        Some(out)
115    }
116
117    fn decode_blob(header: &[u8], blob: &[u8]) -> Option<Self> {
118        let mut p = Self::decode(header)?;
119        if let Some((bids, consumed)) = decode_side_blob(blob) {
120            p.bid_changes = bids;
121            if let Some((asks, _)) = decode_side_blob(&blob[consumed..]) {
122                p.ask_changes = asks;
123            }
124        }
125        Some(p)
126    }
127
128    fn blob_pointer_offset() -> Option<usize> { Some(TAIL_OFFSET) }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use digdigdig3::core::types::OrderBookLevel;
135
136    fn lvl(price: f64, size: f64) -> OrderBookLevel {
137        OrderBookLevel { price, size, order_count: None }
138    }
139
140    #[test]
141    fn from_delta_keeps_full_depth() {
142        // 40 levels per side — far past the old 25 cap.
143        let bids: Vec<OrderBookLevel> = (0..40).map(|i| lvl(100.0 - i as f64, i as f64)).collect();
144        let asks: Vec<OrderBookLevel> = (0..40).map(|i| lvl(101.0 + i as f64, i as f64)).collect();
145        let d = OrderbookDelta { bids, asks, timestamp: 1234, ..Default::default() };
146        let p = ObDeltaPoint::from_delta(&d);
147        assert_eq!(p.bid_changes.len(), 40);
148        assert_eq!(p.ask_changes.len(), 40);
149    }
150
151    #[test]
152    fn blob_round_trip_full_depth() {
153        let bids: Vec<(f64, f64)> = (0..30).map(|i| (100.0 - i as f64, i as f64)).collect();
154        let asks: Vec<(f64, f64)> = (0..50).map(|i| (101.0 + i as f64, i as f64)).collect();
155        let p = ObDeltaPoint { ts_ms: 9999, bid_changes: bids.clone(), ask_changes: asks.clone() };
156
157        let mut header = vec![0u8; ObDeltaPoint::RECORD_SIZE];
158        p.encode(&mut header);
159        let blob = p.encode_blob().expect("blob");
160
161        let back = ObDeltaPoint::decode_blob(&header, &blob).expect("decode_blob");
162        assert_eq!(back.ts_ms, 9999);
163        assert_eq!(back.bid_changes, bids);
164        assert_eq!(back.ask_changes, asks);
165    }
166
167    #[test]
168    fn blob_round_trip_with_removal_zero_size() {
169        // A removal carries (price, 0.0) at a non-zero price — must survive.
170        let p = ObDeltaPoint {
171            ts_ms: 1,
172            bid_changes: vec![(99.0, 0.0), (98.5, 3.0)],
173            ask_changes: vec![(101.0, 0.0)],
174        };
175        let mut header = vec![0u8; ObDeltaPoint::RECORD_SIZE];
176        p.encode(&mut header);
177        let blob = p.encode_blob().expect("blob");
178        let back = ObDeltaPoint::decode_blob(&header, &blob).expect("decode_blob");
179        assert_eq!(back.bid_changes, vec![(99.0, 0.0), (98.5, 3.0)]);
180        assert_eq!(back.ask_changes, vec![(101.0, 0.0)]);
181    }
182
183    #[test]
184    fn header_only_decode_empty_levels() {
185        let p = ObDeltaPoint { ts_ms: 42, bid_changes: vec![(1.0, 2.0)], ask_changes: vec![] };
186        let mut header = vec![0u8; ObDeltaPoint::RECORD_SIZE];
187        p.encode(&mut header);
188        let back = ObDeltaPoint::decode(&header).expect("decode");
189        assert_eq!(back.ts_ms, 42);
190        assert!(back.bid_changes.is_empty());
191        assert!(back.ask_changes.is_empty());
192    }
193}