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#[cfg(test)]
137mod tests {
138    use std::str::FromStr;
139
140    use nautilus_core::UnixNanos;
141    use rstest::*;
142    use rust_decimal::Decimal;
143    use rust_decimal_macros::dec;
144
145    use super::*;
146    use crate::{
147        identifiers::{AccountId, InstrumentId, PositionId},
148        types::Quantity,
149    };
150
151    fn test_position_status_report_long() -> PositionStatusReport {
152        PositionStatusReport::new(
153            AccountId::from("SIM-001"),
154            InstrumentId::from("AUDUSD.SIM"),
155            PositionSideSpecified::Long,
156            Quantity::from("100"),
157            UnixNanos::from(1_000_000_000),
158            UnixNanos::from(2_000_000_000),
159            None,                            // report_id
160            Some(PositionId::from("P-001")), // venue_position_id
161            None,                            // avg_px_open
162        )
163    }
164
165    fn test_position_status_report_short() -> PositionStatusReport {
166        PositionStatusReport::new(
167            AccountId::from("SIM-001"),
168            InstrumentId::from("AUDUSD.SIM"),
169            PositionSideSpecified::Short,
170            Quantity::from("50"),
171            UnixNanos::from(1_000_000_000),
172            UnixNanos::from(2_000_000_000),
173            None,
174            None,
175            None,
176        )
177    }
178
179    fn test_position_status_report_flat() -> PositionStatusReport {
180        PositionStatusReport::new(
181            AccountId::from("SIM-001"),
182            InstrumentId::from("AUDUSD.SIM"),
183            PositionSideSpecified::Flat,
184            Quantity::from("0"),
185            UnixNanos::from(1_000_000_000),
186            UnixNanos::from(2_000_000_000),
187            None,
188            None,
189            None,
190        )
191    }
192
193    #[rstest]
194    fn test_position_status_report_new_long() {
195        let report = test_position_status_report_long();
196
197        assert_eq!(report.account_id, AccountId::from("SIM-001"));
198        assert_eq!(report.instrument_id, InstrumentId::from("AUDUSD.SIM"));
199        assert_eq!(report.position_side, PositionSideSpecified::Long);
200        assert_eq!(report.quantity, Quantity::from("100"));
201        assert_eq!(report.signed_decimal_qty, dec!(100));
202        assert_eq!(report.venue_position_id, Some(PositionId::from("P-001")));
203        assert_eq!(report.ts_last, UnixNanos::from(1_000_000_000));
204        assert_eq!(report.ts_init, UnixNanos::from(2_000_000_000));
205    }
206
207    #[rstest]
208    fn test_position_status_report_new_short() {
209        let report = test_position_status_report_short();
210
211        assert_eq!(report.position_side, PositionSideSpecified::Short);
212        assert_eq!(report.quantity, Quantity::from("50"));
213        assert_eq!(report.signed_decimal_qty, dec!(-50));
214        assert_eq!(report.venue_position_id, None);
215    }
216
217    #[rstest]
218    fn test_position_status_report_new_flat() {
219        let report = test_position_status_report_flat();
220
221        assert_eq!(report.position_side, PositionSideSpecified::Flat);
222        assert_eq!(report.quantity, Quantity::from("0"));
223        assert_eq!(report.signed_decimal_qty, Decimal::ZERO);
224    }
225
226    #[rstest]
227    fn test_position_status_report_with_generated_report_id() {
228        let report = PositionStatusReport::new(
229            AccountId::from("SIM-001"),
230            InstrumentId::from("AUDUSD.SIM"),
231            PositionSideSpecified::Long,
232            Quantity::from("100"),
233            UnixNanos::from(1_000_000_000),
234            UnixNanos::from(2_000_000_000),
235            None, // No report ID provided, should generate one
236            None,
237            None,
238        );
239
240        // Should have a generated UUID
241        assert_ne!(
242            report.report_id.to_string(),
243            "00000000-0000-0000-0000-000000000000"
244        );
245    }
246
247    #[rstest]
248    fn test_has_venue_position_id() {
249        let mut report = test_position_status_report_long();
250        assert!(report.has_venue_position_id());
251
252        report.venue_position_id = None;
253        assert!(!report.has_venue_position_id());
254    }
255
256    #[rstest]
257    fn test_is_flat() {
258        let long_report = test_position_status_report_long();
259        let short_report = test_position_status_report_short();
260        let flat_report = test_position_status_report_flat();
261
262        let no_position_report = PositionStatusReport::new(
263            AccountId::from("SIM-001"),
264            InstrumentId::from("AUDUSD.SIM"),
265            PositionSideSpecified::Flat,
266            Quantity::from("0"),
267            UnixNanos::from(1_000_000_000),
268            UnixNanos::from(2_000_000_000),
269            None,
270            None,
271            None,
272        );
273
274        assert!(!long_report.is_flat());
275        assert!(!short_report.is_flat());
276        assert!(flat_report.is_flat());
277        assert!(no_position_report.is_flat());
278    }
279
280    #[rstest]
281    fn test_is_long() {
282        let long_report = test_position_status_report_long();
283        let short_report = test_position_status_report_short();
284        let flat_report = test_position_status_report_flat();
285
286        assert!(long_report.is_long());
287        assert!(!short_report.is_long());
288        assert!(!flat_report.is_long());
289    }
290
291    #[rstest]
292    fn test_is_short() {
293        let long_report = test_position_status_report_long();
294        let short_report = test_position_status_report_short();
295        let flat_report = test_position_status_report_flat();
296
297        assert!(!long_report.is_short());
298        assert!(short_report.is_short());
299        assert!(!flat_report.is_short());
300    }
301
302    #[rstest]
303    fn test_display() {
304        let report = test_position_status_report_long();
305        let display_str = format!("{report}");
306
307        assert!(display_str.contains("PositionStatusReport"));
308        assert!(display_str.contains("SIM-001"));
309        assert!(display_str.contains("AUDUSD.SIM"));
310        assert!(display_str.contains("LONG"));
311        assert!(display_str.contains("100"));
312        assert!(display_str.contains("P-001"));
313        assert!(display_str.contains("avg_px_open=None"));
314    }
315
316    #[rstest]
317    fn test_clone_and_equality() {
318        let report1 = test_position_status_report_long();
319        let report2 = report1.clone();
320
321        assert_eq!(report1, report2);
322    }
323
324    #[rstest]
325    fn test_serialization_roundtrip() {
326        let original = test_position_status_report_long();
327
328        // Test JSON serialization
329        let json = serde_json::to_string(&original).unwrap();
330        let deserialized: PositionStatusReport = serde_json::from_str(&json).unwrap();
331        assert_eq!(original, deserialized);
332    }
333
334    #[rstest]
335    fn test_signed_decimal_qty_calculation() {
336        // Test with various quantities to ensure signed decimal calculation is correct
337        let long_100 = PositionStatusReport::new(
338            AccountId::from("SIM-001"),
339            InstrumentId::from("AUDUSD.SIM"),
340            PositionSideSpecified::Long,
341            Quantity::from("100.5"),
342            UnixNanos::from(1_000_000_000),
343            UnixNanos::from(2_000_000_000),
344            None,
345            None,
346            None,
347        );
348
349        let short_200 = PositionStatusReport::new(
350            AccountId::from("SIM-001"),
351            InstrumentId::from("AUDUSD.SIM"),
352            PositionSideSpecified::Short,
353            Quantity::from("200.75"),
354            UnixNanos::from(1_000_000_000),
355            UnixNanos::from(2_000_000_000),
356            None,
357            None,
358            None,
359        );
360
361        assert_eq!(
362            long_100.signed_decimal_qty,
363            Decimal::from_f64_retain(100.5).unwrap()
364        );
365        assert_eq!(
366            short_200.signed_decimal_qty,
367            Decimal::from_f64_retain(-200.75).unwrap()
368        );
369    }
370
371    #[rstest]
372    fn test_different_position_sides_not_equal() {
373        let long_report = test_position_status_report_long();
374        let short_report = PositionStatusReport::new(
375            AccountId::from("SIM-001"),
376            InstrumentId::from("AUDUSD.SIM"),
377            PositionSideSpecified::Short,
378            Quantity::from("100"), // Same quantity but different side
379            UnixNanos::from(1_000_000_000),
380            UnixNanos::from(2_000_000_000),
381            None,                            // report_id
382            Some(PositionId::from("P-001")), // venue_position_id
383            None,                            // avg_px_open
384        );
385
386        assert_ne!(long_report, short_report);
387        assert_ne!(
388            long_report.signed_decimal_qty,
389            short_report.signed_decimal_qty
390        );
391    }
392
393    #[rstest]
394    fn test_with_avg_px_open() {
395        let report = PositionStatusReport::new(
396            AccountId::from("SIM-001"),
397            InstrumentId::from("AUDUSD.SIM"),
398            PositionSideSpecified::Long,
399            Quantity::from("100"),
400            UnixNanos::from(1_000_000_000),
401            UnixNanos::from(2_000_000_000),
402            None,
403            Some(PositionId::from("P-001")),
404            Some(Decimal::from_str("1.23456").unwrap()),
405        );
406
407        assert_eq!(
408            report.avg_px_open,
409            Some(rust_decimal::Decimal::from_str("1.23456").unwrap())
410        );
411        assert!(format!("{report}").contains("avg_px_open=Some(1.23456)"));
412    }
413
414    #[rstest]
415    fn test_avg_px_open_none_default() {
416        let report = PositionStatusReport::new(
417            AccountId::from("SIM-001"),
418            InstrumentId::from("AUDUSD.SIM"),
419            PositionSideSpecified::Long,
420            Quantity::from("100"),
421            UnixNanos::from(1_000_000_000),
422            UnixNanos::from(2_000_000_000),
423            None,
424            None,
425            None, // avg_px_open is None
426        );
427
428        assert_eq!(report.avg_px_open, None);
429    }
430
431    #[rstest]
432    fn test_avg_px_open_with_different_sides() {
433        let long_with_price = PositionStatusReport::new(
434            AccountId::from("SIM-001"),
435            InstrumentId::from("AUDUSD.SIM"),
436            PositionSideSpecified::Long,
437            Quantity::from("100"),
438            UnixNanos::from(1_000_000_000),
439            UnixNanos::from(2_000_000_000),
440            None,
441            None,
442            Some(Decimal::from_str("1.50000").unwrap()),
443        );
444
445        let short_with_price = PositionStatusReport::new(
446            AccountId::from("SIM-001"),
447            InstrumentId::from("AUDUSD.SIM"),
448            PositionSideSpecified::Short,
449            Quantity::from("100"),
450            UnixNanos::from(1_000_000_000),
451            UnixNanos::from(2_000_000_000),
452            None,
453            None,
454            Some(Decimal::from_str("1.60000").unwrap()),
455        );
456
457        assert_eq!(
458            long_with_price.avg_px_open,
459            Some(rust_decimal::Decimal::from_str("1.50000").unwrap())
460        );
461        assert_eq!(
462            short_with_price.avg_px_open,
463            Some(rust_decimal::Decimal::from_str("1.60000").unwrap())
464        );
465    }
466
467    #[rstest]
468    fn test_avg_px_open_serialization() {
469        let report = PositionStatusReport::new(
470            AccountId::from("SIM-001"),
471            InstrumentId::from("AUDUSD.SIM"),
472            PositionSideSpecified::Long,
473            Quantity::from("100"),
474            UnixNanos::from(1_000_000_000),
475            UnixNanos::from(2_000_000_000),
476            None,
477            None,
478            Some(Decimal::from_str("1.99999").unwrap()),
479        );
480
481        let json = serde_json::to_string(&report).unwrap();
482        let deserialized: PositionStatusReport = serde_json::from_str(&json).unwrap();
483
484        assert_eq!(report.avg_px_open, deserialized.avg_px_open);
485    }
486}