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