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