Skip to main content

deribit_websocket/model/
quote.rs

1//! Quote and Mass Quote model definitions for Deribit WebSocket API
2
3use pretty_simple_display::{DebugPretty, DisplaySimple};
4use serde::{Deserialize, Serialize};
5
6/// Represents a single quote in a mass quote request
7#[derive(Clone, Serialize, Deserialize, PartialEq, DebugPretty, DisplaySimple)]
8pub struct Quote {
9    /// Instrument name (e.g., "BTC-PERPETUAL")
10    pub instrument_name: String,
11    /// Quote side: "buy" or "sell"
12    pub side: String,
13    /// Quote amount (positive number)
14    pub amount: f64,
15    /// Quote price
16    pub price: f64,
17    /// Optional quote set ID for grouping quotes
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub quote_set_id: Option<String>,
20    /// Optional post-only flag
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub post_only: Option<bool>,
23    /// Optional time in force
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub time_in_force: Option<String>,
26}
27
28/// Mass quote request parameters
29#[derive(Clone, Serialize, Deserialize, DebugPretty, DisplaySimple)]
30pub struct MassQuoteRequest {
31    /// MMP group name for this mass quote
32    pub mmp_group: String,
33    /// List of quotes to place
34    pub quotes: Vec<Quote>,
35    /// User-defined quote ID for tracking
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub quote_id: Option<String>,
38    /// Whether to return detailed error information
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub detailed: Option<bool>,
41}
42
43/// Mass quote response
44#[derive(Clone, Serialize, Deserialize, DebugPretty, DisplaySimple)]
45pub struct MassQuoteResult {
46    /// Number of successful quotes placed
47    pub success_count: u32,
48    /// Number of failed quotes
49    pub error_count: u32,
50    /// Detailed error information (if requested)
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub errors: Option<Vec<QuoteError>>,
53}
54
55/// Quote error information
56#[derive(Clone, Serialize, Deserialize, DebugPretty, DisplaySimple)]
57pub struct QuoteError {
58    /// Instrument name that failed
59    pub instrument_name: String,
60    /// Side that failed
61    pub side: String,
62    /// Error code
63    pub error_code: i32,
64    /// Error message
65    pub error_message: String,
66}
67
68/// Quote cancellation request parameters
69#[derive(Clone, Serialize, Deserialize, DebugPretty, DisplaySimple)]
70pub struct CancelQuotesRequest {
71    /// Optional currency to filter cancellations (e.g., "BTC")
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub currency: Option<String>,
74    /// Optional instrument kind filter
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub kind: Option<String>,
77    /// Optional specific instrument name
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub instrument_name: Option<String>,
80    /// Optional quote set ID to cancel
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub quote_set_id: Option<String>,
83    /// Optional delta range for options (min, max)
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub delta_range: Option<(f64, f64)>,
86}
87
88/// Quote cancellation response
89#[derive(Clone, Serialize, Deserialize, DebugPretty, DisplaySimple)]
90pub struct CancelQuotesResponse {
91    /// Number of quotes cancelled
92    pub cancelled_count: u32,
93}
94
95/// MMP (Market Maker Protection) group configuration
96#[derive(Clone, Serialize, Deserialize, DebugPretty, DisplaySimple)]
97pub struct MmpGroupConfig {
98    /// MMP group name (unique across account)
99    pub mmp_group: String,
100    /// Quantity limit for this group (max amount per quote)
101    pub quantity_limit: f64,
102    /// Delta limit (must be < quantity_limit)
103    pub delta_limit: f64,
104    /// Interval in milliseconds for MMP triggers
105    pub interval: u64,
106    /// Frozen time in milliseconds after MMP trigger
107    pub frozen_time: u64,
108    /// Whether the group is enabled
109    pub enabled: bool,
110}
111
112/// MMP group status information
113#[derive(Clone, Serialize, Deserialize, DebugPretty, DisplaySimple)]
114pub struct MmpGroupStatus {
115    /// MMP group name
116    pub mmp_group: String,
117    /// Current configuration
118    pub config: MmpGroupConfig,
119    /// Reserved initial margin for this group
120    pub reserved_margin: f64,
121    /// Number of active quotes in this group
122    pub active_quotes: u32,
123    /// Whether the group is currently frozen
124    pub is_frozen: bool,
125    /// Timestamp when freeze will end (if frozen)
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub freeze_end_time: Option<u64>,
128}
129
130/// Quote information from get_open_orders
131#[derive(Clone, Serialize, Deserialize, DebugPretty, DisplaySimple)]
132pub struct QuoteInfo {
133    /// Quote ID
134    pub quote_id: String,
135    /// Instrument name
136    pub instrument_name: String,
137    /// Quote side
138    pub side: String,
139    /// Quote amount
140    pub amount: f64,
141    /// Quote price
142    pub price: f64,
143    /// Quote set ID (if any)
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub quote_set_id: Option<String>,
146    /// MMP group name
147    pub mmp_group: String,
148    /// Quote creation timestamp
149    pub creation_timestamp: u64,
150    /// Quote state (e.g., "open", "filled", "cancelled")
151    pub state: String,
152    /// Filled amount
153    pub filled_amount: f64,
154    /// Average fill price
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub average_price: Option<f64>,
157    /// Quote priority in order book
158    pub priority: u64,
159}
160
161/// MMP trigger notification
162#[derive(Clone, Serialize, Deserialize, DebugPretty, DisplaySimple)]
163pub struct MmpTrigger {
164    /// Currency that triggered MMP
165    pub currency: String,
166    /// MMP group that was triggered (if specific)
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub mmp_group: Option<String>,
169    /// Trigger timestamp
170    pub timestamp: u64,
171    /// Trigger reason
172    pub reason: String,
173    /// Duration of freeze in milliseconds
174    pub frozen_time: u64,
175}
176
177impl Quote {
178    /// Create a new buy quote
179    pub fn buy(instrument_name: String, amount: f64, price: f64) -> Self {
180        Self {
181            instrument_name,
182            side: "buy".to_string(),
183            amount,
184            price,
185            quote_set_id: None,
186            post_only: None,
187            time_in_force: None,
188        }
189    }
190
191    /// Create a new sell quote
192    pub fn sell(instrument_name: String, amount: f64, price: f64) -> Self {
193        Self {
194            instrument_name,
195            side: "sell".to_string(),
196            amount,
197            price,
198            quote_set_id: None,
199            post_only: None,
200            time_in_force: None,
201        }
202    }
203
204    /// Set quote set ID for this quote
205    pub fn with_quote_set_id(mut self, quote_set_id: String) -> Self {
206        self.quote_set_id = Some(quote_set_id);
207        self
208    }
209
210    /// Set post-only flag for this quote
211    pub fn with_post_only(mut self, post_only: bool) -> Self {
212        self.post_only = Some(post_only);
213        self
214    }
215
216    /// Set time in force for this quote
217    pub fn with_time_in_force(mut self, time_in_force: String) -> Self {
218        self.time_in_force = Some(time_in_force);
219        self
220    }
221}
222
223impl MassQuoteRequest {
224    /// Create a new mass quote request
225    pub fn new(mmp_group: String, quotes: Vec<Quote>) -> Self {
226        Self {
227            mmp_group,
228            quotes,
229            quote_id: None,
230            detailed: None,
231        }
232    }
233
234    /// Set quote ID for tracking
235    pub fn with_quote_id(mut self, quote_id: String) -> Self {
236        self.quote_id = Some(quote_id);
237        self
238    }
239
240    /// Request detailed error information
241    pub fn with_detailed_errors(mut self) -> Self {
242        self.detailed = Some(true);
243        self
244    }
245
246    /// Validate the mass quote request
247    pub fn validate(&self) -> Result<(), String> {
248        if self.quotes.is_empty() {
249            return Err("Mass quote request must contain at least one quote".to_string());
250        }
251
252        if self.quotes.len() > 100 {
253            return Err("Mass quote request cannot contain more than 100 quotes".to_string());
254        }
255
256        // Check that all quotes are for the same index (currency pair)
257        let mut currencies = std::collections::HashSet::new();
258        for quote in &self.quotes {
259            let currency = quote
260                .instrument_name
261                .split('-')
262                .next()
263                .ok_or("Invalid instrument name format")?;
264            currencies.insert(currency);
265        }
266
267        if currencies.len() > 1 {
268            return Err(
269                "All quotes in a mass quote request must be for the same currency".to_string(),
270            );
271        }
272
273        // Check for duplicate quotes (same instrument, side, and price)
274        let mut seen = std::collections::HashSet::new();
275        for quote in &self.quotes {
276            let key = (&quote.instrument_name, &quote.side, quote.price as u64);
277            if !seen.insert(key) {
278                return Err(format!(
279                    "Duplicate quote found for {} {} at price {}",
280                    quote.instrument_name, quote.side, quote.price
281                ));
282            }
283        }
284
285        Ok(())
286    }
287}
288
289impl CancelQuotesRequest {
290    /// Create a request to cancel all quotes
291    pub fn all() -> Self {
292        Self {
293            currency: None,
294            kind: None,
295            instrument_name: None,
296            quote_set_id: None,
297            delta_range: None,
298        }
299    }
300
301    /// Create a request to cancel quotes by currency
302    pub fn by_currency(currency: String) -> Self {
303        Self {
304            currency: Some(currency),
305            kind: None,
306            instrument_name: None,
307            quote_set_id: None,
308            delta_range: None,
309        }
310    }
311
312    /// Create a request to cancel quotes by instrument
313    pub fn by_instrument(instrument_name: String) -> Self {
314        Self {
315            currency: None,
316            kind: None,
317            instrument_name: Some(instrument_name),
318            quote_set_id: None,
319            delta_range: None,
320        }
321    }
322
323    /// Create a request to cancel quotes by quote set ID
324    pub fn by_quote_set_id(quote_set_id: String) -> Self {
325        Self {
326            currency: None,
327            kind: None,
328            instrument_name: None,
329            quote_set_id: Some(quote_set_id),
330            delta_range: None,
331        }
332    }
333
334    /// Create a request to cancel quotes by delta range (options only)
335    pub fn by_delta_range(min_delta: f64, max_delta: f64) -> Self {
336        Self {
337            currency: None,
338            kind: None,
339            instrument_name: None,
340            quote_set_id: None,
341            delta_range: Some((min_delta, max_delta)),
342        }
343    }
344}
345
346impl MmpGroupConfig {
347    /// Create a new MMP group configuration
348    pub fn new(
349        mmp_group: String,
350        quantity_limit: f64,
351        delta_limit: f64,
352        interval: u64,
353        frozen_time: u64,
354    ) -> Result<Self, String> {
355        if delta_limit >= quantity_limit {
356            return Err("Delta limit must be less than quantity limit".to_string());
357        }
358
359        // Check quantity limits (500 BTC, 5000 ETH equivalent)
360        let currency = mmp_group.split('_').next().unwrap_or("");
361        let max_limit = match currency.to_uppercase().as_str() {
362            "BTC" => 500.0,
363            "ETH" => 5000.0,
364            _ => 500.0, // Default to BTC limit
365        };
366
367        if quantity_limit > max_limit {
368            return Err(format!(
369                "Quantity limit {} exceeds maximum allowed {} for {}",
370                quantity_limit, max_limit, currency
371            ));
372        }
373
374        Ok(Self {
375            mmp_group,
376            quantity_limit,
377            delta_limit,
378            interval,
379            frozen_time,
380            enabled: true,
381        })
382    }
383
384    /// Disable the MMP group (sets interval to 0)
385    pub fn disable(mut self) -> Self {
386        self.interval = 0;
387        self.enabled = false;
388        self
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn test_quote_creation() {
398        let quote = Quote::buy("BTC-PERPETUAL".to_string(), 1.0, 50000.0)
399            .with_quote_set_id("set1".to_string())
400            .with_post_only(true);
401
402        assert_eq!(quote.instrument_name, "BTC-PERPETUAL");
403        assert_eq!(quote.side, "buy");
404        assert_eq!(quote.amount, 1.0);
405        assert_eq!(quote.price, 50000.0);
406        assert_eq!(quote.quote_set_id, Some("set1".to_string()));
407        assert_eq!(quote.post_only, Some(true));
408    }
409
410    #[test]
411    fn test_mass_quote_validation() {
412        let quotes = vec![
413            Quote::buy("BTC-PERPETUAL".to_string(), 1.0, 50000.0),
414            Quote::sell("BTC-PERPETUAL".to_string(), 1.0, 51000.0),
415        ];
416
417        let request = MassQuoteRequest::new("btc_group".to_string(), quotes);
418        assert!(request.validate().is_ok());
419    }
420
421    #[test]
422    fn test_mass_quote_validation_different_currencies() {
423        let quotes = vec![
424            Quote::buy("BTC-PERPETUAL".to_string(), 1.0, 50000.0),
425            Quote::sell("ETH-PERPETUAL".to_string(), 1.0, 3000.0),
426        ];
427
428        let request = MassQuoteRequest::new("mixed_group".to_string(), quotes);
429        assert!(request.validate().is_err());
430    }
431
432    #[test]
433    fn test_mass_quote_validation_duplicate_quotes() {
434        let quotes = vec![
435            Quote::buy("BTC-PERPETUAL".to_string(), 1.0, 50000.0),
436            Quote::buy("BTC-PERPETUAL".to_string(), 2.0, 50000.0), // Same price to trigger duplicate detection
437        ];
438
439        let request = MassQuoteRequest::new("btc_group".to_string(), quotes);
440        assert!(request.validate().is_err());
441    }
442
443    #[test]
444    fn test_mmp_group_config_validation() {
445        let config = MmpGroupConfig::new("btc_group".to_string(), 100.0, 50.0, 1000, 5000);
446        assert!(config.is_ok());
447
448        let invalid_config = MmpGroupConfig::new(
449            "btc_group".to_string(),
450            50.0,
451            100.0, // Delta limit > quantity limit
452            1000,
453            5000,
454        );
455        assert!(invalid_config.is_err());
456    }
457
458    #[test]
459    fn test_cancel_quotes_builders() {
460        let cancel_all = CancelQuotesRequest::all();
461        assert!(cancel_all.currency.is_none());
462
463        let cancel_btc = CancelQuotesRequest::by_currency("BTC".to_string());
464        assert_eq!(cancel_btc.currency, Some("BTC".to_string()));
465
466        let cancel_instrument = CancelQuotesRequest::by_instrument("BTC-PERPETUAL".to_string());
467        assert_eq!(
468            cancel_instrument.instrument_name,
469            Some("BTC-PERPETUAL".to_string())
470        );
471
472        let cancel_set = CancelQuotesRequest::by_quote_set_id("set1".to_string());
473        assert_eq!(cancel_set.quote_set_id, Some("set1".to_string()));
474
475        let cancel_delta = CancelQuotesRequest::by_delta_range(0.3, 0.7);
476        assert_eq!(cancel_delta.delta_range, Some((0.3, 0.7)));
477    }
478}