1use 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#[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 pub account_id: AccountId,
45 pub instrument_id: InstrumentId,
47 pub position_side: PositionSide,
49 pub quantity: Quantity,
51 pub signed_decimal_qty: Decimal,
53 pub report_id: UUID4,
55 pub ts_last: UnixNanos,
57 pub ts_init: UnixNanos,
59 pub venue_position_id: Option<PositionId>,
61 pub avg_px_open: Option<Decimal>,
63 #[serde(default, deserialize_with = "deserialize_avg_px_open_reconciliation")]
65 pub avg_px_open_reconciliation: AvgPxReconciliation,
66 pub avg_px_open_precision: Option<u8>,
68 pub liquidation_price: Option<Decimal>,
70}
71
72impl PositionStatusReport {
73 #[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 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 #[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 #[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 #[must_use]
130 pub const fn has_venue_position_id(&self) -> bool {
131 self.venue_position_id.is_some()
132 }
133
134 #[must_use]
136 pub fn is_flat(&self) -> bool {
137 self.position_side == PositionSide::Flat
138 }
139
140 #[must_use]
142 pub fn is_long(&self) -> bool {
143 self.position_side == PositionSide::Long
144 }
145
146 #[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
173fn 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, Some(PositionId::from("P-001")), None, )
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, None,
291 None,
292 );
293
294 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 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 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"), UnixNanos::from(1_000_000_000),
432 UnixNanos::from(2_000_000_000),
433 None, Some(PositionId::from("P-001")), None, );
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, );
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}