Skip to main content

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