Skip to main content

deribit_http/model/
combo.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 7/3/26
5******************************************************************************/
6//! Combo books models for Deribit API
7//!
8//! This module provides types for combo instrument operations including
9//! creating combos and calculating leg prices.
10
11use serde::{Deserialize, Serialize};
12
13/// Combo state enumeration
14///
15/// Represents the current state of a combo instrument.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18#[derive(Default)]
19pub enum ComboState {
20    /// Request for quote state
21    #[default]
22    Rfq,
23    /// Active combo available for trading
24    Active,
25    /// Inactive combo not available for trading
26    Inactive,
27}
28
29impl std::fmt::Display for ComboState {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            ComboState::Rfq => write!(f, "rfq"),
33            ComboState::Active => write!(f, "active"),
34            ComboState::Inactive => write!(f, "inactive"),
35        }
36    }
37}
38
39/// A leg within a combo instrument
40///
41/// Represents a single leg of a combo, consisting of an instrument
42/// and an amount multiplier.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct ComboLeg {
45    /// Unique instrument identifier for this leg
46    pub instrument_name: String,
47    /// Size multiplier of the leg. A negative value indicates that trades
48    /// on this leg are in the opposite direction to the combo trades.
49    pub amount: i64,
50}
51
52impl ComboLeg {
53    /// Creates a new combo leg
54    ///
55    /// # Arguments
56    ///
57    /// * `instrument_name` - The instrument identifier
58    /// * `amount` - The size multiplier (can be negative for opposite direction)
59    #[must_use]
60    pub fn new(instrument_name: impl Into<String>, amount: i64) -> Self {
61        Self {
62            instrument_name: instrument_name.into(),
63            amount,
64        }
65    }
66
67    /// Returns true if this leg represents the opposite direction
68    #[must_use]
69    pub fn is_opposite_direction(&self) -> bool {
70        self.amount < 0
71    }
72}
73
74/// Combo instrument information
75///
76/// Contains full details about a combo instrument including its legs,
77/// state, and timestamps.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct Combo {
80    /// Unique combo identifier (e.g., "BTC-FS-29APR22_PERP")
81    pub id: String,
82    /// Numeric instrument ID
83    pub instrument_id: u64,
84    /// Current state of the combo
85    pub state: ComboState,
86    /// Timestamp of the last state change in milliseconds since Unix epoch
87    pub state_timestamp: u64,
88    /// Timestamp when the combo was created in milliseconds since Unix epoch
89    pub creation_timestamp: u64,
90    /// List of legs that make up this combo
91    pub legs: Vec<ComboLeg>,
92}
93
94impl Combo {
95    /// Returns true if the combo is currently active for trading
96    #[must_use]
97    pub fn is_active(&self) -> bool {
98        self.state == ComboState::Active
99    }
100
101    /// Returns true if the combo is in RFQ state
102    #[must_use]
103    pub fn is_rfq(&self) -> bool {
104        self.state == ComboState::Rfq
105    }
106
107    /// Returns the number of legs in this combo
108    #[must_use]
109    pub fn leg_count(&self) -> usize {
110        self.legs.len()
111    }
112}
113
114/// Trade input for creating a combo
115///
116/// Used as input to the `create_combo` endpoint to specify
117/// the instruments and directions for each leg.
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119pub struct ComboTrade {
120    /// Instrument name for this trade
121    pub instrument_name: String,
122    /// Trade amount (optional). For perpetual and inverse futures the amount
123    /// is in USD units. For options and linear futures it is the underlying
124    /// base currency coin.
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub amount: Option<f64>,
127    /// Direction of trade from the maker perspective: "buy" or "sell"
128    pub direction: String,
129}
130
131impl ComboTrade {
132    /// Creates a new combo trade
133    ///
134    /// # Arguments
135    ///
136    /// * `instrument_name` - The instrument identifier
137    /// * `direction` - Trade direction ("buy" or "sell")
138    /// * `amount` - Optional trade amount
139    #[must_use]
140    pub fn new(
141        instrument_name: impl Into<String>,
142        direction: impl Into<String>,
143        amount: Option<f64>,
144    ) -> Self {
145        Self {
146            instrument_name: instrument_name.into(),
147            direction: direction.into(),
148            amount,
149        }
150    }
151
152    /// Creates a buy trade
153    #[must_use]
154    pub fn buy(instrument_name: impl Into<String>, amount: Option<f64>) -> Self {
155        Self::new(instrument_name, "buy", amount)
156    }
157
158    /// Creates a sell trade
159    #[must_use]
160    pub fn sell(instrument_name: impl Into<String>, amount: Option<f64>) -> Self {
161        Self::new(instrument_name, "sell", amount)
162    }
163}
164
165/// Leg input for `get_leg_prices` endpoint
166///
167/// Specifies the parameters for calculating individual leg prices.
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169pub struct LegInput {
170    /// Instrument name for this leg
171    pub instrument_name: String,
172    /// Trade amount. For perpetual and inverse futures the amount is in USD
173    /// units. For options and linear futures it is the underlying base
174    /// currency coin.
175    pub amount: f64,
176    /// Direction of the leg: "buy" or "sell"
177    pub direction: String,
178}
179
180impl LegInput {
181    /// Creates a new leg input
182    ///
183    /// # Arguments
184    ///
185    /// * `instrument_name` - The instrument identifier
186    /// * `amount` - Trade amount
187    /// * `direction` - Trade direction ("buy" or "sell")
188    #[must_use]
189    pub fn new(
190        instrument_name: impl Into<String>,
191        amount: f64,
192        direction: impl Into<String>,
193    ) -> Self {
194        Self {
195            instrument_name: instrument_name.into(),
196            amount,
197            direction: direction.into(),
198        }
199    }
200
201    /// Creates a buy leg input
202    #[must_use]
203    pub fn buy(instrument_name: impl Into<String>, amount: f64) -> Self {
204        Self::new(instrument_name, amount, "buy")
205    }
206
207    /// Creates a sell leg input
208    #[must_use]
209    pub fn sell(instrument_name: impl Into<String>, amount: f64) -> Self {
210        Self::new(instrument_name, amount, "sell")
211    }
212}
213
214/// Individual leg price in response
215///
216/// Contains the calculated price and ratio for a single leg.
217#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
218pub struct LegPrice {
219    /// Instrument name for this leg
220    pub instrument_name: String,
221    /// Direction: "buy" or "sell"
222    pub direction: String,
223    /// Calculated price for this leg
224    pub price: f64,
225    /// Ratio of amount between legs
226    pub ratio: i64,
227}
228
229/// Response from `get_leg_prices` endpoint
230///
231/// Contains the calculated leg prices for a combo structure.
232#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
233pub struct LegPricesResponse {
234    /// This value multiplied by the ratio of a leg gives trade size on that leg
235    pub amount: f64,
236    /// List of leg prices
237    pub legs: Vec<LegPrice>,
238}
239
240impl LegPricesResponse {
241    /// Returns the number of legs in the response
242    #[must_use]
243    pub fn leg_count(&self) -> usize {
244        self.legs.len()
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn test_combo_state_serialization() {
254        assert_eq!(
255            serde_json::to_string(&ComboState::Active).unwrap(),
256            "\"active\""
257        );
258        assert_eq!(serde_json::to_string(&ComboState::Rfq).unwrap(), "\"rfq\"");
259        assert_eq!(
260            serde_json::to_string(&ComboState::Inactive).unwrap(),
261            "\"inactive\""
262        );
263    }
264
265    #[test]
266    fn test_combo_state_deserialization() {
267        assert_eq!(
268            serde_json::from_str::<ComboState>("\"active\"").unwrap(),
269            ComboState::Active
270        );
271        assert_eq!(
272            serde_json::from_str::<ComboState>("\"rfq\"").unwrap(),
273            ComboState::Rfq
274        );
275        assert_eq!(
276            serde_json::from_str::<ComboState>("\"inactive\"").unwrap(),
277            ComboState::Inactive
278        );
279    }
280
281    #[test]
282    fn test_combo_leg_new() {
283        let leg = ComboLeg::new("BTC-PERPETUAL", -1);
284        assert_eq!(leg.instrument_name, "BTC-PERPETUAL");
285        assert_eq!(leg.amount, -1);
286        assert!(leg.is_opposite_direction());
287    }
288
289    #[test]
290    fn test_combo_leg_positive_amount() {
291        let leg = ComboLeg::new("BTC-29APR22", 1);
292        assert!(!leg.is_opposite_direction());
293    }
294
295    #[test]
296    fn test_combo_trade_buy() {
297        let trade = ComboTrade::buy("BTC-29APR22-37500-C", Some(1.0));
298        assert_eq!(trade.instrument_name, "BTC-29APR22-37500-C");
299        assert_eq!(trade.direction, "buy");
300        assert_eq!(trade.amount, Some(1.0));
301    }
302
303    #[test]
304    fn test_combo_trade_sell() {
305        let trade = ComboTrade::sell("BTC-29APR22-37500-P", None);
306        assert_eq!(trade.direction, "sell");
307        assert!(trade.amount.is_none());
308    }
309
310    #[test]
311    fn test_leg_input_new() {
312        let leg = LegInput::new("BTC-1NOV24-67000-C", 2.0, "buy");
313        assert_eq!(leg.instrument_name, "BTC-1NOV24-67000-C");
314        assert_eq!(leg.amount, 2.0);
315        assert_eq!(leg.direction, "buy");
316    }
317
318    #[test]
319    fn test_combo_deserialization() {
320        let json = r#"{
321            "state_timestamp": 1650960943922,
322            "state": "rfq",
323            "legs": [
324                {"instrument_name": "BTC-29APR22-37500-C", "amount": 1},
325                {"instrument_name": "BTC-29APR22-37500-P", "amount": -1}
326            ],
327            "id": "BTC-REV-29APR22-37500",
328            "instrument_id": 52,
329            "creation_timestamp": 1650960943000
330        }"#;
331
332        let combo: Combo = serde_json::from_str(json).unwrap();
333        assert_eq!(combo.id, "BTC-REV-29APR22-37500");
334        assert_eq!(combo.instrument_id, 52);
335        assert_eq!(combo.state, ComboState::Rfq);
336        assert!(combo.is_rfq());
337        assert!(!combo.is_active());
338        assert_eq!(combo.leg_count(), 2);
339        assert_eq!(combo.legs[0].instrument_name, "BTC-29APR22-37500-C");
340        assert_eq!(combo.legs[0].amount, 1);
341        assert_eq!(combo.legs[1].amount, -1);
342    }
343
344    #[test]
345    fn test_leg_prices_response_deserialization() {
346        let json = r#"{
347            "legs": [
348                {"ratio": 1, "instrument_name": "BTC-1NOV24-67000-C", "price": 0.6001, "direction": "buy"},
349                {"ratio": 1, "instrument_name": "BTC-1NOV24-66000-C", "price": 0.0001, "direction": "sell"}
350            ],
351            "amount": 2
352        }"#;
353
354        let response: LegPricesResponse = serde_json::from_str(json).unwrap();
355        assert_eq!(response.amount, 2.0);
356        assert_eq!(response.leg_count(), 2);
357        assert_eq!(response.legs[0].price, 0.6001);
358        assert_eq!(response.legs[1].direction, "sell");
359    }
360
361    #[test]
362    fn test_combo_trade_serialization() {
363        let trade = ComboTrade::new("BTC-29APR22-37500-C", "buy", Some(1.0));
364        let json = serde_json::to_string(&trade).unwrap();
365        assert!(json.contains("\"instrument_name\":\"BTC-29APR22-37500-C\""));
366        assert!(json.contains("\"direction\":\"buy\""));
367        assert!(json.contains("\"amount\":1.0"));
368    }
369
370    #[test]
371    fn test_combo_trade_without_amount() {
372        let trade = ComboTrade::new("BTC-29APR22-37500-C", "buy", None);
373        let json = serde_json::to_string(&trade).unwrap();
374        assert!(!json.contains("amount"));
375    }
376}