Skip to main content

nautilus_model/reports/
position.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 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", from_py_object)
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!(long_100.signed_decimal_qty, dec!(100.5));
362        assert_eq!(short_200.signed_decimal_qty, dec!(-200.75));
363    }
364
365    #[rstest]
366    fn test_different_position_sides_not_equal() {
367        let long_report = test_position_status_report_long();
368        let short_report = PositionStatusReport::new(
369            AccountId::from("SIM-001"),
370            InstrumentId::from("AUDUSD.SIM"),
371            PositionSideSpecified::Short,
372            Quantity::from("100"), // Same quantity but different side
373            UnixNanos::from(1_000_000_000),
374            UnixNanos::from(2_000_000_000),
375            None,                            // report_id
376            Some(PositionId::from("P-001")), // venue_position_id
377            None,                            // avg_px_open
378        );
379
380        assert_ne!(long_report, short_report);
381        assert_ne!(
382            long_report.signed_decimal_qty,
383            short_report.signed_decimal_qty
384        );
385    }
386
387    #[rstest]
388    fn test_with_avg_px_open() {
389        let report = PositionStatusReport::new(
390            AccountId::from("SIM-001"),
391            InstrumentId::from("AUDUSD.SIM"),
392            PositionSideSpecified::Long,
393            Quantity::from("100"),
394            UnixNanos::from(1_000_000_000),
395            UnixNanos::from(2_000_000_000),
396            None,
397            Some(PositionId::from("P-001")),
398            Some(Decimal::from_str("1.23456").unwrap()),
399        );
400
401        assert_eq!(
402            report.avg_px_open,
403            Some(rust_decimal::Decimal::from_str("1.23456").unwrap())
404        );
405        assert!(format!("{report}").contains("avg_px_open=Some(1.23456)"));
406    }
407
408    #[rstest]
409    fn test_avg_px_open_none_default() {
410        let report = PositionStatusReport::new(
411            AccountId::from("SIM-001"),
412            InstrumentId::from("AUDUSD.SIM"),
413            PositionSideSpecified::Long,
414            Quantity::from("100"),
415            UnixNanos::from(1_000_000_000),
416            UnixNanos::from(2_000_000_000),
417            None,
418            None,
419            None, // avg_px_open is None
420        );
421
422        assert_eq!(report.avg_px_open, None);
423    }
424
425    #[rstest]
426    fn test_avg_px_open_with_different_sides() {
427        let long_with_price = PositionStatusReport::new(
428            AccountId::from("SIM-001"),
429            InstrumentId::from("AUDUSD.SIM"),
430            PositionSideSpecified::Long,
431            Quantity::from("100"),
432            UnixNanos::from(1_000_000_000),
433            UnixNanos::from(2_000_000_000),
434            None,
435            None,
436            Some(Decimal::from_str("1.50000").unwrap()),
437        );
438
439        let short_with_price = PositionStatusReport::new(
440            AccountId::from("SIM-001"),
441            InstrumentId::from("AUDUSD.SIM"),
442            PositionSideSpecified::Short,
443            Quantity::from("100"),
444            UnixNanos::from(1_000_000_000),
445            UnixNanos::from(2_000_000_000),
446            None,
447            None,
448            Some(Decimal::from_str("1.60000").unwrap()),
449        );
450
451        assert_eq!(
452            long_with_price.avg_px_open,
453            Some(rust_decimal::Decimal::from_str("1.50000").unwrap())
454        );
455        assert_eq!(
456            short_with_price.avg_px_open,
457            Some(rust_decimal::Decimal::from_str("1.60000").unwrap())
458        );
459    }
460
461    #[rstest]
462    fn test_avg_px_open_serialization() {
463        let report = PositionStatusReport::new(
464            AccountId::from("SIM-001"),
465            InstrumentId::from("AUDUSD.SIM"),
466            PositionSideSpecified::Long,
467            Quantity::from("100"),
468            UnixNanos::from(1_000_000_000),
469            UnixNanos::from(2_000_000_000),
470            None,
471            None,
472            Some(Decimal::from_str("1.99999").unwrap()),
473        );
474
475        let json = serde_json::to_string(&report).unwrap();
476        let deserialized: PositionStatusReport = serde_json::from_str(&json).unwrap();
477
478        assert_eq!(report.avg_px_open, deserialized.avg_px_open);
479    }
480}