deribit_fix/message/orders/
execution_report.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 10/8/25
5******************************************************************************/
6
7//! Execution Report FIX Message Implementation
8
9use super::*;
10use crate::error::Result as DeribitFixResult;
11use crate::message::builder::MessageBuilder;
12use crate::model::types::{ExecType, MsgType};
13use chrono::{DateTime, Utc};
14use deribit_base::{impl_json_debug_pretty, impl_json_display};
15use serde::{Deserialize, Serialize};
16
17/// Execution Report message (MsgType = '8')
18#[derive(Clone, PartialEq, Serialize, Deserialize)]
19pub struct ExecutionReport {
20    /// Order ID
21    pub order_id: String,
22    /// Client order ID
23    pub cl_ord_id: String,
24    /// Original client order ID (for replace/cancel operations)
25    pub orig_cl_ord_id: Option<String>,
26    /// Execution ID
27    pub exec_id: String,
28    /// Execution type
29    pub exec_type: ExecType,
30    /// Order status
31    pub ord_status: OrderStatus,
32    /// Instrument symbol
33    pub symbol: String,
34    /// Side of order
35    pub side: OrderSide,
36    /// Quantity open for further execution
37    pub leaves_qty: f64,
38    /// Total quantity filled
39    pub cum_qty: f64,
40    /// Average price of all fills on this order
41    pub avg_px: Option<f64>,
42    /// Price of this fill
43    pub last_px: Option<f64>,
44    /// Quantity of shares bought/sold on this fill
45    pub last_qty: Option<f64>,
46    /// Order quantity
47    pub order_qty: f64,
48    /// Price
49    pub price: Option<f64>,
50    /// Transaction time
51    pub transact_time: DateTime<Utc>,
52    /// Text
53    pub text: Option<String>,
54    /// Order reject reason (if applicable)
55    pub ord_rej_reason: Option<OrderRejectReason>,
56    /// Custom label
57    pub deribit_label: Option<String>,
58    /// Secondary execution ID
59    pub secondary_exec_id: Option<String>,
60    /// Order type
61    pub ord_type: Option<OrderType>,
62    /// Commission (deprecated, always 0)
63    pub commission: Option<f64>,
64    /// Security exchange
65    pub security_exchange: Option<String>,
66    /// Quantity type
67    pub qty_type: Option<QuantityType>,
68    /// Contract multiplier
69    pub contract_multiplier: Option<f64>,
70    /// Display quantity
71    pub display_qty: Option<f64>,
72    /// Advanced order type for options
73    pub deribit_adv_order_type: Option<char>,
74    /// Volatility for implied volatility orders
75    pub volatility: Option<f64>,
76    /// Fixed USD price for USD orders
77    pub pegged_price: Option<f64>,
78    /// Trade match ID
79    pub trd_match_id: Option<String>,
80    /// Market Maker Protection flag
81    pub deribit_mm_protection: Option<bool>,
82    /// MMP Group
83    pub mmp_group: Option<String>,
84    /// Quote Set ID (for orders from Mass Quote)
85    pub quote_set_id: Option<String>,
86    /// Quote ID (for orders from Mass Quote)
87    pub quote_id: Option<String>,
88    /// Quote Entry ID (for orders from Mass Quote)
89    pub quote_entry_id: Option<String>,
90    /// Execution instruction
91    pub exec_inst: Option<String>,
92    /// Stop price
93    pub stop_px: Option<f64>,
94    /// Condition trigger method
95    pub condition_trigger_method: Option<i32>,
96    /// Last liquidity indicator (1=Added Liquidity, 2=Removed Liquidity)
97    pub last_liquidity_ind: Option<i32>,
98}
99
100impl ExecutionReport {
101    /// Create a new execution report for a new order
102    #[allow(clippy::too_many_arguments)]
103    pub fn new_order(
104        order_id: String,
105        cl_ord_id: String,
106        exec_id: String,
107        symbol: String,
108        side: OrderSide,
109        order_qty: f64,
110        leaves_qty: f64,
111        price: Option<f64>,
112    ) -> Self {
113        Self {
114            order_id,
115            cl_ord_id,
116            orig_cl_ord_id: None,
117            exec_id,
118            exec_type: ExecType::New,
119            ord_status: OrderStatus::New,
120            symbol,
121            side,
122            leaves_qty,
123            cum_qty: 0.0,
124            avg_px: None,
125            last_px: None,
126            last_qty: None,
127            order_qty,
128            price,
129            transact_time: Utc::now(),
130            text: None,
131            ord_rej_reason: None,
132            deribit_label: None,
133            secondary_exec_id: None,
134            ord_type: None,
135            commission: None,
136            security_exchange: None,
137            qty_type: None,
138            contract_multiplier: None,
139            display_qty: None,
140            deribit_adv_order_type: None,
141            volatility: None,
142            pegged_price: None,
143            trd_match_id: None,
144            deribit_mm_protection: None,
145            mmp_group: None,
146            quote_set_id: None,
147            quote_id: None,
148            quote_entry_id: None,
149            exec_inst: None,
150            stop_px: None,
151            condition_trigger_method: None,
152            last_liquidity_ind: None,
153        }
154    }
155
156    /// Create a fill execution report
157    #[allow(clippy::too_many_arguments)]
158    pub fn fill(
159        order_id: String,
160        cl_ord_id: String,
161        exec_id: String,
162        symbol: String,
163        side: OrderSide,
164        order_qty: f64,
165        leaves_qty: f64,
166        cum_qty: f64,
167        last_px: f64,
168        last_qty: f64,
169        avg_px: f64,
170    ) -> Self {
171        Self {
172            order_id,
173            cl_ord_id,
174            orig_cl_ord_id: None,
175            exec_id,
176            exec_type: ExecType::Trade,
177            ord_status: if leaves_qty > 0.0 {
178                OrderStatus::PartiallyFilled
179            } else {
180                OrderStatus::Filled
181            },
182            symbol,
183            side,
184            leaves_qty,
185            cum_qty,
186            avg_px: Some(avg_px),
187            last_px: Some(last_px),
188            last_qty: Some(last_qty),
189            order_qty,
190            price: Some(last_px),
191            transact_time: Utc::now(),
192            text: None,
193            ord_rej_reason: None,
194            deribit_label: None,
195            secondary_exec_id: None,
196            ord_type: None,
197            commission: None,
198            security_exchange: None,
199            qty_type: None,
200            contract_multiplier: None,
201            display_qty: None,
202            deribit_adv_order_type: None,
203            volatility: None,
204            pegged_price: None,
205            trd_match_id: None,
206            deribit_mm_protection: None,
207            mmp_group: None,
208            quote_set_id: None,
209            quote_id: None,
210            quote_entry_id: None,
211            exec_inst: None,
212            stop_px: None,
213            condition_trigger_method: None,
214            last_liquidity_ind: None,
215        }
216    }
217
218    /// Create a rejection execution report
219    pub fn reject(
220        cl_ord_id: String,
221        symbol: String,
222        side: OrderSide,
223        order_qty: f64,
224        reason: OrderRejectReason,
225        text: Option<String>,
226    ) -> Self {
227        Self {
228            order_id: String::new(),
229            cl_ord_id,
230            orig_cl_ord_id: None,
231            exec_id: format!("REJ{}", Utc::now().timestamp_millis()),
232            exec_type: ExecType::Rejected,
233            ord_status: OrderStatus::Rejected,
234            symbol,
235            side,
236            leaves_qty: 0.0,
237            cum_qty: 0.0,
238            avg_px: None,
239            last_px: None,
240            last_qty: None,
241            order_qty,
242            price: None,
243            transact_time: Utc::now(),
244            text,
245            ord_rej_reason: Some(reason),
246            deribit_label: None,
247            secondary_exec_id: None,
248            ord_type: None,
249            commission: None,
250            security_exchange: None,
251            qty_type: None,
252            contract_multiplier: None,
253            display_qty: None,
254            deribit_adv_order_type: None,
255            volatility: None,
256            pegged_price: None,
257            trd_match_id: None,
258            deribit_mm_protection: None,
259            mmp_group: None,
260            quote_set_id: None,
261            quote_id: None,
262            quote_entry_id: None,
263            exec_inst: None,
264            stop_px: None,
265            condition_trigger_method: None,
266            last_liquidity_ind: None,
267        }
268    }
269
270    /// Set custom label
271    pub fn with_label(mut self, label: String) -> Self {
272        self.deribit_label = Some(label);
273        self
274    }
275
276    /// Convert to FIX message
277    pub fn to_fix_message(
278        &self,
279        sender_comp_id: &str,
280        target_comp_id: &str,
281        msg_seq_num: u32,
282    ) -> DeribitFixResult<String> {
283        let mut builder = MessageBuilder::new()
284            .msg_type(MsgType::ExecutionReport)
285            .sender_comp_id(sender_comp_id.to_string())
286            .target_comp_id(target_comp_id.to_string())
287            .msg_seq_num(msg_seq_num)
288            .sending_time(Utc::now());
289
290        // Required fields
291        builder = builder
292            .field(37, self.order_id.clone()) // OrderID
293            .field(11, self.cl_ord_id.clone()) // ClOrdID
294            .field(17, self.exec_id.clone()) // ExecID
295            .field(150, char::from(self.exec_type).to_string()) // ExecType
296            .field(39, char::from(self.ord_status).to_string()) // OrdStatus
297            .field(55, self.symbol.clone()) // Symbol
298            .field(54, char::from(self.side).to_string()) // Side
299            .field(151, self.leaves_qty.to_string()) // LeavesQty
300            .field(14, self.cum_qty.to_string()) // CumQty
301            .field(38, self.order_qty.to_string()) // OrderQty
302            .field(
303                60,
304                self.transact_time.format("%Y%m%d-%H:%M:%S%.3f").to_string(),
305            ); // TransactTime
306
307        // Optional fields
308        if let Some(orig_cl_ord_id) = &self.orig_cl_ord_id {
309            builder = builder.field(41, orig_cl_ord_id.clone());
310        }
311
312        if let Some(avg_px) = &self.avg_px {
313            builder = builder.field(6, avg_px.to_string());
314        }
315
316        if let Some(last_px) = &self.last_px {
317            builder = builder.field(31, last_px.to_string());
318        }
319
320        if let Some(last_qty) = &self.last_qty {
321            builder = builder.field(32, last_qty.to_string());
322        }
323
324        if let Some(price) = &self.price {
325            builder = builder.field(44, price.to_string());
326        }
327
328        if let Some(text) = &self.text {
329            builder = builder.field(58, text.clone());
330        }
331
332        if let Some(reason) = &self.ord_rej_reason {
333            builder = builder.field(103, i32::from(*reason).to_string());
334        }
335
336        if let Some(deribit_label) = &self.deribit_label {
337            builder = builder.field(100010, deribit_label.clone());
338        }
339
340        // Additional optional fields from specification
341        if let Some(secondary_exec_id) = &self.secondary_exec_id {
342            builder = builder.field(527, secondary_exec_id.clone());
343        }
344
345        if let Some(ord_type) = &self.ord_type {
346            builder = builder.field(40, char::from(*ord_type).to_string());
347        }
348
349        if let Some(commission) = &self.commission {
350            builder = builder.field(12, commission.to_string());
351        }
352
353        if let Some(security_exchange) = &self.security_exchange {
354            builder = builder.field(207, security_exchange.clone());
355        }
356
357        if let Some(qty_type) = &self.qty_type {
358            builder = builder.field(854, i32::from(*qty_type).to_string());
359        }
360
361        if let Some(contract_multiplier) = &self.contract_multiplier {
362            builder = builder.field(231, contract_multiplier.to_string());
363        }
364
365        if let Some(display_qty) = &self.display_qty {
366            builder = builder.field(1138, display_qty.to_string());
367        }
368
369        if let Some(deribit_adv_order_type) = &self.deribit_adv_order_type {
370            builder = builder.field(100012, deribit_adv_order_type.to_string());
371        }
372
373        if let Some(volatility) = &self.volatility {
374            builder = builder.field(1188, volatility.to_string());
375        }
376
377        if let Some(pegged_price) = &self.pegged_price {
378            builder = builder.field(839, pegged_price.to_string());
379        }
380
381        if let Some(trd_match_id) = &self.trd_match_id {
382            builder = builder.field(880, trd_match_id.clone());
383        }
384
385        if let Some(deribit_mm_protection) = &self.deribit_mm_protection {
386            builder = builder.field(
387                9008,
388                if *deribit_mm_protection { "Y" } else { "N" }.to_string(),
389            );
390        }
391
392        if let Some(mmp_group) = &self.mmp_group {
393            builder = builder.field(9019, mmp_group.clone());
394        }
395
396        if let Some(quote_set_id) = &self.quote_set_id {
397            builder = builder.field(302, quote_set_id.clone());
398        }
399
400        if let Some(quote_id) = &self.quote_id {
401            builder = builder.field(117, quote_id.clone());
402        }
403
404        if let Some(quote_entry_id) = &self.quote_entry_id {
405            builder = builder.field(299, quote_entry_id.clone());
406        }
407
408        if let Some(exec_inst) = &self.exec_inst {
409            builder = builder.field(18, exec_inst.clone());
410        }
411
412        if let Some(stop_px) = &self.stop_px {
413            builder = builder.field(99, stop_px.to_string());
414        }
415
416        if let Some(condition_trigger_method) = &self.condition_trigger_method {
417            builder = builder.field(5127, condition_trigger_method.to_string());
418        }
419
420        if let Some(last_liquidity_ind) = &self.last_liquidity_ind {
421            builder = builder.field(851, last_liquidity_ind.to_string());
422        }
423
424        Ok(builder.build()?.to_string())
425    }
426}
427
428impl_json_display!(ExecutionReport);
429impl_json_debug_pretty!(ExecutionReport);
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn test_execution_report_new_order() {
437        let report = ExecutionReport::new_order(
438            "ORD123".to_string(),
439            "CLORD123".to_string(),
440            "EXEC123".to_string(),
441            "BTC-PERPETUAL".to_string(),
442            OrderSide::Buy,
443            10.0,
444            10.0,
445            Some(50000.0),
446        );
447
448        assert_eq!(report.order_id, "ORD123");
449        assert_eq!(report.cl_ord_id, "CLORD123");
450        assert_eq!(report.exec_type, ExecType::New);
451        assert_eq!(report.ord_status, OrderStatus::New);
452        assert_eq!(report.symbol, "BTC-PERPETUAL");
453        assert_eq!(report.side, OrderSide::Buy);
454        assert_eq!(report.order_qty, 10.0);
455        assert_eq!(report.leaves_qty, 10.0);
456        assert_eq!(report.cum_qty, 0.0);
457        assert_eq!(report.price, Some(50000.0));
458    }
459
460    #[test]
461    fn test_execution_report_fill() {
462        let report = ExecutionReport::fill(
463            "ORD123".to_string(),
464            "CLORD123".to_string(),
465            "EXEC123".to_string(),
466            "BTC-PERPETUAL".to_string(),
467            OrderSide::Buy,
468            10.0,
469            5.0,
470            5.0,
471            50000.0,
472            5.0,
473            50000.0,
474        );
475
476        assert_eq!(report.exec_type, ExecType::Trade);
477        assert_eq!(report.ord_status, OrderStatus::PartiallyFilled);
478        assert_eq!(report.cum_qty, 5.0);
479        assert_eq!(report.leaves_qty, 5.0);
480        assert_eq!(report.last_px, Some(50000.0));
481        assert_eq!(report.last_qty, Some(5.0));
482        assert_eq!(report.avg_px, Some(50000.0));
483    }
484
485    #[test]
486    fn test_execution_report_fill_complete() {
487        let report = ExecutionReport::fill(
488            "ORD123".to_string(),
489            "CLORD123".to_string(),
490            "EXEC123".to_string(),
491            "BTC-PERPETUAL".to_string(),
492            OrderSide::Sell,
493            10.0,
494            0.0, // No leaves qty means fully filled
495            10.0,
496            49500.0,
497            10.0,
498            49500.0,
499        );
500
501        assert_eq!(report.exec_type, ExecType::Trade);
502        assert_eq!(report.ord_status, OrderStatus::Filled);
503        assert_eq!(report.cum_qty, 10.0);
504        assert_eq!(report.leaves_qty, 0.0);
505    }
506
507    #[test]
508    fn test_execution_report_reject() {
509        let report = ExecutionReport::reject(
510            "CLORD123".to_string(),
511            "BTC-PERPETUAL".to_string(),
512            OrderSide::Buy,
513            10.0,
514            OrderRejectReason::OrderExceedsLimit,
515            Some("Insufficient margin".to_string()),
516        );
517
518        assert_eq!(report.exec_type, ExecType::Rejected);
519        assert_eq!(report.ord_status, OrderStatus::Rejected);
520        assert_eq!(
521            report.ord_rej_reason,
522            Some(OrderRejectReason::OrderExceedsLimit)
523        );
524        assert_eq!(report.text, Some("Insufficient margin".to_string()));
525        assert_eq!(report.leaves_qty, 0.0);
526        assert_eq!(report.cum_qty, 0.0);
527    }
528
529    #[test]
530    fn test_execution_report_with_label() {
531        let report = ExecutionReport::new_order(
532            "ORD123".to_string(),
533            "CLORD123".to_string(),
534            "EXEC123".to_string(),
535            "BTC-PERPETUAL".to_string(),
536            OrderSide::Buy,
537            10.0,
538            10.0,
539            Some(50000.0),
540        )
541        .with_label("my-strategy".to_string());
542
543        assert_eq!(report.deribit_label, Some("my-strategy".to_string()));
544    }
545
546    #[test]
547    fn test_execution_report_to_fix_message() {
548        let report = ExecutionReport::new_order(
549            "ORD123".to_string(),
550            "CLORD123".to_string(),
551            "EXEC123".to_string(),
552            "BTC-PERPETUAL".to_string(),
553            OrderSide::Buy,
554            10.0,
555            10.0,
556            Some(50000.0),
557        );
558
559        let fix_message = report.to_fix_message("SENDER", "TARGET", 1).unwrap();
560
561        // Check that the message contains required fields
562        assert!(fix_message.contains("35=8")); // MsgType
563        assert!(fix_message.contains("37=ORD123")); // OrderID
564        assert!(fix_message.contains("11=CLORD123")); // ClOrdID
565        assert!(fix_message.contains("17=EXEC123")); // ExecID
566        assert!(fix_message.contains("150=0")); // ExecType=New
567        assert!(fix_message.contains("39=0")); // OrdStatus=New
568        assert!(fix_message.contains("55=BTC-PERPETUAL")); // Symbol
569        assert!(fix_message.contains("54=1")); // Side=Buy
570        assert!(fix_message.contains("151=10")); // LeavesQty
571        assert!(fix_message.contains("14=0")); // CumQty
572        assert!(fix_message.contains("38=10")); // OrderQty
573        assert!(fix_message.contains("44=50000")); // Price
574    }
575}