1use 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#[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 pub account_id: AccountId,
38 pub instrument_id: InstrumentId,
40 pub position_side: PositionSideSpecified,
42 pub quantity: Quantity,
44 pub signed_decimal_qty: Decimal,
46 pub report_id: UUID4,
48 pub ts_last: UnixNanos,
50 pub ts_init: UnixNanos,
52 pub venue_position_id: Option<PositionId>,
54 pub avg_px_open: Option<Decimal>,
56}
57
58impl PositionStatusReport {
59 #[allow(clippy::too_many_arguments)]
61 #[must_use]
62 pub fn new(
63 account_id: AccountId,
64 instrument_id: InstrumentId,
65 position_side: PositionSideSpecified,
66 quantity: Quantity,
67 ts_last: UnixNanos,
68 ts_init: UnixNanos,
69 report_id: Option<UUID4>,
70 venue_position_id: Option<PositionId>,
71 avg_px_open: Option<Decimal>,
72 ) -> Self {
73 let signed_decimal_qty = match position_side {
75 PositionSideSpecified::Long => quantity.as_decimal(),
76 PositionSideSpecified::Short => -quantity.as_decimal(),
77 PositionSideSpecified::Flat => Decimal::ZERO,
78 };
79
80 Self {
81 account_id,
82 instrument_id,
83 position_side,
84 quantity,
85 signed_decimal_qty,
86 report_id: report_id.unwrap_or_default(),
87 ts_last,
88 ts_init,
89 venue_position_id,
90 avg_px_open,
91 }
92 }
93
94 #[must_use]
96 pub const fn has_venue_position_id(&self) -> bool {
97 self.venue_position_id.is_some()
98 }
99
100 #[must_use]
102 pub fn is_flat(&self) -> bool {
103 self.position_side == PositionSideSpecified::Flat
104 }
105
106 #[must_use]
108 pub fn is_long(&self) -> bool {
109 self.position_side == PositionSideSpecified::Long
110 }
111
112 #[must_use]
114 pub fn is_short(&self) -> bool {
115 self.position_side == PositionSideSpecified::Short
116 }
117}
118
119impl Display for PositionStatusReport {
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 write!(
122 f,
123 "PositionStatusReport(account={}, instrument={}, side={}, qty={}, venue_pos_id={:?}, avg_px_open={:?}, ts_last={}, ts_init={})",
124 self.account_id,
125 self.instrument_id,
126 self.position_side,
127 self.signed_decimal_qty,
128 self.venue_position_id,
129 self.avg_px_open,
130 self.ts_last,
131 self.ts_init
132 )
133 }
134}
135
136#[cfg(test)]
140mod tests {
141 use std::str::FromStr;
142
143 use nautilus_core::UnixNanos;
144 use rstest::*;
145 use rust_decimal::Decimal;
146
147 use super::*;
148 use crate::{
149 identifiers::{AccountId, InstrumentId, PositionId},
150 types::Quantity,
151 };
152
153 fn test_position_status_report_long() -> PositionStatusReport {
154 PositionStatusReport::new(
155 AccountId::from("SIM-001"),
156 InstrumentId::from("AUDUSD.SIM"),
157 PositionSideSpecified::Long,
158 Quantity::from("100"),
159 UnixNanos::from(1_000_000_000),
160 UnixNanos::from(2_000_000_000),
161 None, Some(PositionId::from("P-001")), None, )
165 }
166
167 fn test_position_status_report_short() -> PositionStatusReport {
168 PositionStatusReport::new(
169 AccountId::from("SIM-001"),
170 InstrumentId::from("AUDUSD.SIM"),
171 PositionSideSpecified::Short,
172 Quantity::from("50"),
173 UnixNanos::from(1_000_000_000),
174 UnixNanos::from(2_000_000_000),
175 None,
176 None,
177 None,
178 )
179 }
180
181 fn test_position_status_report_flat() -> PositionStatusReport {
182 PositionStatusReport::new(
183 AccountId::from("SIM-001"),
184 InstrumentId::from("AUDUSD.SIM"),
185 PositionSideSpecified::Flat,
186 Quantity::from("0"),
187 UnixNanos::from(1_000_000_000),
188 UnixNanos::from(2_000_000_000),
189 None,
190 None,
191 None,
192 )
193 }
194
195 #[rstest]
196 fn test_position_status_report_new_long() {
197 let report = test_position_status_report_long();
198
199 assert_eq!(report.account_id, AccountId::from("SIM-001"));
200 assert_eq!(report.instrument_id, InstrumentId::from("AUDUSD.SIM"));
201 assert_eq!(report.position_side, PositionSideSpecified::Long);
202 assert_eq!(report.quantity, Quantity::from("100"));
203 assert_eq!(report.signed_decimal_qty, Decimal::from(100));
204 assert_eq!(report.venue_position_id, Some(PositionId::from("P-001")));
205 assert_eq!(report.ts_last, UnixNanos::from(1_000_000_000));
206 assert_eq!(report.ts_init, UnixNanos::from(2_000_000_000));
207 }
208
209 #[rstest]
210 fn test_position_status_report_new_short() {
211 let report = test_position_status_report_short();
212
213 assert_eq!(report.position_side, PositionSideSpecified::Short);
214 assert_eq!(report.quantity, Quantity::from("50"));
215 assert_eq!(report.signed_decimal_qty, Decimal::from(-50));
216 assert_eq!(report.venue_position_id, None);
217 }
218
219 #[rstest]
220 fn test_position_status_report_new_flat() {
221 let report = test_position_status_report_flat();
222
223 assert_eq!(report.position_side, PositionSideSpecified::Flat);
224 assert_eq!(report.quantity, Quantity::from("0"));
225 assert_eq!(report.signed_decimal_qty, Decimal::ZERO);
226 }
227
228 #[rstest]
229 fn test_position_status_report_with_generated_report_id() {
230 let report = PositionStatusReport::new(
231 AccountId::from("SIM-001"),
232 InstrumentId::from("AUDUSD.SIM"),
233 PositionSideSpecified::Long,
234 Quantity::from("100"),
235 UnixNanos::from(1_000_000_000),
236 UnixNanos::from(2_000_000_000),
237 None, None,
239 None,
240 );
241
242 assert_ne!(
244 report.report_id.to_string(),
245 "00000000-0000-0000-0000-000000000000"
246 );
247 }
248
249 #[rstest]
250 fn test_has_venue_position_id() {
251 let mut report = test_position_status_report_long();
252 assert!(report.has_venue_position_id());
253
254 report.venue_position_id = None;
255 assert!(!report.has_venue_position_id());
256 }
257
258 #[rstest]
259 fn test_is_flat() {
260 let long_report = test_position_status_report_long();
261 let short_report = test_position_status_report_short();
262 let flat_report = test_position_status_report_flat();
263
264 let no_position_report = PositionStatusReport::new(
265 AccountId::from("SIM-001"),
266 InstrumentId::from("AUDUSD.SIM"),
267 PositionSideSpecified::Flat,
268 Quantity::from("0"),
269 UnixNanos::from(1_000_000_000),
270 UnixNanos::from(2_000_000_000),
271 None,
272 None,
273 None,
274 );
275
276 assert!(!long_report.is_flat());
277 assert!(!short_report.is_flat());
278 assert!(flat_report.is_flat());
279 assert!(no_position_report.is_flat());
280 }
281
282 #[rstest]
283 fn test_is_long() {
284 let long_report = test_position_status_report_long();
285 let short_report = test_position_status_report_short();
286 let flat_report = test_position_status_report_flat();
287
288 assert!(long_report.is_long());
289 assert!(!short_report.is_long());
290 assert!(!flat_report.is_long());
291 }
292
293 #[rstest]
294 fn test_is_short() {
295 let long_report = test_position_status_report_long();
296 let short_report = test_position_status_report_short();
297 let flat_report = test_position_status_report_flat();
298
299 assert!(!long_report.is_short());
300 assert!(short_report.is_short());
301 assert!(!flat_report.is_short());
302 }
303
304 #[rstest]
305 fn test_display() {
306 let report = test_position_status_report_long();
307 let display_str = format!("{report}");
308
309 assert!(display_str.contains("PositionStatusReport"));
310 assert!(display_str.contains("SIM-001"));
311 assert!(display_str.contains("AUDUSD.SIM"));
312 assert!(display_str.contains("LONG"));
313 assert!(display_str.contains("100"));
314 assert!(display_str.contains("P-001"));
315 assert!(display_str.contains("avg_px_open=None"));
316 }
317
318 #[rstest]
319 fn test_clone_and_equality() {
320 let report1 = test_position_status_report_long();
321 let report2 = report1.clone();
322
323 assert_eq!(report1, report2);
324 }
325
326 #[rstest]
327 fn test_serialization_roundtrip() {
328 let original = test_position_status_report_long();
329
330 let json = serde_json::to_string(&original).unwrap();
332 let deserialized: PositionStatusReport = serde_json::from_str(&json).unwrap();
333 assert_eq!(original, deserialized);
334 }
335
336 #[rstest]
337 fn test_signed_decimal_qty_calculation() {
338 let long_100 = PositionStatusReport::new(
340 AccountId::from("SIM-001"),
341 InstrumentId::from("AUDUSD.SIM"),
342 PositionSideSpecified::Long,
343 Quantity::from("100.5"),
344 UnixNanos::from(1_000_000_000),
345 UnixNanos::from(2_000_000_000),
346 None,
347 None,
348 None,
349 );
350
351 let short_200 = PositionStatusReport::new(
352 AccountId::from("SIM-001"),
353 InstrumentId::from("AUDUSD.SIM"),
354 PositionSideSpecified::Short,
355 Quantity::from("200.75"),
356 UnixNanos::from(1_000_000_000),
357 UnixNanos::from(2_000_000_000),
358 None,
359 None,
360 None,
361 );
362
363 assert_eq!(
364 long_100.signed_decimal_qty,
365 Decimal::from_f64_retain(100.5).unwrap()
366 );
367 assert_eq!(
368 short_200.signed_decimal_qty,
369 Decimal::from_f64_retain(-200.75).unwrap()
370 );
371 }
372
373 #[rstest]
374 fn test_different_position_sides_not_equal() {
375 let long_report = test_position_status_report_long();
376 let short_report = PositionStatusReport::new(
377 AccountId::from("SIM-001"),
378 InstrumentId::from("AUDUSD.SIM"),
379 PositionSideSpecified::Short,
380 Quantity::from("100"), UnixNanos::from(1_000_000_000),
382 UnixNanos::from(2_000_000_000),
383 None, Some(PositionId::from("P-001")), None, );
387
388 assert_ne!(long_report, short_report);
389 assert_ne!(
390 long_report.signed_decimal_qty,
391 short_report.signed_decimal_qty
392 );
393 }
394
395 #[rstest]
396 fn test_with_avg_px_open() {
397 let report = PositionStatusReport::new(
398 AccountId::from("SIM-001"),
399 InstrumentId::from("AUDUSD.SIM"),
400 PositionSideSpecified::Long,
401 Quantity::from("100"),
402 UnixNanos::from(1_000_000_000),
403 UnixNanos::from(2_000_000_000),
404 None,
405 Some(PositionId::from("P-001")),
406 Some(Decimal::from_str("1.23456").unwrap()),
407 );
408
409 assert_eq!(
410 report.avg_px_open,
411 Some(rust_decimal::Decimal::from_str("1.23456").unwrap())
412 );
413 assert!(format!("{}", report).contains("avg_px_open=Some(1.23456)"));
414 }
415
416 #[rstest]
417 fn test_avg_px_open_none_default() {
418 let report = PositionStatusReport::new(
419 AccountId::from("SIM-001"),
420 InstrumentId::from("AUDUSD.SIM"),
421 PositionSideSpecified::Long,
422 Quantity::from("100"),
423 UnixNanos::from(1_000_000_000),
424 UnixNanos::from(2_000_000_000),
425 None,
426 None,
427 None, );
429
430 assert_eq!(report.avg_px_open, None);
431 }
432
433 #[rstest]
434 fn test_avg_px_open_with_different_sides() {
435 let long_with_price = PositionStatusReport::new(
436 AccountId::from("SIM-001"),
437 InstrumentId::from("AUDUSD.SIM"),
438 PositionSideSpecified::Long,
439 Quantity::from("100"),
440 UnixNanos::from(1_000_000_000),
441 UnixNanos::from(2_000_000_000),
442 None,
443 None,
444 Some(Decimal::from_str("1.50000").unwrap()),
445 );
446
447 let short_with_price = PositionStatusReport::new(
448 AccountId::from("SIM-001"),
449 InstrumentId::from("AUDUSD.SIM"),
450 PositionSideSpecified::Short,
451 Quantity::from("100"),
452 UnixNanos::from(1_000_000_000),
453 UnixNanos::from(2_000_000_000),
454 None,
455 None,
456 Some(Decimal::from_str("1.60000").unwrap()),
457 );
458
459 assert_eq!(
460 long_with_price.avg_px_open,
461 Some(rust_decimal::Decimal::from_str("1.50000").unwrap())
462 );
463 assert_eq!(
464 short_with_price.avg_px_open,
465 Some(rust_decimal::Decimal::from_str("1.60000").unwrap())
466 );
467 }
468
469 #[rstest]
470 fn test_avg_px_open_serialization() {
471 let report = PositionStatusReport::new(
472 AccountId::from("SIM-001"),
473 InstrumentId::from("AUDUSD.SIM"),
474 PositionSideSpecified::Long,
475 Quantity::from("100"),
476 UnixNanos::from(1_000_000_000),
477 UnixNanos::from(2_000_000_000),
478 None,
479 None,
480 Some(Decimal::from_str("1.99999").unwrap()),
481 );
482
483 let json = serde_json::to_string(&report).unwrap();
484 let deserialized: PositionStatusReport = serde_json::from_str(&json).unwrap();
485
486 assert_eq!(report.avg_px_open, deserialized.avg_px_open);
487 }
488}