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, Deserializer, Serialize};
21
22use crate::{
23    enums::{AvgPxReconciliation, PositionSide},
24    identifiers::{AccountId, InstrumentId, PositionId},
25    types::Quantity,
26};
27
28/// Represents a position status at a point in time.
29///
30/// When `avg_px_open_precision` is set, reconciliation accepts `avg_px_open` within one unit at
31/// that precision on top of its relative tolerance. A precision above 28 adds no allowance.
32#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(tag = "type")]
34#[cfg_attr(
35    feature = "python",
36    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
37)]
38#[cfg_attr(
39    feature = "python",
40    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
41)]
42pub struct PositionStatusReport {
43    /// The account ID associated with the position.
44    pub account_id: AccountId,
45    /// The instrument ID associated with the event.
46    pub instrument_id: InstrumentId,
47    /// The position side.
48    pub position_side: PositionSide,
49    /// The current open quantity.
50    pub quantity: Quantity,
51    /// The current signed quantity as a decimal (positive for position side `LONG`, negative for `SHORT`).
52    pub signed_decimal_qty: Decimal,
53    /// The unique identifier for the event.
54    pub report_id: UUID4,
55    /// UNIX timestamp (nanoseconds) when the last event occurred.
56    pub ts_last: UnixNanos,
57    /// UNIX timestamp (nanoseconds) when the event was initialized.
58    pub ts_init: UnixNanos,
59    /// The position ID (assigned by the venue).
60    pub venue_position_id: Option<PositionId>,
61    /// The reported average open price for the position.
62    pub avg_px_open: Option<Decimal>,
63    /// How position reconciliation may use `avg_px_open`.
64    #[serde(default, deserialize_with = "deserialize_avg_px_open_reconciliation")]
65    pub avg_px_open_reconciliation: AvgPxReconciliation,
66    /// The decimal places the venue kept for `avg_px_open`, when it rounds or truncates the average.
67    pub avg_px_open_precision: Option<u8>,
68}
69
70impl PositionStatusReport {
71    /// Creates a new [`PositionStatusReport`] instance with required fields.
72    #[expect(clippy::too_many_arguments)]
73    #[must_use]
74    pub fn new(
75        account_id: AccountId,
76        instrument_id: InstrumentId,
77        position_side: PositionSide,
78        quantity: Quantity,
79        ts_last: UnixNanos,
80        ts_init: UnixNanos,
81        report_id: Option<UUID4>,
82        venue_position_id: Option<PositionId>,
83        avg_px_open: Option<Decimal>,
84    ) -> Self {
85        // Calculate signed decimal quantity based on position side
86        let signed_decimal_qty = match position_side {
87            PositionSide::Long => quantity.as_decimal(),
88            PositionSide::Short => -quantity.as_decimal(),
89            PositionSide::Flat => Decimal::ZERO,
90        };
91
92        Self {
93            account_id,
94            instrument_id,
95            position_side,
96            quantity,
97            signed_decimal_qty,
98            report_id: report_id.unwrap_or_default(),
99            ts_last,
100            ts_init,
101            venue_position_id,
102            avg_px_open,
103            avg_px_open_reconciliation: AvgPxReconciliation::default(),
104            avg_px_open_precision: None,
105        }
106    }
107
108    /// Sets how position reconciliation may use the reported average open price.
109    #[must_use]
110    pub const fn with_avg_px_open_reconciliation(
111        mut self,
112        avg_px_open_reconciliation: AvgPxReconciliation,
113    ) -> Self {
114        self.avg_px_open_reconciliation = avg_px_open_reconciliation;
115        self
116    }
117
118    /// Sets the decimal places the venue kept for the reported average open price.
119    #[must_use]
120    pub const fn with_avg_px_open_precision(mut self, avg_px_open_precision: u8) -> Self {
121        self.avg_px_open_precision = Some(avg_px_open_precision);
122        self
123    }
124
125    /// Checks if the position has a venue position ID.
126    #[must_use]
127    pub const fn has_venue_position_id(&self) -> bool {
128        self.venue_position_id.is_some()
129    }
130
131    /// Checks if this is a flat position (quantity is zero).
132    #[must_use]
133    pub fn is_flat(&self) -> bool {
134        self.position_side == PositionSide::Flat
135    }
136
137    /// Checks if this is a long position.
138    #[must_use]
139    pub fn is_long(&self) -> bool {
140        self.position_side == PositionSide::Long
141    }
142
143    /// Checks if this is a short position.
144    #[must_use]
145    pub fn is_short(&self) -> bool {
146        self.position_side == PositionSide::Short
147    }
148}
149
150impl Display for PositionStatusReport {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        write!(
153            f,
154            "PositionStatusReport(account={}, instrument={}, side={}, qty={}, venue_pos_id={:?}, avg_px_open={:?}, avg_px_open_reconciliation={}, avg_px_open_precision={:?}, ts_last={}, ts_init={})",
155            self.account_id,
156            self.instrument_id,
157            self.position_side,
158            self.signed_decimal_qty,
159            self.venue_position_id,
160            self.avg_px_open,
161            self.avg_px_open_reconciliation,
162            self.avg_px_open_precision,
163            self.ts_last,
164            self.ts_init
165        )
166    }
167}
168
169// Arrow decoding yields null for rows from files written before the field existed
170fn deserialize_avg_px_open_reconciliation<'de, D>(
171    deserializer: D,
172) -> Result<AvgPxReconciliation, D::Error>
173where
174    D: Deserializer<'de>,
175{
176    Option::<AvgPxReconciliation>::deserialize(deserializer).map(Option::unwrap_or_default)
177}
178
179#[cfg(test)]
180mod tests {
181    use std::str::FromStr;
182
183    use nautilus_core::UnixNanos;
184    use rstest::*;
185    use rust_decimal::Decimal;
186    use rust_decimal_macros::dec;
187
188    use super::*;
189    use crate::{
190        identifiers::{AccountId, InstrumentId, PositionId},
191        types::Quantity,
192    };
193
194    fn test_position_report_long() -> PositionStatusReport {
195        PositionStatusReport::new(
196            AccountId::from("SIM-001"),
197            InstrumentId::from("AUDUSD.SIM"),
198            PositionSide::Long,
199            Quantity::from("100"),
200            UnixNanos::from(1_000_000_000),
201            UnixNanos::from(2_000_000_000),
202            None,                            // report_id
203            Some(PositionId::from("P-001")), // venue_position_id
204            None,                            // avg_px_open
205        )
206    }
207
208    fn test_position_report_short() -> PositionStatusReport {
209        PositionStatusReport::new(
210            AccountId::from("SIM-001"),
211            InstrumentId::from("AUDUSD.SIM"),
212            PositionSide::Short,
213            Quantity::from("50"),
214            UnixNanos::from(1_000_000_000),
215            UnixNanos::from(2_000_000_000),
216            None,
217            None,
218            None,
219        )
220    }
221
222    fn test_position_report_flat() -> PositionStatusReport {
223        PositionStatusReport::new(
224            AccountId::from("SIM-001"),
225            InstrumentId::from("AUDUSD.SIM"),
226            PositionSide::Flat,
227            Quantity::from("0"),
228            UnixNanos::from(1_000_000_000),
229            UnixNanos::from(2_000_000_000),
230            None,
231            None,
232            None,
233        )
234    }
235
236    #[rstest]
237    fn test_position_status_report_new_long() {
238        let report = test_position_report_long();
239
240        assert_eq!(report.account_id, AccountId::from("SIM-001"));
241        assert_eq!(report.instrument_id, InstrumentId::from("AUDUSD.SIM"));
242        assert_eq!(report.position_side, PositionSide::Long);
243        assert_eq!(report.quantity, Quantity::from("100"));
244        assert_eq!(report.signed_decimal_qty, dec!(100));
245        assert_eq!(report.venue_position_id, Some(PositionId::from("P-001")));
246        assert_eq!(report.ts_last, UnixNanos::from(1_000_000_000));
247        assert_eq!(report.ts_init, UnixNanos::from(2_000_000_000));
248        assert_eq!(report.avg_px_open, None);
249        assert_eq!(
250            report.avg_px_open_reconciliation,
251            AvgPxReconciliation::Match
252        );
253        assert_eq!(report.avg_px_open_precision, None);
254    }
255
256    #[rstest]
257    fn test_position_status_report_new_short() {
258        let report = test_position_report_short();
259
260        assert_eq!(report.position_side, PositionSide::Short);
261        assert_eq!(report.quantity, Quantity::from("50"));
262        assert_eq!(report.signed_decimal_qty, dec!(-50));
263        assert_eq!(report.venue_position_id, None);
264    }
265
266    #[rstest]
267    fn test_position_status_report_new_flat() {
268        let report = test_position_report_flat();
269
270        assert_eq!(report.position_side, PositionSide::Flat);
271        assert_eq!(report.quantity, Quantity::from("0"));
272        assert_eq!(report.signed_decimal_qty, Decimal::ZERO);
273    }
274
275    #[rstest]
276    fn test_position_status_report_with_generated_report_id() {
277        let report = PositionStatusReport::new(
278            AccountId::from("SIM-001"),
279            InstrumentId::from("AUDUSD.SIM"),
280            PositionSide::Long,
281            Quantity::from("100"),
282            UnixNanos::from(1_000_000_000),
283            UnixNanos::from(2_000_000_000),
284            None, // No report ID provided, should generate one
285            None,
286            None,
287        );
288
289        // Should have a generated UUID
290        assert_ne!(
291            report.report_id.to_string(),
292            "00000000-0000-0000-0000-000000000000"
293        );
294    }
295
296    #[rstest]
297    fn test_has_venue_position_id() {
298        let mut report = test_position_report_long();
299        assert!(report.has_venue_position_id());
300
301        report.venue_position_id = None;
302        assert!(!report.has_venue_position_id());
303    }
304
305    #[rstest]
306    fn test_is_flat() {
307        let long_report = test_position_report_long();
308        let short_report = test_position_report_short();
309        let flat_report = test_position_report_flat();
310
311        let no_position_report = PositionStatusReport::new(
312            AccountId::from("SIM-001"),
313            InstrumentId::from("AUDUSD.SIM"),
314            PositionSide::Flat,
315            Quantity::from("0"),
316            UnixNanos::from(1_000_000_000),
317            UnixNanos::from(2_000_000_000),
318            None,
319            None,
320            None,
321        );
322
323        assert!(!long_report.is_flat());
324        assert!(!short_report.is_flat());
325        assert!(flat_report.is_flat());
326        assert!(no_position_report.is_flat());
327    }
328
329    #[rstest]
330    fn test_is_long() {
331        let long_report = test_position_report_long();
332        let short_report = test_position_report_short();
333        let flat_report = test_position_report_flat();
334
335        assert!(long_report.is_long());
336        assert!(!short_report.is_long());
337        assert!(!flat_report.is_long());
338    }
339
340    #[rstest]
341    fn test_is_short() {
342        let long_report = test_position_report_long();
343        let short_report = test_position_report_short();
344        let flat_report = test_position_report_flat();
345
346        assert!(!long_report.is_short());
347        assert!(short_report.is_short());
348        assert!(!flat_report.is_short());
349    }
350
351    #[rstest]
352    fn test_display() {
353        let report = test_position_report_long();
354        let display_str = format!("{report}");
355
356        assert!(display_str.contains("PositionStatusReport"));
357        assert!(display_str.contains("SIM-001"));
358        assert!(display_str.contains("AUDUSD.SIM"));
359        assert!(display_str.contains("LONG"));
360        assert!(display_str.contains("100"));
361        assert!(display_str.contains("P-001"));
362        assert!(display_str.contains("avg_px_open=None"));
363        assert!(display_str.contains("avg_px_open_reconciliation=MATCH"));
364        assert!(display_str.contains("avg_px_open_precision=None"));
365    }
366
367    #[rstest]
368    fn test_clone_and_equality() {
369        let report1 = test_position_report_long();
370        let report2 = report1.clone();
371
372        assert_eq!(report1, report2);
373    }
374
375    #[rstest]
376    fn test_serialization_roundtrip() {
377        let original = test_position_report_long();
378
379        // Test JSON serialization
380        let json = serde_json::to_string(&original).unwrap();
381        let deserialized: PositionStatusReport = serde_json::from_str(&json).unwrap();
382        assert_eq!(original, deserialized);
383    }
384
385    #[rstest]
386    fn test_signed_decimal_qty_calculation() {
387        // Test with various quantities to ensure signed decimal calculation is correct
388        let long_100 = PositionStatusReport::new(
389            AccountId::from("SIM-001"),
390            InstrumentId::from("AUDUSD.SIM"),
391            PositionSide::Long,
392            Quantity::from("100.5"),
393            UnixNanos::from(1_000_000_000),
394            UnixNanos::from(2_000_000_000),
395            None,
396            None,
397            None,
398        );
399
400        let short_200 = PositionStatusReport::new(
401            AccountId::from("SIM-001"),
402            InstrumentId::from("AUDUSD.SIM"),
403            PositionSide::Short,
404            Quantity::from("200.75"),
405            UnixNanos::from(1_000_000_000),
406            UnixNanos::from(2_000_000_000),
407            None,
408            None,
409            None,
410        );
411
412        assert_eq!(long_100.signed_decimal_qty, dec!(100.5));
413        assert_eq!(short_200.signed_decimal_qty, dec!(-200.75));
414    }
415
416    #[rstest]
417    fn test_different_position_sides_not_equal() {
418        let long_report = test_position_report_long();
419
420        let short_report = PositionStatusReport::new(
421            AccountId::from("SIM-001"),
422            InstrumentId::from("AUDUSD.SIM"),
423            PositionSide::Short,
424            Quantity::from("100"), // Same quantity but different side
425            UnixNanos::from(1_000_000_000),
426            UnixNanos::from(2_000_000_000),
427            None,                            // report_id
428            Some(PositionId::from("P-001")), // venue_position_id
429            None,                            // avg_px_open
430        );
431
432        assert_ne!(long_report, short_report);
433        assert_ne!(
434            long_report.signed_decimal_qty,
435            short_report.signed_decimal_qty
436        );
437    }
438
439    #[rstest]
440    fn test_with_avg_px_open() {
441        let report = PositionStatusReport::new(
442            AccountId::from("SIM-001"),
443            InstrumentId::from("AUDUSD.SIM"),
444            PositionSide::Long,
445            Quantity::from("100"),
446            UnixNanos::from(1_000_000_000),
447            UnixNanos::from(2_000_000_000),
448            None,
449            Some(PositionId::from("P-001")),
450            Some(Decimal::from_str("1.23456").unwrap()),
451        );
452
453        assert_eq!(
454            report.avg_px_open,
455            Some(rust_decimal::Decimal::from_str("1.23456").unwrap())
456        );
457        assert!(format!("{report}").contains("avg_px_open=Some(1.23456)"));
458    }
459
460    #[rstest]
461    fn test_avg_px_open_none_default() {
462        let report = PositionStatusReport::new(
463            AccountId::from("SIM-001"),
464            InstrumentId::from("AUDUSD.SIM"),
465            PositionSide::Long,
466            Quantity::from("100"),
467            UnixNanos::from(1_000_000_000),
468            UnixNanos::from(2_000_000_000),
469            None,
470            None,
471            None, // avg_px_open is None
472        );
473
474        assert_eq!(report.avg_px_open, None);
475    }
476
477    #[rstest]
478    fn test_avg_px_open_with_different_sides() {
479        let long_with_price = PositionStatusReport::new(
480            AccountId::from("SIM-001"),
481            InstrumentId::from("AUDUSD.SIM"),
482            PositionSide::Long,
483            Quantity::from("100"),
484            UnixNanos::from(1_000_000_000),
485            UnixNanos::from(2_000_000_000),
486            None,
487            None,
488            Some(Decimal::from_str("1.50000").unwrap()),
489        );
490
491        let short_with_price = PositionStatusReport::new(
492            AccountId::from("SIM-001"),
493            InstrumentId::from("AUDUSD.SIM"),
494            PositionSide::Short,
495            Quantity::from("100"),
496            UnixNanos::from(1_000_000_000),
497            UnixNanos::from(2_000_000_000),
498            None,
499            None,
500            Some(Decimal::from_str("1.60000").unwrap()),
501        );
502
503        assert_eq!(
504            long_with_price.avg_px_open,
505            Some(rust_decimal::Decimal::from_str("1.50000").unwrap())
506        );
507        assert_eq!(
508            short_with_price.avg_px_open,
509            Some(rust_decimal::Decimal::from_str("1.60000").unwrap())
510        );
511    }
512
513    #[rstest]
514    fn test_avg_px_open_serialization() {
515        let report = PositionStatusReport::new(
516            AccountId::from("SIM-001"),
517            InstrumentId::from("AUDUSD.SIM"),
518            PositionSide::Long,
519            Quantity::from("100"),
520            UnixNanos::from(1_000_000_000),
521            UnixNanos::from(2_000_000_000),
522            None,
523            None,
524            Some(Decimal::from_str("1.99999").unwrap()),
525        );
526
527        let json = serde_json::to_string(&report).unwrap();
528        let deserialized: PositionStatusReport = serde_json::from_str(&json).unwrap();
529
530        assert_eq!(report.avg_px_open, deserialized.avg_px_open);
531    }
532
533    #[rstest]
534    fn test_with_avg_px_open_metadata() {
535        let report = test_position_report_long()
536            .with_avg_px_open_reconciliation(AvgPxReconciliation::OpeningOnly)
537            .with_avg_px_open_precision(4);
538
539        assert_eq!(
540            report.avg_px_open_reconciliation,
541            AvgPxReconciliation::OpeningOnly
542        );
543        assert_eq!(report.avg_px_open_precision, Some(4));
544        assert!(
545            format!("{report}")
546                .contains("avg_px_open_reconciliation=OPENING_ONLY, avg_px_open_precision=Some(4)")
547        );
548    }
549
550    #[rstest]
551    fn test_avg_px_open_metadata_serialization_roundtrip() {
552        let report = test_position_report_long()
553            .with_avg_px_open_reconciliation(AvgPxReconciliation::OpeningOnly)
554            .with_avg_px_open_precision(4);
555
556        let json = serde_json::to_string(&report).unwrap();
557        let deserialized: PositionStatusReport = serde_json::from_str(&json).unwrap();
558
559        assert_eq!(deserialized, report);
560    }
561
562    #[rstest]
563    #[case::missing(None)]
564    #[case::null(Some(serde_json::Value::Null))]
565    fn test_deserialize_without_avg_px_open_metadata_uses_defaults(
566        #[case] value: Option<serde_json::Value>,
567    ) {
568        let report = test_position_report_long();
569        let mut json = serde_json::to_value(&report).unwrap();
570        let object = json.as_object_mut().unwrap();
571        object.remove("avg_px_open_reconciliation");
572        object.remove("avg_px_open_precision");
573
574        if let Some(value) = value {
575            object.insert("avg_px_open_reconciliation".to_string(), value.clone());
576            object.insert("avg_px_open_precision".to_string(), value);
577        }
578
579        let deserialized: PositionStatusReport = serde_json::from_value(json).unwrap();
580
581        assert_eq!(deserialized, report);
582    }
583
584    #[rstest]
585    fn test_deserialize_rejects_unknown_avg_px_open_reconciliation() {
586        let mut json = serde_json::to_value(test_position_report_long()).unwrap();
587        json.as_object_mut().unwrap().insert(
588            "avg_px_open_reconciliation".to_string(),
589            serde_json::Value::String("FIFO".to_string()),
590        );
591
592        let result = serde_json::from_value::<PositionStatusReport>(json);
593
594        assert_eq!(
595            result.unwrap_err().to_string(),
596            "Matching variant not found"
597        );
598    }
599}