Skip to main content

deribit_fix/message/
positions.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 12/8/25
5******************************************************************************/
6
7//! FIX Position messages implementation
8//!
9//! This module provides functionality for creating and parsing FIX position
10//! messages used in communication with Deribit, including:
11//! - RequestForPositions (MsgType = "AN")
12//! - PositionReport (MsgType = "AP")
13
14use crate::error::Result;
15use crate::error::{DeribitFixError, Result as DeribitFixResult};
16use crate::message::MessageBuilder;
17use crate::model::message::FixMessage;
18use crate::model::types::MsgType;
19
20use crate::model::position::{Direction, Position};
21use serde::{Deserialize, Serialize};
22
23/// Position request type enumeration
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25pub enum PosReqType {
26    /// Positions (0)
27    Positions,
28    /// Trades (1)
29    Trades,
30    /// Exercises (2)
31    Exercises,
32    /// Assignments (3)
33    Assignments,
34}
35
36impl From<PosReqType> for i32 {
37    fn from(value: PosReqType) -> Self {
38        match value {
39            PosReqType::Positions => 0,
40            PosReqType::Trades => 1,
41            PosReqType::Exercises => 2,
42            PosReqType::Assignments => 3,
43        }
44    }
45}
46
47impl TryFrom<i32> for PosReqType {
48    type Error = DeribitFixError;
49
50    fn try_from(value: i32) -> Result<Self> {
51        match value {
52            0 => Ok(PosReqType::Positions),
53            1 => Ok(PosReqType::Trades),
54            2 => Ok(PosReqType::Exercises),
55            3 => Ok(PosReqType::Assignments),
56            _ => Err(DeribitFixError::MessageParsing(format!(
57                "Invalid PosReqType: {}",
58                value
59            ))),
60        }
61    }
62}
63
64/// Subscription request type for positions
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66pub enum SubscriptionRequestType {
67    /// Snapshot (0)
68    Snapshot,
69    /// Snapshot + Updates (1)
70    SnapshotPlusUpdates,
71    /// Disable previous snapshot + updates (2)
72    DisablePreviousSnapshotPlusUpdates,
73}
74
75impl From<SubscriptionRequestType> for i32 {
76    fn from(value: SubscriptionRequestType) -> Self {
77        match value {
78            SubscriptionRequestType::Snapshot => 0,
79            SubscriptionRequestType::SnapshotPlusUpdates => 1,
80            SubscriptionRequestType::DisablePreviousSnapshotPlusUpdates => 2,
81        }
82    }
83}
84
85impl TryFrom<i32> for SubscriptionRequestType {
86    type Error = DeribitFixError;
87
88    fn try_from(value: i32) -> Result<Self> {
89        match value {
90            0 => Ok(SubscriptionRequestType::Snapshot),
91            1 => Ok(SubscriptionRequestType::SnapshotPlusUpdates),
92            2 => Ok(SubscriptionRequestType::DisablePreviousSnapshotPlusUpdates),
93            _ => Err(DeribitFixError::MessageParsing(format!(
94                "Invalid SubscriptionRequestType: {}",
95                value
96            ))),
97        }
98    }
99}
100
101/// Request For Positions message (MsgType = "AN")
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct RequestForPositions {
104    /// Position Request ID (710)
105    pub pos_req_id: String,
106    /// Position Request Type (724)
107    pub pos_req_type: PosReqType,
108    /// Subscription Request Type (263) - optional
109    pub subscription_request_type: Option<SubscriptionRequestType>,
110    /// Clearing Business Date (715) - optional
111    pub clearing_business_date: Option<String>,
112    /// Symbols filter - optional
113    pub symbols: Vec<String>,
114}
115
116impl RequestForPositions {
117    /// Create a new position request for all positions
118    pub fn all_positions(pos_req_id: String) -> Self {
119        Self {
120            pos_req_id,
121            pos_req_type: PosReqType::Positions,
122            subscription_request_type: Some(SubscriptionRequestType::Snapshot),
123            clearing_business_date: None,
124            symbols: Vec::new(),
125        }
126    }
127
128    /// Create a new position request with subscription for updates
129    pub fn positions_with_updates(pos_req_id: String) -> Self {
130        Self {
131            pos_req_id,
132            pos_req_type: PosReqType::Positions,
133            subscription_request_type: Some(SubscriptionRequestType::SnapshotPlusUpdates),
134            clearing_business_date: None,
135            symbols: Vec::new(),
136        }
137    }
138
139    /// Add symbols filter
140    pub fn with_symbols(mut self, symbols: Vec<String>) -> Self {
141        self.symbols = symbols;
142        self
143    }
144
145    /// Add clearing business date
146    pub fn with_clearing_date(mut self, date: String) -> Self {
147        self.clearing_business_date = Some(date);
148        self
149    }
150
151    /// Convert to FIX message
152    pub fn to_fix_message(
153        &self,
154        sender_comp_id: String,
155        target_comp_id: String,
156        msg_seq_num: u32,
157    ) -> DeribitFixResult<FixMessage> {
158        let mut builder = MessageBuilder::new()
159            .msg_type(MsgType::RequestForPositions)
160            .sender_comp_id(sender_comp_id)
161            .target_comp_id(target_comp_id)
162            .msg_seq_num(msg_seq_num)
163            .field(710, self.pos_req_id.clone()) // PosReqID
164            .field(724, i32::from(self.pos_req_type).to_string()); // PosReqType
165
166        // Add optional subscription request type
167        if let Some(subscription_type) = self.subscription_request_type {
168            builder = builder.field(263, i32::from(subscription_type).to_string());
169        }
170
171        // Add optional clearing business date
172        if let Some(ref date) = self.clearing_business_date {
173            builder = builder.field(715, date.clone());
174        }
175
176        // Add symbols if present
177        if !self.symbols.is_empty() {
178            builder = builder.field(146, self.symbols.len().to_string()); // NoRelatedSym
179            for symbol in &self.symbols {
180                builder = builder.field(55, symbol.clone()); // Symbol
181            }
182        }
183
184        builder.build()
185    }
186}
187
188/// Represents a FIX Position Report message (MsgType = AP).
189pub struct PositionReport;
190
191impl PositionReport {
192    /// Parse a Position from a FIX message (Position Report-like payload).
193    ///
194    /// This function extracts Deribit position information from a FixMessage by
195    /// reading standard FIX tags and Deribit extensions. It computes derived fields
196    /// such as net size and direction, and maps several optional numeric fields into
197    /// greeks and margin metrics.
198    ///
199    /// Behavior:
200    /// - Instrument name (tag 55) is mandatory; absence results in an error.
201    /// - LongQty (704) and ShortQty (705) are read and netted as `size = long - short`.
202    /// - `direction` is Buy if `size > 0.0`, otherwise Sell.
203    /// - Many numeric fields are optional; if missing or unparsable, they default to:
204    ///   - f64 options: None
205    ///   - Aggregated numeric values used directly (e.g., `average_price`) default to 0.0
206    /// - Settlement price (730) is reused as both `average_price` and `settlement_price`.
207    ///
208    /// Tag mapping:
209    /// - 55 (Symbol) -> Position.instrument_name
210    /// - 704 (LongQty) and 705 (ShortQty) -> Position.size (long - short)
211    /// - 730 (SettlPx) -> Position.average_price and Position.settlement_price
212    /// - 731 (IndexPx) -> Position.index_price
213    /// - 732 (MarkPx) -> Position.mark_price
214    /// - 898 (MaintenanceMargin) -> Position.maintenance_margin
215    /// - 899 (InitialMargin) -> Position.initial_margin
216    /// - 706 (RealizedPnL) -> Position.realized_profit_loss
217    /// - 707 (UnrealizedPnL) -> Position.floating_profit_loss and Position.unrealized_profit_loss
218    /// - 708 (TotalPnL) -> Position.total_profit_loss
219    /// - 811 (Delta), 812 (Gamma), 813 (Theta), 814 (Vega) -> Position greeks
220    /// - 461 (CFICode) -> Position.kind
221    /// - 100088 (DeribitLiquidationPrice) -> Position.estimated_liquidation_price
222    /// - 100089 (DeribitSizeInCurrency) -> Position.size_currency
223    ///
224    /// Errors:
225    /// - Returns DeribitFixError::Generic when tag 55 (Symbol) is missing.
226    ///
227    /// Note:
228    /// - Fields like `average_price_usd`, `interest_value`, `leverage`, `open_orders_margin`,
229    ///   `realized_funding`, and `size_currency` are not provided by this parser and remain `None`.
230    ///
231    /// Returns:
232    /// - Ok(Position) when parsing succeeds
233    /// - Err(DeribitFixError) if the required symbol (tag 55) is missing
234    pub fn try_from_fix_message(message: &FixMessage) -> Result<Position> {
235        let get_f64 = |tag| message.get_field(tag).and_then(|s| s.parse::<f64>().ok());
236        let get_string = |tag| message.get_field(tag).map(|s| s.to_string());
237
238        let instrument_name = get_string(55).ok_or_else(|| {
239            DeribitFixError::Generic("Missing instrument name (tag 55)".to_string())
240        })?;
241        let long_qty = get_f64(704).unwrap_or(0.0);
242        let short_qty = get_f64(705).unwrap_or(0.0);
243        let size = long_qty - short_qty;
244        let direction = if size > 0.0 {
245            Direction::Buy
246        } else {
247            Direction::Sell
248        };
249        let average_price = get_f64(730).unwrap_or(0.0);
250
251        Ok(Position {
252            instrument_name,
253            size,
254            direction,
255            average_price,
256            average_price_usd: None,
257            delta: get_f64(811),                          // Greeks delta
258            estimated_liquidation_price: get_f64(100088), // DeribitLiquidationPrice
259            floating_profit_loss: get_f64(707),           // Unrealized PnL
260            floating_profit_loss_usd: None,
261            gamma: get_f64(812),          // Greeks gamma
262            index_price: get_f64(731),    // Index price
263            initial_margin: get_f64(899), // Initial margin
264            interest_value: None,
265            kind: get_string(461), // CFICode for instrument type
266            leverage: None,
267            maintenance_margin: get_f64(898), // Maintenance margin
268            mark_price: get_f64(732),         // Mark price
269            open_orders_margin: None,
270            realized_funding: None,
271            realized_profit_loss: get_f64(706),   // Realized PnL
272            settlement_price: get_f64(730),       // Settlement price (same as avg price for now)
273            size_currency: get_f64(100089),       // DeribitSizeInCurrency
274            theta: get_f64(813),                  // Greeks theta
275            total_profit_loss: get_f64(708),      // Total PnL
276            vega: get_f64(814),                   // Greeks vega
277            unrealized_profit_loss: get_f64(707), // Same as floating PnL
278        })
279    }
280
281    /// Builds a FIX Position Report (MsgType=AP) from a Deribit `Position`.
282    ///
283    /// This function converts a Deribit position into a FIX message using
284    /// the provided message metadata (sender/target IDs and sequence number).
285    /// Only fields present in the input position are included in the resulting
286    /// message, and position-side determines whether LongQty or ShortQty is used.
287    ///
288    /// FIX tags populated:
289    /// - 35 (MsgType): AP (PositionReport)
290    /// - 49 (SenderCompID): from `sender_comp_id`
291    /// - 56 (TargetCompID): from `target_comp_id`
292    /// - 34 (MsgSeqNum): from `msg_seq_num`
293    /// - 55 (Symbol): from `position.instrument_name`
294    /// - 730 (SettlPx): from `position.average_price`
295    /// - 704 (LongQty): if `position.direction` is Buy, with `position.size`
296    /// - 705 (ShortQty): if `position.direction` is Sell, with `abs(position.size)`
297    /// - 706 (PosAmt Realized PnL): from `position.realized_profit_loss` (if set)
298    /// - 707 (PosAmt Floating/Unrealized PnL): from `position.floating_profit_loss` (if set)
299    /// - 708 (PosAmt Total PnL): from `position.total_profit_loss` (if set)
300    /// - 811 (Delta): from `position.delta` (if set)
301    /// - 812 (Gamma): from `position.gamma` (if set)
302    /// - 813 (Theta): from `position.theta` (if set)
303    /// - 814 (Vega): from `position.vega` (if set)
304    /// - 731 (IndexPx): from `position.index_price` (if set)
305    /// - 732 (MarkPx): from `position.mark_price` (if set)
306    /// - 899 (InitialMargin): from `position.initial_margin` (if set)
307    /// - 898 (MaintenanceMargin): from `position.maintenance_margin` (if set)
308    /// - 979 (PosAmtType): constant "FMTM"
309    ///
310    /// Parameters:
311    /// - `position`: Source Deribit position to translate.
312    /// - `sender_comp_id`: Value for FIX tag 49 (SenderCompID).
313    /// - `target_comp_id`: Value for FIX tag 56 (TargetCompID).
314    /// - `msg_seq_num`: Value for FIX tag 34 (MsgSeqNum).
315    ///
316    /// Returns:
317    /// - `Ok(String)`: The serialized FIX message string when message building succeeds.
318    /// - `Err(DeribitFixError)`: If the underlying builder fails to construct the message.
319    ///
320    /// Notes:
321    /// - Quantity tag selection depends on `position.direction`:
322    ///   - Buy -> 704=LongQty
323    ///   - Sell -> 705=ShortQty (absolute value)
324    /// - Optional numeric fields are only included when present in `position`.
325    pub fn from_deribit_position(
326        position: &Position,
327        sender_comp_id: String,
328        target_comp_id: String,
329        msg_seq_num: u32,
330    ) -> Result<String> {
331        let msg = MessageBuilder::new()
332            .msg_type(MsgType::PositionReport)
333            .sender_comp_id(sender_comp_id)
334            .target_comp_id(target_comp_id)
335            .msg_seq_num(msg_seq_num);
336
337        // Add position-specific fields
338        let msg = msg.field(55, position.instrument_name.clone()); // Symbol
339        let msg = msg.field(730, position.average_price.to_string()); // SettlPx
340
341        // Add position quantity based on direction
342        let msg = match position.direction {
343            Direction::Buy => msg.field(704, position.size.to_string()), // LongQty
344            Direction::Sell => msg.field(705, position.size.abs().to_string()), // ShortQty (absolute value)
345        };
346
347        // Add other position fields (only if they exist)
348        let msg = if let Some(realized_pnl) = position.realized_profit_loss {
349            msg.field(706, realized_pnl.to_string())
350        } else {
351            msg
352        };
353
354        let msg = if let Some(floating_pnl) = position.floating_profit_loss {
355            msg.field(707, floating_pnl.to_string())
356        } else {
357            msg
358        };
359
360        let msg = if let Some(total_pnl) = position.total_profit_loss {
361            msg.field(708, total_pnl.to_string())
362        } else {
363            msg
364        };
365
366        // Add Greeks if available
367        let msg = if let Some(delta) = position.delta {
368            msg.field(811, delta.to_string())
369        } else {
370            msg
371        };
372
373        let msg = if let Some(gamma) = position.gamma {
374            msg.field(812, gamma.to_string())
375        } else {
376            msg
377        };
378
379        let msg = if let Some(theta) = position.theta {
380            msg.field(813, theta.to_string())
381        } else {
382            msg
383        };
384
385        let msg = if let Some(vega) = position.vega {
386            msg.field(814, vega.to_string())
387        } else {
388            msg
389        };
390
391        // Add other optional fields
392        let msg = if let Some(index_price) = position.index_price {
393            msg.field(731, index_price.to_string())
394        } else {
395            msg
396        };
397
398        let msg = if let Some(mark_price) = position.mark_price {
399            msg.field(732, mark_price.to_string())
400        } else {
401            msg
402        };
403
404        let msg = if let Some(initial_margin) = position.initial_margin {
405            msg.field(899, initial_margin.to_string())
406        } else {
407            msg
408        };
409
410        let msg = if let Some(maintenance_margin) = position.maintenance_margin {
411            msg.field(898, maintenance_margin.to_string())
412        } else {
413            msg
414        };
415
416        let msg = msg.field(979, "FMTM".to_string()); // PosAmtType
417
418        // Deribit custom tags
419        let msg = if let Some(liquidation_price) = position.estimated_liquidation_price {
420            msg.field(100088, liquidation_price.to_string()) // DeribitLiquidationPrice
421        } else {
422            msg
423        };
424
425        let msg = if let Some(size_currency) = position.size_currency {
426            msg.field(100089, size_currency.to_string()) // DeribitSizeInCurrency
427        } else {
428            msg
429        };
430
431        Ok(msg.build()?.to_string())
432    }
433}
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use crate::model::message::FixMessage;
438
439    #[test]
440    fn test_pos_req_type_conversion() {
441        assert_eq!(i32::from(PosReqType::Positions), 0);
442        assert_eq!(i32::from(PosReqType::Trades), 1);
443
444        assert_eq!(PosReqType::try_from(0).unwrap(), PosReqType::Positions);
445        assert_eq!(PosReqType::try_from(1).unwrap(), PosReqType::Trades);
446
447        assert!(PosReqType::try_from(99).is_err());
448    }
449
450    #[test]
451    fn test_subscription_request_type_conversion() {
452        assert_eq!(i32::from(SubscriptionRequestType::Snapshot), 0);
453        assert_eq!(i32::from(SubscriptionRequestType::SnapshotPlusUpdates), 1);
454
455        assert_eq!(
456            SubscriptionRequestType::try_from(0).unwrap(),
457            SubscriptionRequestType::Snapshot
458        );
459        assert_eq!(
460            SubscriptionRequestType::try_from(1).unwrap(),
461            SubscriptionRequestType::SnapshotPlusUpdates
462        );
463
464        assert!(SubscriptionRequestType::try_from(99).is_err());
465    }
466
467    #[test]
468    fn test_request_for_positions_creation() {
469        let request = RequestForPositions::all_positions("POS_123".to_string());
470        assert_eq!(request.pos_req_id, "POS_123");
471        assert_eq!(request.pos_req_type, PosReqType::Positions);
472        assert_eq!(
473            request.subscription_request_type,
474            Some(SubscriptionRequestType::Snapshot)
475        );
476    }
477
478    #[test]
479    fn test_request_for_positions_with_symbols() {
480        let request = RequestForPositions::all_positions("POS_123".to_string()).with_symbols(vec![
481            "BTC-PERPETUAL".to_string(),
482            "ETH-PERPETUAL".to_string(),
483        ]);
484
485        assert_eq!(request.symbols.len(), 2);
486        assert!(request.symbols.contains(&"BTC-PERPETUAL".to_string()));
487    }
488
489    #[test]
490    fn test_request_for_positions_to_fix_message() {
491        let request = RequestForPositions::all_positions("POS_123".to_string());
492        let fix_message = request
493            .to_fix_message("SENDER".to_string(), "TARGET".to_string(), 1)
494            .unwrap();
495
496        // Test field values directly
497        assert_eq!(fix_message.get_field(35), Some(&"AN".to_string())); // MsgType
498        assert_eq!(fix_message.get_field(710), Some(&"POS_123".to_string())); // PosReqID
499        assert_eq!(fix_message.get_field(724), Some(&"0".to_string())); // PosReqType
500        assert_eq!(fix_message.get_field(263), Some(&"0".to_string())); // SubscriptionRequestType
501    }
502
503    #[test]
504    fn test_position_report_try_from_fix_message() {
505        // Create a FixMessage manually by setting fields
506        let mut fix_message = FixMessage::new();
507        fix_message.set_field(55, "BTC-PERPETUAL".to_string()); // Symbol
508        fix_message.set_field(704, "1.5".to_string()); // LongQty
509        fix_message.set_field(705, "0.0".to_string()); // ShortQty
510        fix_message.set_field(730, "50000.0".to_string()); // SettlPx (average price)
511        fix_message.set_field(707, "100.0".to_string()); // Unrealized PnL
512        fix_message.set_field(706, "50.0".to_string()); // Realized PnL
513
514        let position = PositionReport::try_from_fix_message(&fix_message).unwrap();
515
516        assert_eq!(position.instrument_name, "BTC-PERPETUAL");
517        assert_eq!(position.size, 1.5);
518        assert_eq!(position.average_price, 50000.0);
519        assert!(matches!(position.direction, Direction::Buy));
520        assert_eq!(position.floating_profit_loss, Some(100.0));
521        assert_eq!(position.realized_profit_loss, Some(50.0));
522    }
523
524    #[test]
525    fn test_position_report_from_deribit_position() {
526        // Create a Position struct
527        let position = Position {
528            instrument_name: "ETH-PERPETUAL".to_string(),
529            size: 2.0,
530            direction: Direction::Buy,
531            average_price: 3500.0,
532            average_price_usd: None,
533            delta: Some(0.5),
534            estimated_liquidation_price: None,
535            floating_profit_loss: Some(150.0),
536            floating_profit_loss_usd: None,
537            gamma: Some(0.001),
538            index_price: Some(3520.0),
539            initial_margin: Some(100.0),
540            interest_value: None,
541            kind: Some("future".to_string()),
542            leverage: None,
543            maintenance_margin: Some(50.0),
544            mark_price: Some(3510.0),
545            open_orders_margin: None,
546            realized_funding: None,
547            realized_profit_loss: Some(50.0),
548            settlement_price: Some(3500.0),
549            size_currency: None,
550            theta: Some(-0.1),
551            total_profit_loss: Some(200.0),
552            vega: Some(0.05),
553            unrealized_profit_loss: Some(150.0),
554        };
555
556        let fix_message = PositionReport::from_deribit_position(
557            &position,
558            "SENDER".to_string(),
559            "TARGET".to_string(),
560            1,
561        )
562        .unwrap();
563
564        // Verify the FIX message contains expected fields
565        assert!(fix_message.contains("55=ETH-PERPETUAL")); // Symbol
566        assert!(fix_message.contains("704=2")); // LongQty
567        assert!(fix_message.contains("730=3500")); // SettlPx
568    }
569
570    #[test]
571    fn test_position_direction_sell() {
572        // Create a FixMessage with negative position (sell)
573        let mut fix_message = FixMessage::new();
574        fix_message.set_field(55, "BTC-PERPETUAL".to_string()); // Symbol
575        fix_message.set_field(704, "0.0".to_string()); // LongQty
576        fix_message.set_field(705, "1.0".to_string()); // ShortQty
577        fix_message.set_field(730, "45000.0".to_string()); // SettlPx
578
579        let position = PositionReport::try_from_fix_message(&fix_message).unwrap();
580
581        assert_eq!(position.instrument_name, "BTC-PERPETUAL");
582        assert_eq!(position.size, -1.0); // Negative size for sell
583        assert!(matches!(position.direction, Direction::Sell));
584        assert_eq!(position.average_price, 45000.0);
585    }
586
587    #[test]
588    fn test_position_report_with_deribit_custom_tags() {
589        // Create a FixMessage with Deribit custom tags
590        let mut fix_message = FixMessage::new();
591        fix_message.set_field(55, "ETH-PERPETUAL".to_string()); // Symbol
592        fix_message.set_field(704, "2.5".to_string()); // LongQty
593        fix_message.set_field(730, "3500.0".to_string()); // SettlPx
594        fix_message.set_field(100088, "3000.0".to_string()); // DeribitLiquidationPrice
595        fix_message.set_field(100089, "8750.0".to_string()); // DeribitSizeInCurrency
596
597        let position = PositionReport::try_from_fix_message(&fix_message).unwrap();
598
599        assert_eq!(position.instrument_name, "ETH-PERPETUAL");
600        assert_eq!(position.size, 2.5);
601        assert_eq!(position.estimated_liquidation_price, Some(3000.0));
602        assert_eq!(position.size_currency, Some(8750.0));
603    }
604
605    #[test]
606    fn test_position_report_emits_deribit_custom_tags() {
607        // Create a Position with Deribit custom fields
608        let position = Position {
609            instrument_name: "BTC-PERPETUAL".to_string(),
610            size: 1.0,
611            direction: Direction::Buy,
612            average_price: 50000.0,
613            average_price_usd: None,
614            delta: None,
615            estimated_liquidation_price: Some(45000.0),
616            floating_profit_loss: None,
617            floating_profit_loss_usd: None,
618            gamma: None,
619            index_price: None,
620            initial_margin: None,
621            interest_value: None,
622            kind: None,
623            leverage: None,
624            maintenance_margin: None,
625            mark_price: None,
626            open_orders_margin: None,
627            realized_funding: None,
628            realized_profit_loss: None,
629            settlement_price: None,
630            size_currency: Some(50000.0),
631            theta: None,
632            total_profit_loss: None,
633            vega: None,
634            unrealized_profit_loss: None,
635        };
636
637        let fix_message = PositionReport::from_deribit_position(
638            &position,
639            "SENDER".to_string(),
640            "TARGET".to_string(),
641            1,
642        )
643        .unwrap();
644
645        // Check that Deribit custom tags are present
646        assert!(fix_message.contains("100088=45000")); // DeribitLiquidationPrice
647        assert!(fix_message.contains("100089=50000")); // DeribitSizeInCurrency
648    }
649}