Skip to main content

deribit_base/model/
combo.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 6/3/26
5******************************************************************************/
6
7//! Combo Books data structures and types
8//!
9//! This module contains types for Deribit combo instruments,
10//! which are multi-leg instruments combining multiple options or futures.
11//!
12//! Combos allow trading of spread strategies (e.g., call spreads, straddles)
13//! as a single instrument with a unified order book.
14
15use crate::model::order::OrderSide;
16use pretty_simple_display::{DebugPretty, DisplaySimple};
17use serde::{Deserialize, Serialize};
18
19/// Combo state enumeration
20///
21/// Indicates the current state of a combo instrument.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
23#[serde(rename_all = "lowercase")]
24pub enum ComboState {
25    /// Request for quote state - combo is being requested
26    Rfq,
27    /// Active state - combo has an active order book
28    #[default]
29    Active,
30    /// Inactive state - combo is no longer tradeable
31    Inactive,
32}
33
34impl ComboState {
35    /// Get the string representation for API requests
36    #[must_use]
37    pub fn as_str(&self) -> &'static str {
38        match self {
39            Self::Rfq => "rfq",
40            Self::Active => "active",
41            Self::Inactive => "inactive",
42        }
43    }
44
45    /// Check if the combo is tradeable
46    #[must_use]
47    pub fn is_tradeable(&self) -> bool {
48        matches!(self, Self::Active)
49    }
50}
51
52impl std::fmt::Display for ComboState {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        write!(f, "{}", self.as_str())
55    }
56}
57
58/// Leg in a combo instrument
59///
60/// Represents one instrument leg in a combo, with the instrument name
61/// and size multiplier. A negative amount indicates the leg trades
62/// in the opposite direction to the combo trade.
63#[derive(DebugPretty, DisplaySimple, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ComboLeg {
65    /// Instrument name (e.g., "BTC-29APR22-37500-C")
66    pub instrument_name: String,
67    /// Size multiplier (negative = opposite direction)
68    pub amount: i32,
69}
70
71impl ComboLeg {
72    /// Create a new combo leg
73    #[must_use]
74    pub fn new(instrument_name: String, amount: i32) -> Self {
75        Self {
76            instrument_name,
77            amount,
78        }
79    }
80
81    /// Check if this leg is in the same direction as the combo
82    #[must_use]
83    pub fn is_same_direction(&self) -> bool {
84        self.amount > 0
85    }
86
87    /// Check if this leg is in the opposite direction to the combo
88    #[must_use]
89    pub fn is_opposite_direction(&self) -> bool {
90        self.amount < 0
91    }
92
93    /// Get the absolute amount multiplier
94    #[must_use]
95    pub fn abs_amount(&self) -> i32 {
96        self.amount.abs()
97    }
98}
99
100/// Trade leg for combo creation request
101///
102/// Used when creating a combo via `/private/create_combo`.
103/// Specifies the instrument, amount (as string), and direction.
104#[derive(DebugPretty, DisplaySimple, Clone, PartialEq, Serialize, Deserialize)]
105pub struct ComboTradeLeg {
106    /// Instrument name
107    pub instrument_name: String,
108    /// Amount as string (API requirement)
109    pub amount: String,
110    /// Trade direction
111    pub direction: OrderSide,
112}
113
114impl ComboTradeLeg {
115    /// Create a new combo trade leg
116    #[must_use]
117    pub fn new(instrument_name: String, amount: String, direction: OrderSide) -> Self {
118        Self {
119            instrument_name,
120            amount,
121            direction,
122        }
123    }
124
125    /// Create from numeric amount
126    #[must_use]
127    pub fn from_amount(instrument_name: String, amount: i32, direction: OrderSide) -> Self {
128        Self {
129            instrument_name,
130            amount: amount.to_string(),
131            direction,
132        }
133    }
134}
135
136/// Create combo request
137///
138/// Used to create a new combo or retrieve an existing combo
139/// via `/private/create_combo`.
140#[derive(DebugPretty, DisplaySimple, Clone, PartialEq, Serialize, Deserialize)]
141pub struct CreateComboRequest {
142    /// List of trade legs defining the combo structure
143    pub trades: Vec<ComboTradeLeg>,
144}
145
146impl CreateComboRequest {
147    /// Create a new combo request
148    #[must_use]
149    pub fn new(trades: Vec<ComboTradeLeg>) -> Self {
150        Self { trades }
151    }
152
153    /// Get the number of legs in this combo request
154    #[must_use]
155    pub fn leg_count(&self) -> usize {
156        self.trades.len()
157    }
158
159    /// Check if this is a valid combo (at least 2 legs)
160    #[must_use]
161    pub fn is_valid(&self) -> bool {
162        self.trades.len() >= 2
163    }
164}
165
166/// Combo details
167///
168/// Contains full details of a combo instrument,
169/// returned by `/public/get_combo_details` or `/public/get_combos`.
170#[derive(DebugPretty, DisplaySimple, Clone, PartialEq, Serialize, Deserialize)]
171pub struct ComboDetails {
172    /// Unique combo identifier (e.g., "BTC-FS-29APR22_PERP")
173    pub id: String,
174    /// Internal instrument ID
175    pub instrument_id: i64,
176    /// Current combo state
177    pub state: ComboState,
178    /// Timestamp of last state change in milliseconds
179    pub state_timestamp: i64,
180    /// Combo creation timestamp in milliseconds
181    pub creation_timestamp: i64,
182    /// List of instrument legs in the combo
183    pub legs: Vec<ComboLeg>,
184}
185
186impl ComboDetails {
187    /// Get the number of legs in this combo
188    #[must_use]
189    pub fn leg_count(&self) -> usize {
190        self.legs.len()
191    }
192
193    /// Check if the combo is currently tradeable
194    #[must_use]
195    pub fn is_tradeable(&self) -> bool {
196        self.state.is_tradeable()
197    }
198
199    /// Get all instrument names in this combo
200    #[must_use]
201    pub fn instruments(&self) -> Vec<&str> {
202        self.legs
203            .iter()
204            .map(|l| l.instrument_name.as_str())
205            .collect()
206    }
207
208    /// Check if this is a futures spread (contains "FS" in ID)
209    #[must_use]
210    pub fn is_futures_spread(&self) -> bool {
211        self.id.contains("-FS-")
212    }
213
214    /// Check if this is a call spread (contains "CS" in ID)
215    #[must_use]
216    pub fn is_call_spread(&self) -> bool {
217        self.id.contains("-CS-")
218    }
219
220    /// Check if this is a put spread (contains "PS" in ID)
221    #[must_use]
222    pub fn is_put_spread(&self) -> bool {
223        self.id.contains("-PS-")
224    }
225
226    /// Check if this is a reversal (contains "REV" in ID)
227    #[must_use]
228    pub fn is_reversal(&self) -> bool {
229        self.id.contains("-REV-")
230    }
231}
232
233/// List of combo IDs response
234///
235/// Simple wrapper for the list of combo IDs returned by `/public/get_combo_ids`.
236#[derive(DebugPretty, DisplaySimple, Clone, PartialEq, Eq, Serialize, Deserialize)]
237pub struct ComboIds {
238    /// List of combo identifiers
239    pub ids: Vec<String>,
240}
241
242impl ComboIds {
243    /// Create a new combo IDs list
244    #[must_use]
245    pub fn new(ids: Vec<String>) -> Self {
246        Self { ids }
247    }
248
249    /// Get the number of combos
250    #[must_use]
251    pub fn len(&self) -> usize {
252        self.ids.len()
253    }
254
255    /// Check if the list is empty
256    #[must_use]
257    pub fn is_empty(&self) -> bool {
258        self.ids.is_empty()
259    }
260
261    /// Check if a specific combo ID exists
262    #[must_use]
263    pub fn contains(&self, combo_id: &str) -> bool {
264        self.ids.iter().any(|id| id == combo_id)
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn test_combo_state_default() {
274        let state = ComboState::default();
275        assert_eq!(state, ComboState::Active);
276    }
277
278    #[test]
279    fn test_combo_state_as_str() {
280        assert_eq!(ComboState::Rfq.as_str(), "rfq");
281        assert_eq!(ComboState::Active.as_str(), "active");
282        assert_eq!(ComboState::Inactive.as_str(), "inactive");
283    }
284
285    #[test]
286    fn test_combo_state_is_tradeable() {
287        assert!(!ComboState::Rfq.is_tradeable());
288        assert!(ComboState::Active.is_tradeable());
289        assert!(!ComboState::Inactive.is_tradeable());
290    }
291
292    #[test]
293    fn test_combo_state_display() {
294        assert_eq!(format!("{}", ComboState::Rfq), "rfq");
295        assert_eq!(format!("{}", ComboState::Active), "active");
296        assert_eq!(format!("{}", ComboState::Inactive), "inactive");
297    }
298
299    #[test]
300    fn test_combo_state_serialization() {
301        let state = ComboState::Active;
302        let json = serde_json::to_string(&state).unwrap();
303        assert_eq!(json, "\"active\"");
304
305        let deserialized: ComboState = serde_json::from_str(&json).unwrap();
306        assert_eq!(deserialized, ComboState::Active);
307    }
308
309    #[test]
310    fn test_combo_leg_new() {
311        let leg = ComboLeg::new("BTC-PERPETUAL".to_string(), -1);
312        assert_eq!(leg.instrument_name, "BTC-PERPETUAL");
313        assert_eq!(leg.amount, -1);
314    }
315
316    #[test]
317    fn test_combo_leg_direction() {
318        let positive_leg = ComboLeg::new("BTC-29APR22".to_string(), 1);
319        assert!(positive_leg.is_same_direction());
320        assert!(!positive_leg.is_opposite_direction());
321        assert_eq!(positive_leg.abs_amount(), 1);
322
323        let negative_leg = ComboLeg::new("BTC-PERPETUAL".to_string(), -1);
324        assert!(!negative_leg.is_same_direction());
325        assert!(negative_leg.is_opposite_direction());
326        assert_eq!(negative_leg.abs_amount(), 1);
327    }
328
329    #[test]
330    fn test_combo_leg_serialization() {
331        let leg = ComboLeg::new("BTC-PERPETUAL".to_string(), -1);
332        let json = serde_json::to_string(&leg).unwrap();
333        let deserialized: ComboLeg = serde_json::from_str(&json).unwrap();
334        assert_eq!(leg, deserialized);
335    }
336
337    #[test]
338    fn test_combo_trade_leg_new() {
339        let leg = ComboTradeLeg::new(
340            "BTC-29APR22-37500-C".to_string(),
341            "1".to_string(),
342            OrderSide::Buy,
343        );
344        assert_eq!(leg.instrument_name, "BTC-29APR22-37500-C");
345        assert_eq!(leg.amount, "1");
346        assert_eq!(leg.direction, OrderSide::Buy);
347    }
348
349    #[test]
350    fn test_combo_trade_leg_from_amount() {
351        let leg = ComboTradeLeg::from_amount("BTC-29APR22-37500-C".to_string(), 1, OrderSide::Buy);
352        assert_eq!(leg.amount, "1");
353    }
354
355    #[test]
356    fn test_combo_trade_leg_serialization() {
357        let leg = ComboTradeLeg::new(
358            "BTC-29APR22-37500-C".to_string(),
359            "1".to_string(),
360            OrderSide::Buy,
361        );
362        let json = serde_json::to_string(&leg).unwrap();
363        let deserialized: ComboTradeLeg = serde_json::from_str(&json).unwrap();
364        assert_eq!(leg, deserialized);
365    }
366
367    #[test]
368    fn test_create_combo_request_new() {
369        let trades = vec![
370            ComboTradeLeg::new(
371                "BTC-29APR22-37500-C".to_string(),
372                "1".to_string(),
373                OrderSide::Buy,
374            ),
375            ComboTradeLeg::new(
376                "BTC-29APR22-37500-P".to_string(),
377                "1".to_string(),
378                OrderSide::Sell,
379            ),
380        ];
381        let request = CreateComboRequest::new(trades);
382        assert_eq!(request.leg_count(), 2);
383        assert!(request.is_valid());
384    }
385
386    #[test]
387    fn test_create_combo_request_invalid() {
388        let request = CreateComboRequest::new(vec![ComboTradeLeg::new(
389            "BTC-29APR22-37500-C".to_string(),
390            "1".to_string(),
391            OrderSide::Buy,
392        )]);
393        assert!(!request.is_valid());
394    }
395
396    fn create_test_combo_details() -> ComboDetails {
397        ComboDetails {
398            id: "BTC-FS-29APR22_PERP".to_string(),
399            instrument_id: 27,
400            state: ComboState::Active,
401            state_timestamp: 1650620605150,
402            creation_timestamp: 1650620575000,
403            legs: vec![
404                ComboLeg::new("BTC-PERPETUAL".to_string(), -1),
405                ComboLeg::new("BTC-29APR22".to_string(), 1),
406            ],
407        }
408    }
409
410    #[test]
411    fn test_combo_details_leg_count() {
412        let combo = create_test_combo_details();
413        assert_eq!(combo.leg_count(), 2);
414    }
415
416    #[test]
417    fn test_combo_details_is_tradeable() {
418        let active_combo = create_test_combo_details();
419        assert!(active_combo.is_tradeable());
420
421        let mut inactive_combo = create_test_combo_details();
422        inactive_combo.state = ComboState::Inactive;
423        assert!(!inactive_combo.is_tradeable());
424    }
425
426    #[test]
427    fn test_combo_details_instruments() {
428        let combo = create_test_combo_details();
429        let instruments = combo.instruments();
430        assert_eq!(instruments.len(), 2);
431        assert!(instruments.contains(&"BTC-PERPETUAL"));
432        assert!(instruments.contains(&"BTC-29APR22"));
433    }
434
435    #[test]
436    fn test_combo_details_type_detection() {
437        let futures_spread = create_test_combo_details();
438        assert!(futures_spread.is_futures_spread());
439        assert!(!futures_spread.is_call_spread());
440        assert!(!futures_spread.is_put_spread());
441        assert!(!futures_spread.is_reversal());
442
443        let mut call_spread = create_test_combo_details();
444        call_spread.id = "BTC-CS-29APR22-39300_39600".to_string();
445        assert!(call_spread.is_call_spread());
446
447        let mut reversal = create_test_combo_details();
448        reversal.id = "BTC-REV-29APR22-37500".to_string();
449        assert!(reversal.is_reversal());
450    }
451
452    #[test]
453    fn test_combo_details_serialization() {
454        let combo = create_test_combo_details();
455        let json = serde_json::to_string(&combo).unwrap();
456        let deserialized: ComboDetails = serde_json::from_str(&json).unwrap();
457        assert_eq!(combo.id, deserialized.id);
458        assert_eq!(combo.state, deserialized.state);
459        assert_eq!(combo.legs.len(), deserialized.legs.len());
460    }
461
462    #[test]
463    fn test_combo_ids_new() {
464        let ids = ComboIds::new(vec![
465            "BTC-CS-29APR22-39300_39600".to_string(),
466            "BTC-FS-29APR22_PERP".to_string(),
467        ]);
468        assert_eq!(ids.len(), 2);
469        assert!(!ids.is_empty());
470    }
471
472    #[test]
473    fn test_combo_ids_contains() {
474        let ids = ComboIds::new(vec![
475            "BTC-CS-29APR22-39300_39600".to_string(),
476            "BTC-FS-29APR22_PERP".to_string(),
477        ]);
478        assert!(ids.contains("BTC-FS-29APR22_PERP"));
479        assert!(!ids.contains("ETH-FS-29APR22_PERP"));
480    }
481
482    #[test]
483    fn test_combo_ids_empty() {
484        let ids = ComboIds::new(vec![]);
485        assert!(ids.is_empty());
486        assert_eq!(ids.len(), 0);
487    }
488
489    #[test]
490    fn test_combo_ids_serialization() {
491        let ids = ComboIds::new(vec!["BTC-FS-29APR22_PERP".to_string()]);
492        let json = serde_json::to_string(&ids).unwrap();
493        let deserialized: ComboIds = serde_json::from_str(&json).unwrap();
494        assert_eq!(ids, deserialized);
495    }
496}