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