nautilus_model/reports/
position.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::fmt::{Debug, Display};
17
18use nautilus_core::{UUID4, UnixNanos};
19use rust_decimal::Decimal;
20use serde::{Deserialize, Serialize};
21
22use crate::{
23    enums::PositionSideSpecified,
24    identifiers::{AccountId, InstrumentId, PositionId},
25    types::Quantity,
26};
27
28/// Represents a position status at a point in time.
29#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(tag = "type")]
31#[cfg_attr(
32    feature = "python",
33    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model")
34)]
35pub struct PositionStatusReport {
36    /// The account ID associated with the position.
37    pub account_id: AccountId,
38    /// The instrument ID associated with the event.
39    pub instrument_id: InstrumentId,
40    /// The position side.
41    pub position_side: PositionSideSpecified,
42    /// The current open quantity.
43    pub quantity: Quantity,
44    /// The current signed quantity as a decimal (positive for position side `LONG`, negative for `SHORT`).
45    pub signed_decimal_qty: Decimal,
46    /// The unique identifier for the event.
47    pub report_id: UUID4,
48    /// UNIX timestamp (nanoseconds) when the last event occurred.
49    pub ts_last: UnixNanos,
50    /// UNIX timestamp (nanoseconds) when the event was initialized.
51    pub ts_init: UnixNanos,
52    /// The position ID (assigned by the venue).
53    pub venue_position_id: Option<PositionId>,
54    /// The reported average open price for the position.
55    pub avg_px_open: Option<Decimal>,
56}
57
58impl PositionStatusReport {
59    /// Creates a new [`PositionStatusReport`] instance with required fields.
60    #[allow(clippy::too_many_arguments)]
61    #[must_use]
62    pub fn new(
63        account_id: AccountId,
64        instrument_id: InstrumentId,
65        position_side: PositionSideSpecified,
66        quantity: Quantity,
67        ts_last: UnixNanos,
68        ts_init: UnixNanos,
69        report_id: Option<UUID4>,
70        venue_position_id: Option<PositionId>,
71        avg_px_open: Option<Decimal>,
72    ) -> Self {
73        // Calculate signed decimal quantity based on position side
74        let signed_decimal_qty = match position_side {
75            PositionSideSpecified::Long => quantity.as_decimal(),
76            PositionSideSpecified::Short => -quantity.as_decimal(),
77            PositionSideSpecified::Flat => Decimal::ZERO,
78        };
79
80        Self {
81            account_id,
82            instrument_id,
83            position_side,
84            quantity,
85            signed_decimal_qty,
86            report_id: report_id.unwrap_or_default(),
87            ts_last,
88            ts_init,
89            venue_position_id,
90            avg_px_open,
91        }
92    }
93
94    /// Checks if the position has a venue position ID.
95    #[must_use]
96    pub const fn has_venue_position_id(&self) -> bool {
97        self.venue_position_id.is_some()
98    }
99
100    /// Checks if this is a flat position (quantity is zero).
101    #[must_use]
102    pub fn is_flat(&self) -> bool {
103        self.position_side == PositionSideSpecified::Flat
104    }
105
106    /// Checks if this is a long position.
107    #[must_use]
108    pub fn is_long(&self) -> bool {
109        self.position_side == PositionSideSpecified::Long
110    }
111
112    /// Checks if this is a short position.
113    #[must_use]
114    pub fn is_short(&self) -> bool {
115        self.position_side == PositionSideSpecified::Short
116    }
117}
118
119impl Display for PositionStatusReport {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        write!(
122            f,
123            "PositionStatusReport(account={}, instrument={}, side={}, qty={}, venue_pos_id={:?}, avg_px_open={:?}, ts_last={}, ts_init={})",
124            self.account_id,
125            self.instrument_id,
126            self.position_side,
127            self.signed_decimal_qty,
128            self.venue_position_id,
129            self.avg_px_open,
130            self.ts_last,
131            self.ts_init
132        )
133    }
134}
135
136////////////////////////////////////////////////////////////////////////////////
137// Tests
138////////////////////////////////////////////////////////////////////////////////
139#[cfg(test)]
140mod tests {
141    use std::str::FromStr;
142
143    use nautilus_core::UnixNanos;
144    use rstest::*;
145    use rust_decimal::Decimal;
146
147    use super::*;
148    use crate::{
149        identifiers::{AccountId, InstrumentId, PositionId},
150        types::Quantity,
151    };
152
153    fn test_position_status_report_long() -> PositionStatusReport {
154        PositionStatusReport::new(
155            AccountId::from("SIM-001"),
156            InstrumentId::from("AUDUSD.SIM"),
157            PositionSideSpecified::Long,
158            Quantity::from("100"),
159            UnixNanos::from(1_000_000_000),
160            UnixNanos::from(2_000_000_000),
161            None,                            // report_id
162            Some(PositionId::from("P-001")), // venue_position_id
163            None,                            // avg_px_open
164        )
165    }
166
167    fn test_position_status_report_short() -> PositionStatusReport {
168        PositionStatusReport::new(
169            AccountId::from("SIM-001"),
170            InstrumentId::from("AUDUSD.SIM"),
171            PositionSideSpecified::Short,
172            Quantity::from("50"),
173            UnixNanos::from(1_000_000_000),
174            UnixNanos::from(2_000_000_000),
175            None,
176            None,
177            None,
178        )
179    }
180
181    fn test_position_status_report_flat() -> PositionStatusReport {
182        PositionStatusReport::new(
183            AccountId::from("SIM-001"),
184            InstrumentId::from("AUDUSD.SIM"),
185            PositionSideSpecified::Flat,
186            Quantity::from("0"),
187            UnixNanos::from(1_000_000_000),
188            UnixNanos::from(2_000_000_000),
189            None,
190            None,
191            None,
192        )
193    }
194
195    #[rstest]
196    fn test_position_status_report_new_long() {
197        let report = test_position_status_report_long();
198
199        assert_eq!(report.account_id, AccountId::from("SIM-001"));
200        assert_eq!(report.instrument_id, InstrumentId::from("AUDUSD.SIM"));
201        assert_eq!(report.position_side, PositionSideSpecified::Long);
202        assert_eq!(report.quantity, Quantity::from("100"));
203        assert_eq!(report.signed_decimal_qty, Decimal::from(100));
204        assert_eq!(report.venue_position_id, Some(PositionId::from("P-001")));
205        assert_eq!(report.ts_last, UnixNanos::from(1_000_000_000));
206        assert_eq!(report.ts_init, UnixNanos::from(2_000_000_000));
207    }
208
209    #[rstest]
210    fn test_position_status_report_new_short() {
211        let report = test_position_status_report_short();
212
213        assert_eq!(report.position_side, PositionSideSpecified::Short);
214        assert_eq!(report.quantity, Quantity::from("50"));
215        assert_eq!(report.signed_decimal_qty, Decimal::from(-50));
216        assert_eq!(report.venue_position_id, None);
217    }
218
219    #[rstest]
220    fn test_position_status_report_new_flat() {
221        let report = test_position_status_report_flat();
222
223        assert_eq!(report.position_side, PositionSideSpecified::Flat);
224        assert_eq!(report.quantity, Quantity::from("0"));
225        assert_eq!(report.signed_decimal_qty, Decimal::ZERO);
226    }
227
228    #[rstest]
229    fn test_position_status_report_with_generated_report_id() {
230        let report = PositionStatusReport::new(
231            AccountId::from("SIM-001"),
232            InstrumentId::from("AUDUSD.SIM"),
233            PositionSideSpecified::Long,
234            Quantity::from("100"),
235            UnixNanos::from(1_000_000_000),
236            UnixNanos::from(2_000_000_000),
237            None, // No report ID provided, should generate one
238            None,
239            None,
240        );
241
242        // Should have a generated UUID
243        assert_ne!(
244            report.report_id.to_string(),
245            "00000000-0000-0000-0000-000000000000"
246        );
247    }
248
249    #[rstest]
250    fn test_has_venue_position_id() {
251        let mut report = test_position_status_report_long();
252        assert!(report.has_venue_position_id());
253
254        report.venue_position_id = None;
255        assert!(!report.has_venue_position_id());
256    }
257
258    #[rstest]
259    fn test_is_flat() {
260        let long_report = test_position_status_report_long();
261        let short_report = test_position_status_report_short();
262        let flat_report = test_position_status_report_flat();
263
264        let no_position_report = PositionStatusReport::new(
265            AccountId::from("SIM-001"),
266            InstrumentId::from("AUDUSD.SIM"),
267            PositionSideSpecified::Flat,
268            Quantity::from("0"),
269            UnixNanos::from(1_000_000_000),
270            UnixNanos::from(2_000_000_000),
271            None,
272            None,
273            None,
274        );
275
276        assert!(!long_report.is_flat());
277        assert!(!short_report.is_flat());
278        assert!(flat_report.is_flat());
279        assert!(no_position_report.is_flat());
280    }
281
282    #[rstest]
283    fn test_is_long() {
284        let long_report = test_position_status_report_long();
285        let short_report = test_position_status_report_short();
286        let flat_report = test_position_status_report_flat();
287
288        assert!(long_report.is_long());
289        assert!(!short_report.is_long());
290        assert!(!flat_report.is_long());
291    }
292
293    #[rstest]
294    fn test_is_short() {
295        let long_report = test_position_status_report_long();
296        let short_report = test_position_status_report_short();
297        let flat_report = test_position_status_report_flat();
298
299        assert!(!long_report.is_short());
300        assert!(short_report.is_short());
301        assert!(!flat_report.is_short());
302    }
303
304    #[rstest]
305    fn test_display() {
306        let report = test_position_status_report_long();
307        let display_str = format!("{report}");
308
309        assert!(display_str.contains("PositionStatusReport"));
310        assert!(display_str.contains("SIM-001"));
311        assert!(display_str.contains("AUDUSD.SIM"));
312        assert!(display_str.contains("LONG"));
313        assert!(display_str.contains("100"));
314        assert!(display_str.contains("P-001"));
315        assert!(display_str.contains("avg_px_open=None"));
316    }
317
318    #[rstest]
319    fn test_clone_and_equality() {
320        let report1 = test_position_status_report_long();
321        let report2 = report1.clone();
322
323        assert_eq!(report1, report2);
324    }
325
326    #[rstest]
327    fn test_serialization_roundtrip() {
328        let original = test_position_status_report_long();
329
330        // Test JSON serialization
331        let json = serde_json::to_string(&original).unwrap();
332        let deserialized: PositionStatusReport = serde_json::from_str(&json).unwrap();
333        assert_eq!(original, deserialized);
334    }
335
336    #[rstest]
337    fn test_signed_decimal_qty_calculation() {
338        // Test with various quantities to ensure signed decimal calculation is correct
339        let long_100 = PositionStatusReport::new(
340            AccountId::from("SIM-001"),
341            InstrumentId::from("AUDUSD.SIM"),
342            PositionSideSpecified::Long,
343            Quantity::from("100.5"),
344            UnixNanos::from(1_000_000_000),
345            UnixNanos::from(2_000_000_000),
346            None,
347            None,
348            None,
349        );
350
351        let short_200 = PositionStatusReport::new(
352            AccountId::from("SIM-001"),
353            InstrumentId::from("AUDUSD.SIM"),
354            PositionSideSpecified::Short,
355            Quantity::from("200.75"),
356            UnixNanos::from(1_000_000_000),
357            UnixNanos::from(2_000_000_000),
358            None,
359            None,
360            None,
361        );
362
363        assert_eq!(
364            long_100.signed_decimal_qty,
365            Decimal::from_f64_retain(100.5).unwrap()
366        );
367        assert_eq!(
368            short_200.signed_decimal_qty,
369            Decimal::from_f64_retain(-200.75).unwrap()
370        );
371    }
372
373    #[rstest]
374    fn test_different_position_sides_not_equal() {
375        let long_report = test_position_status_report_long();
376        let short_report = PositionStatusReport::new(
377            AccountId::from("SIM-001"),
378            InstrumentId::from("AUDUSD.SIM"),
379            PositionSideSpecified::Short,
380            Quantity::from("100"), // Same quantity but different side
381            UnixNanos::from(1_000_000_000),
382            UnixNanos::from(2_000_000_000),
383            None,                            // report_id
384            Some(PositionId::from("P-001")), // venue_position_id
385            None,                            // avg_px_open
386        );
387
388        assert_ne!(long_report, short_report);
389        assert_ne!(
390            long_report.signed_decimal_qty,
391            short_report.signed_decimal_qty
392        );
393    }
394
395    #[rstest]
396    fn test_with_avg_px_open() {
397        let report = PositionStatusReport::new(
398            AccountId::from("SIM-001"),
399            InstrumentId::from("AUDUSD.SIM"),
400            PositionSideSpecified::Long,
401            Quantity::from("100"),
402            UnixNanos::from(1_000_000_000),
403            UnixNanos::from(2_000_000_000),
404            None,
405            Some(PositionId::from("P-001")),
406            Some(Decimal::from_str("1.23456").unwrap()),
407        );
408
409        assert_eq!(
410            report.avg_px_open,
411            Some(rust_decimal::Decimal::from_str("1.23456").unwrap())
412        );
413        assert!(format!("{}", report).contains("avg_px_open=Some(1.23456)"));
414    }
415
416    #[rstest]
417    fn test_avg_px_open_none_default() {
418        let report = PositionStatusReport::new(
419            AccountId::from("SIM-001"),
420            InstrumentId::from("AUDUSD.SIM"),
421            PositionSideSpecified::Long,
422            Quantity::from("100"),
423            UnixNanos::from(1_000_000_000),
424            UnixNanos::from(2_000_000_000),
425            None,
426            None,
427            None, // avg_px_open is None
428        );
429
430        assert_eq!(report.avg_px_open, None);
431    }
432
433    #[rstest]
434    fn test_avg_px_open_with_different_sides() {
435        let long_with_price = PositionStatusReport::new(
436            AccountId::from("SIM-001"),
437            InstrumentId::from("AUDUSD.SIM"),
438            PositionSideSpecified::Long,
439            Quantity::from("100"),
440            UnixNanos::from(1_000_000_000),
441            UnixNanos::from(2_000_000_000),
442            None,
443            None,
444            Some(Decimal::from_str("1.50000").unwrap()),
445        );
446
447        let short_with_price = PositionStatusReport::new(
448            AccountId::from("SIM-001"),
449            InstrumentId::from("AUDUSD.SIM"),
450            PositionSideSpecified::Short,
451            Quantity::from("100"),
452            UnixNanos::from(1_000_000_000),
453            UnixNanos::from(2_000_000_000),
454            None,
455            None,
456            Some(Decimal::from_str("1.60000").unwrap()),
457        );
458
459        assert_eq!(
460            long_with_price.avg_px_open,
461            Some(rust_decimal::Decimal::from_str("1.50000").unwrap())
462        );
463        assert_eq!(
464            short_with_price.avg_px_open,
465            Some(rust_decimal::Decimal::from_str("1.60000").unwrap())
466        );
467    }
468
469    #[rstest]
470    fn test_avg_px_open_serialization() {
471        let report = PositionStatusReport::new(
472            AccountId::from("SIM-001"),
473            InstrumentId::from("AUDUSD.SIM"),
474            PositionSideSpecified::Long,
475            Quantity::from("100"),
476            UnixNanos::from(1_000_000_000),
477            UnixNanos::from(2_000_000_000),
478            None,
479            None,
480            Some(Decimal::from_str("1.99999").unwrap()),
481        );
482
483        let json = serde_json::to_string(&report).unwrap();
484        let deserialized: PositionStatusReport = serde_json::from_str(&json).unwrap();
485
486        assert_eq!(report.avg_px_open, deserialized.avg_px_open);
487    }
488}