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::{DeribitFixError, Result as DeribitFixResult};
15use crate::message::MessageBuilder;
16use crate::model::message::FixMessage;
17use crate::model::types::MsgType;
18use chrono::{DateTime, Utc};
19use deribit_base::prelude::Position;
20use serde::{Deserialize, Serialize};
21
22/// Position request type enumeration
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24pub enum PosReqType {
25    /// Positions (0)
26    Positions,
27    /// Trades (1)
28    Trades,
29    /// Exercises (2)
30    Exercises,
31    /// Assignments (3)
32    Assignments,
33}
34
35impl From<PosReqType> for i32 {
36    fn from(value: PosReqType) -> Self {
37        match value {
38            PosReqType::Positions => 0,
39            PosReqType::Trades => 1,
40            PosReqType::Exercises => 2,
41            PosReqType::Assignments => 3,
42        }
43    }
44}
45
46impl TryFrom<i32> for PosReqType {
47    type Error = DeribitFixError;
48
49    fn try_from(value: i32) -> Result<Self, Self::Error> {
50        match value {
51            0 => Ok(PosReqType::Positions),
52            1 => Ok(PosReqType::Trades),
53            2 => Ok(PosReqType::Exercises),
54            3 => Ok(PosReqType::Assignments),
55            _ => Err(DeribitFixError::MessageParsing(format!(
56                "Invalid PosReqType: {}",
57                value
58            ))),
59        }
60    }
61}
62
63/// Subscription request type for positions
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65pub enum SubscriptionRequestType {
66    /// Snapshot (0)
67    Snapshot,
68    /// Snapshot + Updates (1)
69    SnapshotPlusUpdates,
70    /// Disable previous snapshot + updates (2)
71    DisablePreviousSnapshotPlusUpdates,
72}
73
74impl From<SubscriptionRequestType> for i32 {
75    fn from(value: SubscriptionRequestType) -> Self {
76        match value {
77            SubscriptionRequestType::Snapshot => 0,
78            SubscriptionRequestType::SnapshotPlusUpdates => 1,
79            SubscriptionRequestType::DisablePreviousSnapshotPlusUpdates => 2,
80        }
81    }
82}
83
84impl TryFrom<i32> for SubscriptionRequestType {
85    type Error = DeribitFixError;
86
87    fn try_from(value: i32) -> Result<Self, Self::Error> {
88        match value {
89            0 => Ok(SubscriptionRequestType::Snapshot),
90            1 => Ok(SubscriptionRequestType::SnapshotPlusUpdates),
91            2 => Ok(SubscriptionRequestType::DisablePreviousSnapshotPlusUpdates),
92            _ => Err(DeribitFixError::MessageParsing(format!(
93                "Invalid SubscriptionRequestType: {}",
94                value
95            ))),
96        }
97    }
98}
99
100/// Request For Positions message (MsgType = "AN")
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct RequestForPositions {
103    /// Position Request ID (710)
104    pub pos_req_id: String,
105    /// Position Request Type (724)
106    pub pos_req_type: PosReqType,
107    /// Subscription Request Type (263) - optional
108    pub subscription_request_type: Option<SubscriptionRequestType>,
109    /// Clearing Business Date (715) - optional
110    pub clearing_business_date: Option<String>,
111    /// Symbols filter - optional
112    pub symbols: Vec<String>,
113}
114
115impl RequestForPositions {
116    /// Create a new position request for all positions
117    pub fn all_positions(pos_req_id: String) -> Self {
118        Self {
119            pos_req_id,
120            pos_req_type: PosReqType::Positions,
121            subscription_request_type: Some(SubscriptionRequestType::Snapshot),
122            clearing_business_date: None,
123            symbols: Vec::new(),
124        }
125    }
126
127    /// Create a new position request with subscription for updates
128    pub fn positions_with_updates(pos_req_id: String) -> Self {
129        Self {
130            pos_req_id,
131            pos_req_type: PosReqType::Positions,
132            subscription_request_type: Some(SubscriptionRequestType::SnapshotPlusUpdates),
133            clearing_business_date: None,
134            symbols: Vec::new(),
135        }
136    }
137
138    /// Add symbols filter
139    pub fn with_symbols(mut self, symbols: Vec<String>) -> Self {
140        self.symbols = symbols;
141        self
142    }
143
144    /// Add clearing business date
145    pub fn with_clearing_date(mut self, date: String) -> Self {
146        self.clearing_business_date = Some(date);
147        self
148    }
149
150    /// Convert to FIX message
151    pub fn to_fix_message(
152        &self,
153        sender_comp_id: String,
154        target_comp_id: String,
155        msg_seq_num: u32,
156    ) -> DeribitFixResult<FixMessage> {
157        let mut builder = MessageBuilder::new()
158            .msg_type(MsgType::RequestForPositions)
159            .sender_comp_id(sender_comp_id)
160            .target_comp_id(target_comp_id)
161            .msg_seq_num(msg_seq_num)
162            .field(710, self.pos_req_id.clone()) // PosReqID
163            .field(724, i32::from(self.pos_req_type).to_string()); // PosReqType
164
165        // Add optional subscription request type
166        if let Some(subscription_type) = self.subscription_request_type {
167            builder = builder.field(263, i32::from(subscription_type).to_string());
168        }
169
170        // Add optional clearing business date
171        if let Some(ref date) = self.clearing_business_date {
172            builder = builder.field(715, date.clone());
173        }
174
175        // Add symbols if present
176        if !self.symbols.is_empty() {
177            builder = builder.field(146, self.symbols.len().to_string()); // NoRelatedSym
178            for symbol in &self.symbols {
179                builder = builder.field(55, symbol.clone()); // Symbol
180            }
181        }
182
183        builder.build()
184    }
185}
186
187/// Position Report message (MsgType = "AP")
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189pub struct PositionReport {
190    /// Position Request ID (710)
191    pub pos_req_id: String,
192    /// Symbol (55)
193    pub symbol: String,
194    /// Position Quantity (703)
195    pub position_qty: Option<f64>,
196    /// Average Price (6)
197    pub average_price: Option<f64>,
198    /// Unrealized PnL (1247)
199    pub unrealized_pnl: Option<f64>,
200    /// Realized PnL (1248)
201    pub realized_pnl: Option<f64>,
202    /// Position date (704) - optional
203    pub position_date: Option<String>,
204    /// Last update time
205    pub last_update_time: Option<DateTime<Utc>>,
206}
207
208impl PositionReport {
209    /// Create a new Position Report
210    pub fn new(pos_req_id: String, symbol: String) -> Self {
211        Self {
212            pos_req_id,
213            symbol,
214            position_qty: None,
215            average_price: None,
216            unrealized_pnl: None,
217            realized_pnl: None,
218            position_date: None,
219            last_update_time: Some(Utc::now()),
220        }
221    }
222
223    /// Set position quantity
224    pub fn with_position_qty(mut self, position_qty: f64) -> Self {
225        self.position_qty = Some(position_qty);
226        self
227    }
228
229    /// Set average price
230    pub fn with_average_price(mut self, average_price: f64) -> Self {
231        self.average_price = Some(average_price);
232        self
233    }
234
235    /// Set unrealized PnL
236    pub fn with_unrealized_pnl(mut self, unrealized_pnl: f64) -> Self {
237        self.unrealized_pnl = Some(unrealized_pnl);
238        self
239    }
240
241    /// Set realized PnL
242    pub fn with_realized_pnl(mut self, realized_pnl: f64) -> Self {
243        self.realized_pnl = Some(realized_pnl);
244        self
245    }
246
247    /// Set position date
248    pub fn with_position_date(mut self, position_date: String) -> Self {
249        self.position_date = Some(position_date);
250        self
251    }
252
253    /// Parse from FIX message
254    pub fn from_fix_message(message: &FixMessage) -> DeribitFixResult<Self> {
255        let pos_req_id = message
256            .get_field(710)
257            .ok_or_else(|| DeribitFixError::MessageParsing("Missing PosReqID (710)".to_string()))?
258            .clone();
259
260        let symbol = message
261            .get_field(55)
262            .ok_or_else(|| DeribitFixError::MessageParsing("Missing Symbol (55)".to_string()))?
263            .clone();
264
265        let position_qty = message.get_field(703).and_then(|s| s.parse::<f64>().ok());
266
267        let average_price = message.get_field(6).and_then(|s| s.parse::<f64>().ok());
268
269        let unrealized_pnl = message.get_field(1247).and_then(|s| s.parse::<f64>().ok());
270
271        let realized_pnl = message.get_field(1248).and_then(|s| s.parse::<f64>().ok());
272
273        let position_date = message.get_field(704).cloned();
274
275        Ok(Self {
276            pos_req_id,
277            symbol,
278            position_qty,
279            average_price,
280            unrealized_pnl,
281            realized_pnl,
282            position_date,
283            last_update_time: Some(Utc::now()),
284        })
285    }
286
287    /// Convert to FIX message for emission
288    pub fn to_fix_message(
289        &self,
290        sender_comp_id: String,
291        target_comp_id: String,
292        msg_seq_num: u32,
293    ) -> DeribitFixResult<FixMessage> {
294        let mut builder = MessageBuilder::new()
295            .msg_type(MsgType::PositionReport)
296            .sender_comp_id(sender_comp_id)
297            .target_comp_id(target_comp_id)
298            .msg_seq_num(msg_seq_num)
299            .field(710, self.pos_req_id.clone()) // PosReqID
300            .field(55, self.symbol.clone()); // Symbol
301
302        // Add optional fields
303        if let Some(position_qty) = self.position_qty {
304            builder = builder.field(703, position_qty.to_string()); // PosQty
305        }
306
307        if let Some(average_price) = self.average_price {
308            builder = builder.field(6, average_price.to_string()); // AvgPx
309        }
310
311        if let Some(unrealized_pnl) = self.unrealized_pnl {
312            builder = builder.field(1247, unrealized_pnl.to_string()); // UnrealizedPnL
313        }
314
315        if let Some(realized_pnl) = self.realized_pnl {
316            builder = builder.field(1248, realized_pnl.to_string()); // RealizedPnL
317        }
318
319        if let Some(ref position_date) = self.position_date {
320            builder = builder.field(704, position_date.clone()); // PosDate
321        }
322
323        builder.build()
324    }
325
326    /// Convert to deribit_base Position
327    pub fn to_position(&self) -> Position {
328        Position {
329            symbol: self.symbol.clone(),
330            quantity: self.position_qty.unwrap_or(0.0),
331            average_price: self.average_price.unwrap_or(0.0),
332            realized_pnl: self.realized_pnl.unwrap_or(0.0),
333            unrealized_pnl: self.unrealized_pnl.unwrap_or(0.0),
334        }
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn test_pos_req_type_conversion() {
344        assert_eq!(i32::from(PosReqType::Positions), 0);
345        assert_eq!(i32::from(PosReqType::Trades), 1);
346
347        assert_eq!(PosReqType::try_from(0).unwrap(), PosReqType::Positions);
348        assert_eq!(PosReqType::try_from(1).unwrap(), PosReqType::Trades);
349
350        assert!(PosReqType::try_from(99).is_err());
351    }
352
353    #[test]
354    fn test_subscription_request_type_conversion() {
355        assert_eq!(i32::from(SubscriptionRequestType::Snapshot), 0);
356        assert_eq!(i32::from(SubscriptionRequestType::SnapshotPlusUpdates), 1);
357
358        assert_eq!(
359            SubscriptionRequestType::try_from(0).unwrap(),
360            SubscriptionRequestType::Snapshot
361        );
362        assert_eq!(
363            SubscriptionRequestType::try_from(1).unwrap(),
364            SubscriptionRequestType::SnapshotPlusUpdates
365        );
366
367        assert!(SubscriptionRequestType::try_from(99).is_err());
368    }
369
370    #[test]
371    fn test_request_for_positions_creation() {
372        let request = RequestForPositions::all_positions("POS_123".to_string());
373        assert_eq!(request.pos_req_id, "POS_123");
374        assert_eq!(request.pos_req_type, PosReqType::Positions);
375        assert_eq!(
376            request.subscription_request_type,
377            Some(SubscriptionRequestType::Snapshot)
378        );
379    }
380
381    #[test]
382    fn test_request_for_positions_with_symbols() {
383        let request = RequestForPositions::all_positions("POS_123".to_string()).with_symbols(vec![
384            "BTC-PERPETUAL".to_string(),
385            "ETH-PERPETUAL".to_string(),
386        ]);
387
388        assert_eq!(request.symbols.len(), 2);
389        assert!(request.symbols.contains(&"BTC-PERPETUAL".to_string()));
390    }
391
392    #[test]
393    fn test_request_for_positions_to_fix_message() {
394        let request = RequestForPositions::all_positions("POS_123".to_string());
395        let fix_message = request
396            .to_fix_message("SENDER".to_string(), "TARGET".to_string(), 1)
397            .unwrap();
398
399        // Test field values directly
400        assert_eq!(fix_message.get_field(35), Some(&"AN".to_string())); // MsgType
401        assert_eq!(fix_message.get_field(710), Some(&"POS_123".to_string())); // PosReqID
402        assert_eq!(fix_message.get_field(724), Some(&"0".to_string())); // PosReqType
403        assert_eq!(fix_message.get_field(263), Some(&"0".to_string())); // SubscriptionRequestType
404    }
405
406    #[test]
407    fn test_position_report_from_fix_message() {
408        // Create a FixMessage manually by setting fields
409        let mut fix_message = FixMessage::new();
410        fix_message.set_field(710, "POS_123".to_string());
411        fix_message.set_field(55, "BTC-PERPETUAL".to_string());
412        fix_message.set_field(703, "1.5".to_string());
413        fix_message.set_field(6, "50000.0".to_string());
414        fix_message.set_field(1247, "100.0".to_string());
415        fix_message.set_field(1248, "50.0".to_string());
416
417        let position_report = PositionReport::from_fix_message(&fix_message).unwrap();
418
419        assert_eq!(position_report.pos_req_id, "POS_123");
420        assert_eq!(position_report.symbol, "BTC-PERPETUAL");
421        assert_eq!(position_report.position_qty, Some(1.5));
422        assert_eq!(position_report.average_price, Some(50000.0));
423        assert_eq!(position_report.unrealized_pnl, Some(100.0));
424        assert_eq!(position_report.realized_pnl, Some(50.0));
425    }
426
427    #[test]
428    fn test_position_report_to_position() {
429        let position_report = PositionReport {
430            pos_req_id: "POS_123".to_string(),
431            symbol: "BTC-PERPETUAL".to_string(),
432            position_qty: Some(1.5),
433            average_price: Some(50000.0),
434            unrealized_pnl: Some(100.0),
435            realized_pnl: Some(50.0),
436            position_date: None,
437            last_update_time: None,
438        };
439
440        let position = position_report.to_position();
441        assert_eq!(position.symbol, "BTC-PERPETUAL");
442        assert_eq!(position.quantity, 1.5);
443        assert_eq!(position.average_price, 50000.0);
444        assert_eq!(position.unrealized_pnl, 100.0);
445        assert_eq!(position.realized_pnl, 50.0);
446    }
447
448    #[test]
449    fn test_position_report_builder() {
450        let report = PositionReport::new("POS_789".to_string(), "ETH-PERPETUAL".to_string())
451            .with_position_qty(2.0)
452            .with_average_price(3500.0)
453            .with_unrealized_pnl(150.0)
454            .with_realized_pnl(50.0)
455            .with_position_date("20240102".to_string());
456
457        assert_eq!(report.pos_req_id, "POS_789");
458        assert_eq!(report.symbol, "ETH-PERPETUAL");
459        assert_eq!(report.position_qty, Some(2.0));
460        assert_eq!(report.average_price, Some(3500.0));
461        assert_eq!(report.unrealized_pnl, Some(150.0));
462        assert_eq!(report.realized_pnl, Some(50.0));
463        assert_eq!(report.position_date, Some("20240102".to_string()));
464        assert!(report.last_update_time.is_some());
465    }
466
467    #[test]
468    fn test_position_report_to_fix_message() {
469        let report = PositionReport::new("POS_123".to_string(), "BTC-PERPETUAL".to_string())
470            .with_position_qty(1.0)
471            .with_average_price(45000.0)
472            .with_unrealized_pnl(500.0)
473            .with_realized_pnl(-200.0)
474            .with_position_date("20240103".to_string());
475
476        let fix_message = report
477            .to_fix_message("SENDER".to_string(), "TARGET".to_string(), 1)
478            .unwrap();
479
480        // Check required fields
481        assert_eq!(fix_message.get_field(35).unwrap(), "AP"); // MsgType
482        assert_eq!(fix_message.get_field(710).unwrap(), "POS_123"); // PosReqID
483        assert_eq!(fix_message.get_field(55).unwrap(), "BTC-PERPETUAL"); // Symbol
484
485        // Check optional fields
486        assert_eq!(fix_message.get_field(703).unwrap(), "1"); // PosQty
487        assert_eq!(fix_message.get_field(6).unwrap(), "45000"); // AvgPx
488        assert_eq!(fix_message.get_field(1247).unwrap(), "500"); // UnrealizedPnL
489        assert_eq!(fix_message.get_field(1248).unwrap(), "-200"); // RealizedPnL
490        assert_eq!(fix_message.get_field(704).unwrap(), "20240103"); // PosDate
491    }
492
493    #[test]
494    fn test_position_report_to_fix_message_minimal() {
495        let report = PositionReport::new("POS_MIN".to_string(), "ETH-PERPETUAL".to_string());
496
497        let fix_message = report
498            .to_fix_message("SENDER".to_string(), "TARGET".to_string(), 2)
499            .unwrap();
500
501        // Check required fields only
502        assert_eq!(fix_message.get_field(35).unwrap(), "AP"); // MsgType
503        assert_eq!(fix_message.get_field(710).unwrap(), "POS_MIN"); // PosReqID
504        assert_eq!(fix_message.get_field(55).unwrap(), "ETH-PERPETUAL"); // Symbol
505
506        // Optional fields should not be present
507        assert!(fix_message.get_field(703).is_none()); // PosQty
508        assert!(fix_message.get_field(6).is_none()); // AvgPx
509        assert!(fix_message.get_field(1247).is_none()); // UnrealizedPnL
510        assert!(fix_message.get_field(1248).is_none()); // RealizedPnL
511        assert!(fix_message.get_field(704).is_none()); // PosDate
512    }
513}