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