Skip to main content

finance_query/streaming/
book.rs

1//! Order-book (level 2) depth streaming.
2//!
3//! Top-of-book bid/ask is all [`PriceUpdate`](super::PriceUpdate) can carry;
4//! this stream pushes the full ladder of price levels per side.
5
6use std::sync::Arc;
7use std::time::Duration;
8
9use serde::{Deserialize, Serialize};
10
11use super::client::StreamResult;
12use super::handle::{RECONNECT_BACKOFF, SourceStream, stream_builder, stream_handle};
13use super::polygon::PolygonBookSource;
14use super::source::ReconnectConfig;
15
16/// Channel capacity — book updates are large but less frequent than prints.
17const CHANNEL_CAPACITY: usize = 1024;
18
19/// One price level of an order book.
20#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22#[non_exhaustive]
23pub struct BookLevel {
24    /// Level price.
25    pub price: f64,
26    /// Total size resting at this price.
27    pub size: f64,
28}
29
30/// A depth-of-book update: both sides, best level first.
31#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33#[non_exhaustive]
34pub struct OrderBookUpdate {
35    /// Symbol or pair this book belongs to.
36    pub symbol: String,
37    /// Bid levels, highest price first.
38    pub bids: Vec<BookLevel>,
39    /// Ask levels, lowest price first.
40    pub asks: Vec<BookLevel>,
41    /// Exchange identifier, where the venue reports one.
42    pub exchange: Option<i32>,
43    /// Update timestamp (milliseconds).
44    pub time: i64,
45}
46
47impl OrderBookUpdate {
48    /// Best (highest) bid level.
49    pub fn best_bid(&self) -> Option<BookLevel> {
50        self.bids.first().copied()
51    }
52
53    /// Best (lowest) ask level.
54    pub fn best_ask(&self) -> Option<BookLevel> {
55        self.asks.first().copied()
56    }
57
58    /// Difference between best ask and best bid.
59    pub fn spread(&self) -> Option<f64> {
60        Some(self.best_ask()?.price - self.best_bid()?.price)
61    }
62
63    /// Midpoint of the top of book.
64    pub fn mid(&self) -> Option<f64> {
65        Some((self.best_ask()?.price + self.best_bid()?.price) / 2.0)
66    }
67
68    /// Total size resting on each side, as `(bid_depth, ask_depth)`.
69    pub fn depth(&self) -> (f64, f64) {
70        (
71            self.bids.iter().map(|l| l.size).sum(),
72            self.asks.iter().map(|l| l.size).sum(),
73        )
74    }
75}
76
77stream_handle! {
78    /// A subscription to level-2 order-book depth.
79    ///
80    /// Backed by Polygon's crypto level-2 feed (`XL2`) — the one cluster that
81    /// publishes depth — so pairs are crypto pairs (`"BTC-USD"`). Requires the
82    /// `polygon` feature and the `POLYGON_API_KEY` environment variable set.
83    ///
84    /// # Example
85    ///
86    /// ```no_run
87    /// use finance_query::streaming::DepthStream;
88    /// use futures::StreamExt;
89    ///
90    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
91    /// let mut books = DepthStream::subscribe(["BTC-USD"]).await?;
92    ///
93    /// while let Some(book) = books.next().await {
94    ///     println!("{} spread {:?}", book.symbol, book.spread());
95    /// }
96    /// # Ok(())
97    /// # }
98    /// ```
99    DepthStream(OrderBookUpdate);
100    add: add_pairs = "Add pairs to the subscription.",
101    remove: remove_pairs = "Remove pairs from the subscription.",
102}
103
104impl DepthStream {
105    /// Subscribe to depth updates for the given crypto pairs.
106    pub async fn subscribe<S, I>(pairs: I) -> StreamResult<Self>
107    where
108        S: Into<String>,
109        I: IntoIterator<Item = S>,
110    {
111        DepthStreamBuilder::new().pairs(pairs).build().await
112    }
113}
114
115/// Builder for a [`DepthStream`].
116pub struct DepthStreamBuilder {
117    pairs: Vec<String>,
118    retry_delay: Duration,
119    max_reconnect_attempts: Option<u32>,
120}
121
122impl DepthStreamBuilder {
123    /// Create a builder with no pairs.
124    pub fn new() -> Self {
125        Self {
126            pairs: Vec::new(),
127            retry_delay: RECONNECT_BACKOFF,
128            max_reconnect_attempts: None,
129        }
130    }
131
132    /// Build and start the stream.
133    pub async fn build(self) -> StreamResult<DepthStream> {
134        let reconnect =
135            ReconnectConfig::new(self.retry_delay).max_attempts(self.max_reconnect_attempts);
136        Ok(DepthStream {
137            inner: SourceStream::start(
138                Arc::new(PolygonBookSource),
139                self.pairs,
140                reconnect,
141                CHANNEL_CAPACITY,
142            ),
143        })
144    }
145}
146
147stream_builder!(
148    DepthStreamBuilder,
149    pairs = "Add crypto pairs to subscribe to."
150);
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    fn book() -> OrderBookUpdate {
157        OrderBookUpdate {
158            symbol: "BTC-USD".into(),
159            bids: vec![
160                BookLevel {
161                    price: 100.0,
162                    size: 2.0,
163                },
164                BookLevel {
165                    price: 99.0,
166                    size: 3.0,
167                },
168            ],
169            asks: vec![
170                BookLevel {
171                    price: 101.0,
172                    size: 1.0,
173                },
174                BookLevel {
175                    price: 102.0,
176                    size: 4.0,
177                },
178            ],
179            ..Default::default()
180        }
181    }
182
183    #[test]
184    fn top_of_book_helpers_use_the_first_level() {
185        let book = book();
186        assert_eq!(book.best_bid().unwrap().price, 100.0);
187        assert_eq!(book.best_ask().unwrap().price, 101.0);
188        assert!((book.spread().unwrap() - 1.0).abs() < 1e-9);
189        assert!((book.mid().unwrap() - 100.5).abs() < 1e-9);
190    }
191
192    #[test]
193    fn depth_sums_each_side() {
194        let (bid_depth, ask_depth) = book().depth();
195        assert!((bid_depth - 5.0).abs() < 1e-9);
196        assert!((ask_depth - 5.0).abs() < 1e-9);
197    }
198
199    #[test]
200    fn an_empty_side_has_no_spread() {
201        let empty = OrderBookUpdate::default();
202        assert!(empty.spread().is_none());
203        assert!(empty.mid().is_none());
204    }
205}