deribit_base/model/
book_summary.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 21/7/25
5******************************************************************************/
6
7use crate::{impl_json_debug_pretty, impl_json_display};
8use serde::{Deserialize, Serialize};
9
10/// Book summary information for an instrument
11#[derive(Clone, PartialEq, Serialize, Deserialize)]
12pub struct BookSummary {
13    /// Instrument name
14    pub instrument_name: String,
15    /// Base currency
16    pub base_currency: String,
17    /// Quote currency (usually USD)
18    pub quote_currency: String,
19    /// 24h trading volume
20    pub volume: f64,
21    /// 24h trading volume in USD
22    pub volume_usd: f64,
23    /// Open interest
24    pub open_interest: f64,
25    /// 24h price change percentage
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub price_change: Option<f64>,
28    /// Current mark price
29    pub mark_price: f64,
30    /// Mark implied volatility (options only)
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub mark_iv: Option<f64>,
33    /// Best bid price
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub bid_price: Option<f64>,
36    /// Best ask price
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub ask_price: Option<f64>,
39    /// Mid price (bid + ask) / 2
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub mid_price: Option<f64>,
42    /// Last trade price
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub last: Option<f64>,
45    /// 24h high price
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub high: Option<f64>,
48    /// 24h low price
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub low: Option<f64>,
51    /// Estimated delivery price
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub estimated_delivery_price: Option<f64>,
54    /// Current funding rate (perpetuals only)
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub current_funding: Option<f64>,
57    /// 8h funding rate (perpetuals only)
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub funding_8h: Option<f64>,
60    /// Creation timestamp (milliseconds since Unix epoch)
61    pub creation_timestamp: i64,
62    // Additional optional fields merged from deribit-http types.rs
63    /// Underlying index name
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub underlying_index: Option<String>,
66    /// Underlying price
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub underlying_price: Option<f64>,
69    /// Interest rate
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub interest_rate: Option<f64>,
72}
73
74impl BookSummary {
75    /// Create a new book summary
76    pub fn new(
77        instrument_name: String,
78        base_currency: String,
79        quote_currency: String,
80        mark_price: f64,
81        creation_timestamp: i64,
82    ) -> Self {
83        Self {
84            instrument_name,
85            base_currency,
86            quote_currency,
87            volume: 0.0,
88            volume_usd: 0.0,
89            open_interest: 0.0,
90            price_change: None,
91            mark_price,
92            mark_iv: None,
93            bid_price: None,
94            ask_price: None,
95            mid_price: None,
96            last: None,
97            high: None,
98            low: None,
99            estimated_delivery_price: None,
100            current_funding: None,
101            funding_8h: None,
102            creation_timestamp,
103            // initialize merged optional fields
104            underlying_index: None,
105            underlying_price: None,
106            interest_rate: None,
107        }
108    }
109
110    /// Set volume information
111    pub fn with_volume(mut self, volume: f64, volume_usd: f64) -> Self {
112        self.volume = volume;
113        self.volume_usd = volume_usd;
114        self
115    }
116
117    /// Set price information
118    pub fn with_prices(
119        mut self,
120        bid: Option<f64>,
121        ask: Option<f64>,
122        last: Option<f64>,
123        high: Option<f64>,
124        low: Option<f64>,
125    ) -> Self {
126        self.bid_price = bid;
127        self.ask_price = ask;
128        self.last = last;
129        self.high = high;
130        self.low = low;
131
132        // Calculate mid price if both bid and ask are available
133        if let (Some(bid), Some(ask)) = (bid, ask) {
134            self.mid_price = Some((bid + ask) / 2.0);
135        }
136
137        self
138    }
139
140    /// Set open interest
141    pub fn with_open_interest(mut self, open_interest: f64) -> Self {
142        self.open_interest = open_interest;
143        self
144    }
145
146    /// Set price change percentage
147    pub fn with_price_change(mut self, price_change: f64) -> Self {
148        self.price_change = Some(price_change);
149        self
150    }
151
152    /// Set implied volatility (for options)
153    pub fn with_iv(mut self, mark_iv: f64) -> Self {
154        self.mark_iv = Some(mark_iv);
155        self
156    }
157
158    /// Set funding rates (for perpetuals)
159    pub fn with_funding(mut self, current: f64, funding_8h: f64) -> Self {
160        self.current_funding = Some(current);
161        self.funding_8h = Some(funding_8h);
162        self
163    }
164
165    /// Set estimated delivery price
166    pub fn with_delivery_price(mut self, price: f64) -> Self {
167        self.estimated_delivery_price = Some(price);
168        self
169    }
170
171    /// Get spread (ask - bid)
172    pub fn spread(&self) -> Option<f64> {
173        match (self.bid_price, self.ask_price) {
174            (Some(bid), Some(ask)) => Some(ask - bid),
175            _ => None,
176        }
177    }
178
179    /// Get spread percentage
180    pub fn spread_percentage(&self) -> Option<f64> {
181        match (self.spread(), self.mid_price) {
182            (Some(spread), Some(mid)) if mid > 0.0 => Some((spread / mid) * 100.0),
183            _ => None,
184        }
185    }
186
187    /// Check if this is a perpetual contract
188    pub fn is_perpetual(&self) -> bool {
189        self.instrument_name.contains("PERPETUAL")
190    }
191
192    /// Check if this is an option
193    pub fn is_option(&self) -> bool {
194        // Options end with -C or -P (call/put) but not PERPETUAL
195        !self.is_perpetual()
196            && (self.instrument_name.ends_with("-C") || self.instrument_name.ends_with("-P"))
197    }
198
199    /// Check if this is a future
200    pub fn is_future(&self) -> bool {
201        !self.is_perpetual() && !self.is_option()
202    }
203
204    /// Get 24h price change in absolute terms
205    pub fn price_change_absolute(&self) -> Option<f64> {
206        self.price_change.map(|change| {
207            if let Some(last) = self.last {
208                last * (change / 100.0)
209            } else {
210                self.mark_price * (change / 100.0)
211            }
212        })
213    }
214}
215
216impl_json_display!(BookSummary);
217impl_json_debug_pretty!(BookSummary);
218
219/// Collection of book summaries
220#[derive(Clone, PartialEq, Serialize, Deserialize)]
221pub struct BookSummaries {
222    /// List of book summaries
223    pub summaries: Vec<BookSummary>,
224}
225
226impl BookSummaries {
227    /// Create a new collection
228    pub fn new() -> Self {
229        Self {
230            summaries: Vec::new(),
231        }
232    }
233
234    /// Add a book summary
235    pub fn add(&mut self, summary: BookSummary) {
236        self.summaries.push(summary);
237    }
238
239    /// Get summaries by currency
240    pub fn by_currency(&self, currency: String) -> Vec<&BookSummary> {
241        self.summaries
242            .iter()
243            .filter(|s| s.base_currency == currency)
244            .collect()
245    }
246
247    /// Get summaries by instrument type
248    pub fn perpetuals(&self) -> Vec<&BookSummary> {
249        self.summaries.iter().filter(|s| s.is_perpetual()).collect()
250    }
251
252    /// Get option summaries
253    pub fn options(&self) -> Vec<&BookSummary> {
254        self.summaries.iter().filter(|s| s.is_option()).collect()
255    }
256
257    /// Get future summaries
258    pub fn futures(&self) -> Vec<&BookSummary> {
259        self.summaries.iter().filter(|s| s.is_future()).collect()
260    }
261
262    /// Sort by volume (descending)
263    pub fn sort_by_volume(&mut self) {
264        self.summaries
265            .sort_by(|a, b| b.volume_usd.partial_cmp(&a.volume_usd).unwrap());
266    }
267
268    /// Sort by open interest (descending)
269    pub fn sort_by_open_interest(&mut self) {
270        self.summaries
271            .sort_by(|a, b| b.open_interest.partial_cmp(&a.open_interest).unwrap());
272    }
273}
274
275impl Default for BookSummaries {
276    fn default() -> Self {
277        Self::new()
278    }
279}
280
281impl_json_display!(BookSummaries);
282impl_json_debug_pretty!(BookSummaries);
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn test_book_summary_creation() {
290        let summary = BookSummary::new(
291            "BTC-PERPETUAL".to_string(),
292            "BTC".to_string(),
293            "USD".to_string(),
294            45000.0,
295            1640995200000,
296        );
297
298        assert_eq!(summary.instrument_name, "BTC-PERPETUAL");
299        assert_eq!(summary.base_currency, "BTC".to_string());
300        assert_eq!(summary.mark_price, 45000.0);
301        assert!(summary.is_perpetual());
302    }
303
304    #[test]
305    fn test_book_summary_builder() {
306        let summary = BookSummary::new(
307            "BTC-25MAR23-50000-C".to_string(),
308            "BTC".to_string(),
309            "USD".to_string(),
310            2500.0,
311            1640995200000,
312        )
313        .with_volume(100.0, 4500000.0)
314        .with_prices(
315            Some(2480.0),
316            Some(2520.0),
317            Some(2500.0),
318            Some(2600.0),
319            Some(2400.0),
320        )
321        .with_iv(85.5);
322
323        assert_eq!(summary.volume, 100.0);
324        assert_eq!(summary.volume_usd, 4500000.0);
325        assert_eq!(summary.bid_price, Some(2480.0));
326        assert_eq!(summary.ask_price, Some(2520.0));
327        assert_eq!(summary.mid_price, Some(2500.0));
328        assert_eq!(summary.mark_iv, Some(85.5));
329        assert!(summary.is_option());
330    }
331
332    #[test]
333    fn test_spread_calculation() {
334        let summary = BookSummary::new(
335            "BTC-PERPETUAL".to_string(),
336            "BTC".to_string(),
337            "USD".to_string(),
338            45000.0,
339            1640995200000,
340        )
341        .with_prices(Some(44950.0), Some(45050.0), None, None, None);
342
343        assert_eq!(summary.spread(), Some(100.0));
344        assert_eq!(summary.mid_price, Some(45000.0));
345
346        let spread_pct = summary.spread_percentage().unwrap();
347        assert!((spread_pct - 0.2222).abs() < 0.001); // ~0.22%
348    }
349
350    #[test]
351    fn test_instrument_type_detection() {
352        let perpetual = BookSummary::new(
353            "BTC-PERPETUAL".to_string(),
354            "BTC".to_string(),
355            "USD".to_string(),
356            45000.0,
357            0,
358        );
359        assert!(perpetual.is_perpetual());
360        assert!(!perpetual.is_option());
361        assert!(!perpetual.is_future());
362
363        let option = BookSummary::new(
364            "BTC-25MAR23-50000-C".to_string(),
365            "BTC".to_string(),
366            "USD".to_string(),
367            2500.0,
368            0,
369        );
370        assert!(!option.is_perpetual());
371        assert!(option.is_option());
372        assert!(!option.is_future());
373
374        let future = BookSummary::new(
375            "BTC-25MAR23".to_string(),
376            "BTC".to_string(),
377            "USD".to_string(),
378            45000.0,
379            0,
380        );
381        assert!(!future.is_perpetual());
382        assert!(!future.is_option());
383        assert!(future.is_future());
384    }
385
386    #[test]
387    fn test_book_summaries_collection() {
388        let mut summaries = BookSummaries::new();
389
390        summaries.add(
391            BookSummary::new(
392                "BTC-PERPETUAL".to_string(),
393                "BTC".to_string(),
394                "USD".to_string(),
395                45000.0,
396                0,
397            )
398            .with_volume(1000.0, 45000000.0),
399        );
400
401        summaries.add(
402            BookSummary::new(
403                "ETH-PERPETUAL".to_string(),
404                "ETH".to_string(),
405                "USD".to_string(),
406                3000.0,
407                0,
408            )
409            .with_volume(500.0, 1500000.0),
410        );
411
412        assert_eq!(summaries.summaries.len(), 2);
413        assert_eq!(summaries.by_currency("BTC".to_string()).len(), 1);
414        assert_eq!(summaries.perpetuals().len(), 2);
415
416        summaries.sort_by_volume();
417        assert_eq!(summaries.summaries[0].base_currency, "BTC".to_string());
418    }
419
420    #[test]
421    fn test_serde() {
422        let summary = BookSummary::new(
423            "BTC-PERPETUAL".to_string(),
424            "BTC".to_string(),
425            "USD".to_string(),
426            45000.0,
427            1640995200000,
428        )
429        .with_funding(0.0001, 0.0008);
430
431        let json = serde_json::to_string(&summary).unwrap();
432        let deserialized: BookSummary = serde_json::from_str(&json).unwrap();
433        assert_eq!(summary, deserialized);
434    }
435}