deribit_websocket/model/
quote.rs

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