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}
69
70impl PositionStatusReport {
71 #[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 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 #[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 #[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 #[must_use]
127 pub const fn has_venue_position_id(&self) -> bool {
128 self.venue_position_id.is_some()
129 }
130
131 #[must_use]
133 pub fn is_flat(&self) -> bool {
134 self.position_side == PositionSide::Flat
135 }
136
137 #[must_use]
139 pub fn is_long(&self) -> bool {
140 self.position_side == PositionSide::Long
141 }
142
143 #[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
169fn 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, Some(PositionId::from("P-001")), None, )
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, None,
286 None,
287 );
288
289 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 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 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"), UnixNanos::from(1_000_000_000),
426 UnixNanos::from(2_000_000_000),
427 None, Some(PositionId::from("P-001")), None, );
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, );
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}