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}
55
56impl PositionStatusReport {
57    /// Creates a new [`PositionStatusReport`] instance with required fields.
58    #[allow(clippy::too_many_arguments)]
59    #[must_use]
60    pub fn new(
61        account_id: AccountId,
62        instrument_id: InstrumentId,
63        position_side: PositionSideSpecified,
64        quantity: Quantity,
65        venue_position_id: Option<PositionId>,
66        ts_last: UnixNanos,
67        ts_init: UnixNanos,
68        report_id: Option<UUID4>,
69    ) -> Self {
70        // Calculate signed decimal quantity based on position side
71        let signed_decimal_qty = match position_side {
72            PositionSideSpecified::Long => quantity.as_decimal(),
73            PositionSideSpecified::Short => -quantity.as_decimal(),
74            PositionSideSpecified::Flat => Decimal::ZERO,
75        };
76
77        Self {
78            account_id,
79            instrument_id,
80            position_side,
81            quantity,
82            signed_decimal_qty,
83            report_id: report_id.unwrap_or_default(),
84            ts_last,
85            ts_init,
86            venue_position_id,
87        }
88    }
89
90    /// Checks if the position has a venue position ID.
91    #[must_use]
92    pub const fn has_venue_position_id(&self) -> bool {
93        self.venue_position_id.is_some()
94    }
95
96    /// Checks if this is a flat position (quantity is zero).
97    #[must_use]
98    pub fn is_flat(&self) -> bool {
99        self.position_side == PositionSideSpecified::Flat
100    }
101
102    /// Checks if this is a long position.
103    #[must_use]
104    pub fn is_long(&self) -> bool {
105        self.position_side == PositionSideSpecified::Long
106    }
107
108    /// Checks if this is a short position.
109    #[must_use]
110    pub fn is_short(&self) -> bool {
111        self.position_side == PositionSideSpecified::Short
112    }
113}
114
115impl Display for PositionStatusReport {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        write!(
118            f,
119            "PositionStatusReport(account={}, instrument={}, side={}, qty={}, venue_pos_id={:?}, ts_last={}, ts_init={})",
120            self.account_id,
121            self.instrument_id,
122            self.position_side,
123            self.signed_decimal_qty,
124            self.venue_position_id,
125            self.ts_last,
126            self.ts_init
127        )
128    }
129}
130
131////////////////////////////////////////////////////////////////////////////////
132// Tests
133////////////////////////////////////////////////////////////////////////////////
134#[cfg(test)]
135mod tests {
136    use nautilus_core::UnixNanos;
137    use rstest::*;
138    use rust_decimal::Decimal;
139
140    use super::*;
141    use crate::{
142        identifiers::{AccountId, InstrumentId, PositionId},
143        types::Quantity,
144    };
145
146    fn test_position_status_report_long() -> PositionStatusReport {
147        PositionStatusReport::new(
148            AccountId::from("SIM-001"),
149            InstrumentId::from("AUDUSD.SIM"),
150            PositionSideSpecified::Long,
151            Quantity::from("100"),
152            Some(PositionId::from("P-001")),
153            UnixNanos::from(1_000_000_000),
154            UnixNanos::from(2_000_000_000),
155            None,
156        )
157    }
158
159    fn test_position_status_report_short() -> PositionStatusReport {
160        PositionStatusReport::new(
161            AccountId::from("SIM-001"),
162            InstrumentId::from("AUDUSD.SIM"),
163            PositionSideSpecified::Short,
164            Quantity::from("50"),
165            None,
166            UnixNanos::from(1_000_000_000),
167            UnixNanos::from(2_000_000_000),
168            None,
169        )
170    }
171
172    fn test_position_status_report_flat() -> PositionStatusReport {
173        PositionStatusReport::new(
174            AccountId::from("SIM-001"),
175            InstrumentId::from("AUDUSD.SIM"),
176            PositionSideSpecified::Flat,
177            Quantity::from("0"),
178            None,
179            UnixNanos::from(1_000_000_000),
180            UnixNanos::from(2_000_000_000),
181            None,
182        )
183    }
184
185    #[rstest]
186    fn test_position_status_report_new_long() {
187        let report = test_position_status_report_long();
188
189        assert_eq!(report.account_id, AccountId::from("SIM-001"));
190        assert_eq!(report.instrument_id, InstrumentId::from("AUDUSD.SIM"));
191        assert_eq!(report.position_side, PositionSideSpecified::Long);
192        assert_eq!(report.quantity, Quantity::from("100"));
193        assert_eq!(report.signed_decimal_qty, Decimal::from(100));
194        assert_eq!(report.venue_position_id, Some(PositionId::from("P-001")));
195        assert_eq!(report.ts_last, UnixNanos::from(1_000_000_000));
196        assert_eq!(report.ts_init, UnixNanos::from(2_000_000_000));
197    }
198
199    #[rstest]
200    fn test_position_status_report_new_short() {
201        let report = test_position_status_report_short();
202
203        assert_eq!(report.position_side, PositionSideSpecified::Short);
204        assert_eq!(report.quantity, Quantity::from("50"));
205        assert_eq!(report.signed_decimal_qty, Decimal::from(-50));
206        assert_eq!(report.venue_position_id, None);
207    }
208
209    #[rstest]
210    fn test_position_status_report_new_flat() {
211        let report = test_position_status_report_flat();
212
213        assert_eq!(report.position_side, PositionSideSpecified::Flat);
214        assert_eq!(report.quantity, Quantity::from("0"));
215        assert_eq!(report.signed_decimal_qty, Decimal::ZERO);
216    }
217
218    #[rstest]
219    fn test_position_status_report_with_generated_report_id() {
220        let report = PositionStatusReport::new(
221            AccountId::from("SIM-001"),
222            InstrumentId::from("AUDUSD.SIM"),
223            PositionSideSpecified::Long,
224            Quantity::from("100"),
225            None,
226            UnixNanos::from(1_000_000_000),
227            UnixNanos::from(2_000_000_000),
228            None, // No report ID provided, should generate one
229        );
230
231        // Should have a generated UUID
232        assert_ne!(
233            report.report_id.to_string(),
234            "00000000-0000-0000-0000-000000000000"
235        );
236    }
237
238    #[rstest]
239    fn test_has_venue_position_id() {
240        let mut report = test_position_status_report_long();
241        assert!(report.has_venue_position_id());
242
243        report.venue_position_id = None;
244        assert!(!report.has_venue_position_id());
245    }
246
247    #[rstest]
248    fn test_is_flat() {
249        let long_report = test_position_status_report_long();
250        let short_report = test_position_status_report_short();
251        let flat_report = test_position_status_report_flat();
252
253        let no_position_report = PositionStatusReport::new(
254            AccountId::from("SIM-001"),
255            InstrumentId::from("AUDUSD.SIM"),
256            PositionSideSpecified::Flat,
257            Quantity::from("0"),
258            None,
259            UnixNanos::from(1_000_000_000),
260            UnixNanos::from(2_000_000_000),
261            None,
262        );
263
264        assert!(!long_report.is_flat());
265        assert!(!short_report.is_flat());
266        assert!(flat_report.is_flat());
267        assert!(no_position_report.is_flat());
268    }
269
270    #[rstest]
271    fn test_is_long() {
272        let long_report = test_position_status_report_long();
273        let short_report = test_position_status_report_short();
274        let flat_report = test_position_status_report_flat();
275
276        assert!(long_report.is_long());
277        assert!(!short_report.is_long());
278        assert!(!flat_report.is_long());
279    }
280
281    #[rstest]
282    fn test_is_short() {
283        let long_report = test_position_status_report_long();
284        let short_report = test_position_status_report_short();
285        let flat_report = test_position_status_report_flat();
286
287        assert!(!long_report.is_short());
288        assert!(short_report.is_short());
289        assert!(!flat_report.is_short());
290    }
291
292    #[rstest]
293    fn test_display() {
294        let report = test_position_status_report_long();
295        let display_str = format!("{report}");
296
297        assert!(display_str.contains("PositionStatusReport"));
298        assert!(display_str.contains("SIM-001"));
299        assert!(display_str.contains("AUDUSD.SIM"));
300        assert!(display_str.contains("LONG"));
301        assert!(display_str.contains("100"));
302        assert!(display_str.contains("P-001"));
303    }
304
305    #[rstest]
306    fn test_clone_and_equality() {
307        let report1 = test_position_status_report_long();
308        let report2 = report1.clone();
309
310        assert_eq!(report1, report2);
311    }
312
313    #[rstest]
314    fn test_serialization_roundtrip() {
315        let original = test_position_status_report_long();
316
317        // Test JSON serialization
318        let json = serde_json::to_string(&original).unwrap();
319        let deserialized: PositionStatusReport = serde_json::from_str(&json).unwrap();
320        assert_eq!(original, deserialized);
321    }
322
323    #[rstest]
324    fn test_signed_decimal_qty_calculation() {
325        // Test with various quantities to ensure signed decimal calculation is correct
326        let long_100 = PositionStatusReport::new(
327            AccountId::from("SIM-001"),
328            InstrumentId::from("AUDUSD.SIM"),
329            PositionSideSpecified::Long,
330            Quantity::from("100.5"),
331            None,
332            UnixNanos::from(1_000_000_000),
333            UnixNanos::from(2_000_000_000),
334            None,
335        );
336
337        let short_200 = PositionStatusReport::new(
338            AccountId::from("SIM-001"),
339            InstrumentId::from("AUDUSD.SIM"),
340            PositionSideSpecified::Short,
341            Quantity::from("200.75"),
342            None,
343            UnixNanos::from(1_000_000_000),
344            UnixNanos::from(2_000_000_000),
345            None,
346        );
347
348        assert_eq!(
349            long_100.signed_decimal_qty,
350            Decimal::from_f64_retain(100.5).unwrap()
351        );
352        assert_eq!(
353            short_200.signed_decimal_qty,
354            Decimal::from_f64_retain(-200.75).unwrap()
355        );
356    }
357
358    #[rstest]
359    fn test_different_position_sides_not_equal() {
360        let long_report = test_position_status_report_long();
361        let short_report = PositionStatusReport::new(
362            AccountId::from("SIM-001"),
363            InstrumentId::from("AUDUSD.SIM"),
364            PositionSideSpecified::Short,
365            Quantity::from("100"), // Same quantity but different side
366            Some(PositionId::from("P-001")),
367            UnixNanos::from(1_000_000_000),
368            UnixNanos::from(2_000_000_000),
369            None,
370        );
371
372        assert_ne!(long_report, short_report);
373        assert_ne!(
374            long_report.signed_decimal_qty,
375            short_report.signed_decimal_qty
376        );
377    }
378}