1use std::{
22 fmt::Display,
23 hash::{Hash, Hasher},
24};
25
26use ahash::{AHashMap, AHashSet};
27use indexmap::IndexMap;
28use nautilus_core::{
29 UUID4, UnixNanos,
30 correctness::{
31 CorrectnessError, CorrectnessResult, CorrectnessResultExt, FAILED, check_equal,
32 check_predicate_true,
33 },
34};
35use rust_decimal::{Decimal, prelude::ToPrimitive};
36use serde::{Deserialize, Serialize};
37
38use crate::{
39 enums::{InstrumentClass, OrderSide, PositionAdjustmentType, PositionSide},
40 events::{OrderFillVoided, OrderFilled, PositionAdjusted},
41 identifiers::{
42 AccountId, ClientOrderId, InstrumentId, PositionId, StrategyId, Symbol, TradeId, TraderId,
43 Venue, VenueOrderId,
44 },
45 instruments::{Instrument, InstrumentAny},
46 types::{Currency, Money, Price, Quantity},
47};
48
49#[repr(C)]
56#[derive(Debug, Clone, Serialize, Deserialize)]
57#[cfg_attr(
58 feature = "python",
59 pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
60)]
61#[cfg_attr(
62 feature = "python",
63 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
64)]
65pub struct Position {
66 pub events: Vec<OrderFilled>,
67 pub adjustments: Vec<PositionAdjusted>,
68 #[serde(default)]
69 pub replay_events: Vec<PositionReplayEvent>,
70 #[serde(default)]
71 pub fill_voids: Vec<PositionFillVoid>,
72 pub trader_id: TraderId,
73 pub strategy_id: StrategyId,
74 pub instrument_id: InstrumentId,
75 pub id: PositionId,
76 pub account_id: AccountId,
77 pub opening_order_id: ClientOrderId,
78 pub closing_order_id: Option<ClientOrderId>,
79 pub entry: OrderSide,
80 pub side: PositionSide,
81 pub signed_qty: f64,
82 pub quantity: Quantity,
83 pub peak_qty: Quantity,
84 pub price_precision: u8,
85 pub size_precision: u8,
86 pub multiplier: Quantity,
87 pub is_inverse: bool,
88 pub is_currency_pair: bool,
89 pub instrument_class: InstrumentClass,
90 pub base_currency: Option<Currency>,
91 pub quote_currency: Currency,
92 pub settlement_currency: Currency,
93 pub ts_init: UnixNanos,
94 pub ts_opened: UnixNanos,
95 pub ts_last: UnixNanos,
96 pub ts_closed: Option<UnixNanos>,
97 pub duration_ns: u64,
98 pub avg_px_open: f64,
99 pub avg_px_close: Option<f64>,
100 pub realized_return: f64,
101 pub realized_pnl: Option<Money>,
102 #[serde(with = "nautilus_core::serialization::sorted_hashset")]
103 pub trade_ids: AHashSet<TradeId>,
104 pub buy_qty: Quantity,
105 pub sell_qty: Quantity,
106 pub commissions: IndexMap<Currency, Money>,
107}
108
109#[expect(clippy::large_enum_variant)]
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub enum PositionReplayEvent {
112 Filled(OrderFilled),
113 Adjusted(PositionAdjusted),
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct PositionFillVoid {
118 pub event: OrderFillVoided,
119 pub voided_qty: Quantity,
120 pub commission_voided: Option<Money>,
121}
122
123impl Position {
124 #[must_use]
130 #[allow(
131 clippy::needless_pass_by_value,
132 reason = "constructor takes the opening fill by value as the position's seed event"
133 )]
134 pub fn new(instrument: &InstrumentAny, fill: OrderFilled) -> Self {
135 Self::new_checked(instrument, fill).expect_display(FAILED)
136 }
137
138 #[allow(
145 clippy::needless_pass_by_value,
146 reason = "constructor takes the opening fill by value as the position's seed event"
147 )]
148 pub fn new_checked(instrument: &InstrumentAny, fill: OrderFilled) -> CorrectnessResult<Self> {
149 Self::check_fill_instrument(instrument.id(), "instrument.id()", &fill)?;
150 let position_id = Self::fill_position_id(&fill)?;
151
152 let mut item = Self {
153 events: Vec::<OrderFilled>::new(),
154 adjustments: Vec::<PositionAdjusted>::new(),
155 replay_events: Vec::new(),
156 fill_voids: Vec::new(),
157 trade_ids: AHashSet::<TradeId>::new(),
158 buy_qty: Quantity::zero(instrument.size_precision()),
159 sell_qty: Quantity::zero(instrument.size_precision()),
160 commissions: IndexMap::<Currency, Money>::new(),
161 trader_id: fill.trader_id,
162 strategy_id: fill.strategy_id,
163 instrument_id: fill.instrument_id,
164 id: position_id,
165 account_id: fill.account_id,
166 opening_order_id: fill.client_order_id,
167 closing_order_id: None,
168 entry: fill.order_side,
169 side: PositionSide::Flat,
170 signed_qty: 0.0,
171 quantity: fill.last_qty,
172 peak_qty: fill.last_qty,
173 price_precision: instrument.price_precision(),
174 size_precision: instrument.size_precision(),
175 multiplier: instrument.multiplier(),
176 is_inverse: instrument.is_inverse(),
177 is_currency_pair: matches!(instrument, InstrumentAny::CurrencyPair(_)),
178 instrument_class: instrument.instrument_class(),
179 base_currency: instrument.base_currency(),
180 quote_currency: instrument.quote_currency(),
181 settlement_currency: instrument.cost_currency(),
182 ts_init: fill.ts_init,
183 ts_opened: fill.ts_event,
184 ts_last: fill.ts_event,
185 ts_closed: None,
186 duration_ns: 0,
187 avg_px_open: fill.last_px.as_f64(),
188 avg_px_close: None,
189 realized_return: 0.0,
190 realized_pnl: None,
191 };
192 item.apply_fill(&fill, true)?;
193 Ok(item)
194 }
195
196 #[must_use]
203 pub fn clone_without_events(&self) -> Self {
204 Self {
205 events: Vec::new(),
206 adjustments: Vec::new(),
207 replay_events: Vec::new(),
208 fill_voids: Vec::new(),
209 trader_id: self.trader_id,
210 strategy_id: self.strategy_id,
211 instrument_id: self.instrument_id,
212 id: self.id,
213 account_id: self.account_id,
214 opening_order_id: self.opening_order_id,
215 closing_order_id: self.closing_order_id,
216 entry: self.entry,
217 side: self.side,
218 signed_qty: self.signed_qty,
219 quantity: self.quantity,
220 peak_qty: self.peak_qty,
221 price_precision: self.price_precision,
222 size_precision: self.size_precision,
223 multiplier: self.multiplier,
224 is_inverse: self.is_inverse,
225 is_currency_pair: self.is_currency_pair,
226 instrument_class: self.instrument_class,
227 base_currency: self.base_currency,
228 quote_currency: self.quote_currency,
229 settlement_currency: self.settlement_currency,
230 ts_init: self.ts_init,
231 ts_opened: self.ts_opened,
232 ts_last: self.ts_last,
233 ts_closed: self.ts_closed,
234 duration_ns: self.duration_ns,
235 avg_px_open: self.avg_px_open,
236 avg_px_close: self.avg_px_close,
237 realized_return: self.realized_return,
238 realized_pnl: self.realized_pnl,
239 trade_ids: AHashSet::new(),
240 buy_qty: self.buy_qty,
241 sell_qty: self.sell_qty,
242 commissions: self.commissions.clone(),
243 }
244 }
245
246 pub fn purge_events_for_order(&mut self, client_order_id: ClientOrderId) {
257 self.replay_events.retain(|event| {
258 !matches!(event, PositionReplayEvent::Filled(fill) if fill.client_order_id == client_order_id)
259 });
260 self.fill_voids
261 .retain(|record| record.event.client_order_id != client_order_id);
262
263 let filtered_events: Vec<OrderFilled> = self
264 .events
265 .iter()
266 .filter(|e| e.client_order_id != client_order_id)
267 .cloned()
268 .collect();
269
270 let preserved_adjustments: Vec<PositionAdjusted> = self
273 .adjustments
274 .iter()
275 .filter(|adj| {
276 adj.adjustment_type != PositionAdjustmentType::Commission
279 })
280 .copied()
281 .collect();
282
283 if filtered_events.is_empty() {
285 log::warn!(
286 "Position {} has no fills remaining after purging order {}; consider closing the position instead",
287 self.id,
288 client_order_id
289 );
290 self.events.clear();
291 self.trade_ids.clear();
292 self.adjustments.clear();
293 self.buy_qty = Quantity::zero(self.size_precision);
294 self.sell_qty = Quantity::zero(self.size_precision);
295 self.commissions.clear();
296 self.signed_qty = 0.0;
297 self.quantity = Quantity::zero(self.size_precision);
298 self.side = PositionSide::Flat;
299 self.avg_px_close = None;
300 self.realized_pnl = None;
301 self.realized_return = 0.0;
302 self.ts_opened = UnixNanos::default();
303 self.ts_last = UnixNanos::default();
304 self.ts_closed = Some(UnixNanos::default());
305 self.duration_ns = 0;
306 return;
307 }
308
309 let position_id = self.id;
311 let size_precision = self.size_precision;
312
313 self.events = Vec::new();
315 self.trade_ids = AHashSet::new();
316 self.adjustments = Vec::new();
317 self.buy_qty = Quantity::zero(size_precision);
318 self.sell_qty = Quantity::zero(size_precision);
319 self.commissions.clear();
320 self.signed_qty = 0.0;
321 self.quantity = Quantity::zero(size_precision);
322 self.peak_qty = Quantity::zero(size_precision);
323 self.side = PositionSide::Flat;
324 self.avg_px_open = 0.0;
325 self.avg_px_close = None;
326 self.realized_pnl = None;
327 self.realized_return = 0.0;
328
329 let first_event = &filtered_events[0];
331 self.entry = first_event.order_side;
332 self.opening_order_id = first_event.client_order_id;
333 self.ts_opened = first_event.ts_event;
334 self.ts_init = first_event.ts_init;
335 self.closing_order_id = None;
336 self.ts_closed = None;
337 self.duration_ns = 0;
338
339 for event in filtered_events {
341 self.apply_fill(&event, false).expect_display(FAILED);
342 }
343
344 for adjustment in preserved_adjustments {
346 self.apply_adjustment_state(adjustment, false);
347 }
348
349 log::info!(
350 "Purged fills for order {} from position {}; recalculated state: qty={}, signed_qty={}, side={:?}",
351 client_order_id,
352 position_id,
353 self.quantity,
354 self.signed_qty,
355 self.side
356 );
357 }
358
359 pub fn apply(&mut self, fill: &OrderFilled) {
365 self.apply_fill(fill, true).expect_display(FAILED);
366 }
367
368 pub fn try_apply(&mut self, fill: &OrderFilled) -> CorrectnessResult<()> {
376 Self::check_fill_instrument(self.instrument_id, "self.instrument_id", fill)?;
377 let position_id = Self::fill_position_id(fill)?;
378 check_equal(&self.id, &position_id, "self.id", "fill.position_id")?;
379 self.apply_fill(fill, true)
380 }
381
382 fn check_fill_instrument(
383 instrument_id: InstrumentId,
384 instrument_param: &str,
385 fill: &OrderFilled,
386 ) -> CorrectnessResult<()> {
387 check_equal(
388 &instrument_id,
389 &fill.instrument_id,
390 instrument_param,
391 "fill.instrument_id",
392 )
393 }
394
395 fn fill_position_id(fill: &OrderFilled) -> CorrectnessResult<PositionId> {
396 fill.position_id
397 .ok_or_else(|| CorrectnessError::PredicateViolation {
398 message: "`fill.position_id` was None".to_string(),
399 })
400 }
401
402 fn apply_fill(&mut self, fill: &OrderFilled, record_replay: bool) -> CorrectnessResult<()> {
403 if record_replay
404 && (self.side == PositionSide::Flat || !self.trade_ids.contains(&fill.trade_id))
405 && self.is_duplicate_replay_fill(fill)
406 {
407 log::warn!(
408 "Ignoring historical duplicate fill {} for position {}; durable replay already contains this trade",
409 fill.trade_id,
410 self.id,
411 );
412 return Ok(());
413 }
414
415 if fill.ts_event < self.ts_opened {
416 log::warn!(
417 "Fill ts_event {} for {} is before position ts_opened {}",
418 fill.ts_event,
419 self.id,
420 self.ts_opened,
421 );
422 }
423
424 if self.side == PositionSide::Flat {
425 self.events.clear();
427 self.trade_ids.clear();
428 self.adjustments.clear();
429 self.buy_qty = Quantity::zero(self.size_precision);
430 self.sell_qty = Quantity::zero(self.size_precision);
431 self.commissions.clear();
432 self.opening_order_id = fill.client_order_id;
433 self.closing_order_id = None;
434 self.peak_qty = Quantity::zero(self.size_precision);
435 self.ts_init = fill.ts_init;
436 self.ts_opened = fill.ts_event;
437 self.ts_closed = None;
438 self.duration_ns = 0;
439 self.avg_px_open = fill.last_px.as_f64();
440 self.avg_px_close = None;
441 self.realized_return = 0.0;
442 self.realized_pnl = None;
443 }
444
445 if record_replay {
446 check_predicate_true(
447 !self.trade_ids.contains(&fill.trade_id),
448 "`fill.trade_id` already contained in `trade_ids`",
449 )?;
450 self.replay_events
451 .push(PositionReplayEvent::Filled(fill.clone()));
452 }
453
454 self.events.push(fill.clone());
455 self.trade_ids.insert(fill.trade_id);
456
457 if let Some(commission) = fill.commission {
459 let commission_currency = commission.currency;
460 if let Some(existing_commission) = self.commissions.get_mut(&commission_currency) {
461 *existing_commission = *existing_commission + commission;
462 } else {
463 self.commissions.insert(commission_currency, commission);
464 }
465 }
466
467 match fill.order_side {
469 OrderSide::Buy => {
470 self.handle_buy_order_fill(fill);
471 }
472 OrderSide::Sell => {
473 self.handle_sell_order_fill(fill);
474 }
475 }
476
477 if self.is_currency_pair
479 && let Some(commission) = fill.commission
480 && let Some(base_currency) = self.base_currency
481 && commission.currency == base_currency
482 {
483 let mut adjustment_id = fill.event_id.as_bytes();
484 adjustment_id[15] ^= 0x01;
485
486 let adjustment = PositionAdjusted::new(
487 self.trader_id,
488 self.strategy_id,
489 self.instrument_id,
490 self.id,
491 self.account_id,
492 PositionAdjustmentType::Commission,
493 Some(-commission.as_decimal()),
494 None,
495 Some(fill.client_order_id.inner()),
496 UUID4::from_bytes(adjustment_id),
497 fill.ts_event,
498 fill.ts_init,
499 );
500 self.apply_adjustment_state(adjustment, false);
501 }
502
503 self.quantity = Quantity::new(self.signed_qty.abs(), self.size_precision);
505 if self.quantity > self.peak_qty {
506 self.peak_qty = self.quantity;
507 }
508
509 if self.quantity.is_zero() {
510 self.side = PositionSide::Flat;
511 self.signed_qty = 0.0; self.closing_order_id = Some(fill.client_order_id);
513 self.ts_closed = Some(fill.ts_event);
514 self.duration_ns = if let Some(ts_closed) = self.ts_closed {
515 ts_closed.as_u64().saturating_sub(self.ts_opened.as_u64())
516 } else {
517 0
518 };
519 } else if self.signed_qty > 0.0 {
520 self.entry = OrderSide::Buy;
521 self.side = PositionSide::Long;
522 } else {
523 self.entry = OrderSide::Sell;
524 self.side = PositionSide::Short;
525 }
526
527 self.ts_last = fill.ts_event;
528
529 debug_assert!(
530 match self.side {
531 PositionSide::Long => self.signed_qty > 0.0,
532 PositionSide::Short => self.signed_qty < 0.0,
533 PositionSide::Flat => self.signed_qty == 0.0,
534 },
535 "Invariant: position side must match signed_qty sign (side={:?}, signed_qty={})",
536 self.side,
537 self.signed_qty,
538 );
539 debug_assert!(
540 self.peak_qty >= self.quantity,
541 "Invariant: peak_qty must not be less than current quantity (peak={}, quantity={})",
542 self.peak_qty,
543 self.quantity,
544 );
545
546 Ok(())
547 }
548
549 fn is_duplicate_replay_fill(&self, fill: &OrderFilled) -> bool {
550 let continues_latest_fill = fill.causation_id.is_some_and(|source_id| {
551 self.events.last().is_some_and(|latest| {
552 latest.trade_id == fill.trade_id && latest.event_id == source_id
553 })
554 });
555
556 if self.trade_ids.contains(&fill.trade_id) {
557 return !continues_latest_fill
558 || self.replay_events.iter().any(|event| {
559 matches!(
560 event,
561 PositionReplayEvent::Filled(replayed)
562 if replayed.trade_id == fill.trade_id
563 && replayed.causation_id == fill.causation_id
564 )
565 });
566 }
567
568 let replay_starts_current_cycle = self.replay_events.is_empty()
569 || matches!(
570 (self.replay_events.first(), self.events.first()),
571 (
572 Some(PositionReplayEvent::Filled(replayed)),
573 Some(current),
574 ) if replayed.event_id == current.event_id
575 );
576 let corrected_trade = self
577 .fill_voids
578 .iter()
579 .any(|record| record.event.trade_id == fill.trade_id);
580 let current_cycle_only = replay_starts_current_cycle && !corrected_trade;
581 if current_cycle_only {
582 return false;
583 }
584
585 self.replay_events.iter().any(|event| {
586 matches!(
587 event,
588 PositionReplayEvent::Filled(replayed) if replayed.trade_id == fill.trade_id
589 )
590 })
591 }
592
593 fn handle_buy_order_fill(&mut self, fill: &OrderFilled) {
594 let mut realized_pnl = if let Some(commission) = fill.commission {
596 if commission.currency == self.settlement_currency {
597 -commission.as_f64()
598 } else {
599 0.0
600 }
601 } else {
602 0.0
603 };
604
605 let last_px = fill.last_px.as_f64();
606 let last_qty = fill.last_qty.as_f64();
607 let last_qty_object = fill.last_qty;
608
609 if self.signed_qty > 0.0 {
610 self.avg_px_open = self.calculate_avg_px_open_px(last_px, last_qty);
611 } else if self.signed_qty < 0.0 {
612 let avg_px_close = self.calculate_avg_px_close_px(last_px, last_qty);
614 self.avg_px_close = Some(avg_px_close);
615 self.realized_return = self
616 .calculate_return(self.avg_px_open, avg_px_close)
617 .unwrap_or_else(|e| {
618 log::error!("Error calculating return: {e}");
619 0.0
620 });
621 realized_pnl += self
622 .calculate_pnl_raw(self.avg_px_open, last_px, last_qty)
623 .unwrap_or_else(|e| {
624 log::error!("Error calculating PnL: {e}");
625 0.0
626 });
627 }
628
629 let current_pnl = self.realized_pnl.map_or(0.0, |p| p.as_f64());
630 self.realized_pnl = Some(Money::new(
631 current_pnl + realized_pnl,
632 self.settlement_currency,
633 ));
634
635 let was_short = self.signed_qty < 0.0;
636 self.signed_qty += last_qty;
637 self.buy_qty = self.buy_qty + last_qty_object;
638
639 if was_short && last_qty_object > self.quantity {
641 self.avg_px_open = last_px;
642 }
643 }
644
645 fn handle_sell_order_fill(&mut self, fill: &OrderFilled) {
646 let mut realized_pnl = if let Some(commission) = fill.commission {
648 if commission.currency == self.settlement_currency {
649 -commission.as_f64()
650 } else {
651 0.0
652 }
653 } else {
654 0.0
655 };
656
657 let last_px = fill.last_px.as_f64();
658 let last_qty = fill.last_qty.as_f64();
659 let last_qty_object = fill.last_qty;
660
661 if self.signed_qty < 0.0 {
662 self.avg_px_open = self.calculate_avg_px_open_px(last_px, last_qty);
663 } else if self.signed_qty > 0.0 {
664 let avg_px_close = self.calculate_avg_px_close_px(last_px, last_qty);
666 self.avg_px_close = Some(avg_px_close);
667 self.realized_return = self
668 .calculate_return(self.avg_px_open, avg_px_close)
669 .unwrap_or_else(|e| {
670 log::error!("Error calculating return: {e}");
671 0.0
672 });
673 realized_pnl += self
674 .calculate_pnl_raw(self.avg_px_open, last_px, last_qty)
675 .unwrap_or_else(|e| {
676 log::error!("Error calculating PnL: {e}");
677 0.0
678 });
679 }
680
681 let current_pnl = self.realized_pnl.map_or(0.0, |p| p.as_f64());
682 self.realized_pnl = Some(Money::new(
683 current_pnl + realized_pnl,
684 self.settlement_currency,
685 ));
686
687 let was_long = self.signed_qty > 0.0;
688 self.signed_qty -= last_qty;
689 self.sell_qty = self.sell_qty + last_qty_object;
690
691 if was_long && last_qty_object > self.quantity {
693 self.avg_px_open = last_px;
694 }
695 }
696
697 pub fn apply_adjustment(&mut self, adjustment: PositionAdjusted) {
710 self.apply_adjustment_state(adjustment, true);
711 }
712
713 fn apply_adjustment_state(&mut self, adjustment: PositionAdjusted, record_replay: bool) {
714 if record_replay {
715 self.replay_events
716 .push(PositionReplayEvent::Adjusted(adjustment));
717 }
718
719 if let Some(quantity_change) = adjustment.quantity_change {
721 self.signed_qty += quantity_change
722 .to_f64()
723 .expect("Failed to convert Decimal to f64");
724
725 self.quantity = Quantity::new(self.signed_qty.abs(), self.size_precision);
726
727 if self.quantity > self.peak_qty {
728 self.peak_qty = self.quantity;
729 }
730 }
731
732 if let Some(pnl_change) = adjustment.pnl_change {
734 self.realized_pnl = Some(match self.realized_pnl {
735 Some(current) => current + pnl_change,
736 None => pnl_change,
737 });
738 }
739
740 if self.quantity.is_zero() {
743 self.side = PositionSide::Flat;
744 self.signed_qty = 0.0; } else if self.signed_qty > 0.0 {
746 self.side = PositionSide::Long;
747 } else {
748 self.side = PositionSide::Short;
749 }
750
751 self.adjustments.push(adjustment);
752 self.ts_last = adjustment.ts_event;
753
754 debug_assert!(
755 match self.side {
756 PositionSide::Long => self.signed_qty > 0.0,
757 PositionSide::Short => self.signed_qty < 0.0,
758 PositionSide::Flat => self.signed_qty == 0.0,
759 },
760 "Invariant: position side must match signed_qty sign (side={:?}, signed_qty={})",
761 self.side,
762 self.signed_qty,
763 );
764 debug_assert!(
765 self.peak_qty >= self.quantity,
766 "Invariant: peak_qty must not be less than current quantity (peak={}, quantity={})",
767 self.peak_qty,
768 self.quantity,
769 );
770 }
771
772 pub fn apply_fill_void(
784 &mut self,
785 event: OrderFillVoided,
786 voided_qty: Quantity,
787 commission_voided: Option<Money>,
788 ) -> anyhow::Result<Option<Money>> {
789 let fragment_qty = self
790 .fill_fragments(event.client_order_id, event.trade_id)
791 .iter()
792 .fold(Quantity::zero(self.size_precision), |total, fill| {
793 total + fill.last_qty
794 });
795 anyhow::ensure!(
796 !voided_qty.is_zero() && voided_qty <= fragment_qty,
797 "position fill void exceeds known fragments for {}",
798 event.trade_id,
799 );
800
801 if let Some(previous) = self.fill_voids.iter().rev().find(|record| {
802 record.event.client_order_id == event.client_order_id
803 && record.event.trade_id == event.trade_id
804 }) {
805 anyhow::ensure!(
806 voided_qty >= previous.voided_qty,
807 "stale position fill void for {}",
808 event.trade_id,
809 );
810 anyhow::ensure!(
811 voided_qty != previous.voided_qty
812 || commission_voided != previous.commission_voided,
813 "duplicate position fill void for {}",
814 event.trade_id,
815 );
816 }
817
818 self.fill_voids.push(PositionFillVoid {
819 event,
820 voided_qty,
821 commission_voided,
822 });
823
824 Ok(self.rebuild_from_replay())
825 }
826
827 #[must_use]
829 pub fn fill_fragments(
830 &self,
831 client_order_id: ClientOrderId,
832 trade_id: TradeId,
833 ) -> Vec<&OrderFilled> {
834 self.replay_events
835 .iter()
836 .filter_map(|event| match event {
837 PositionReplayEvent::Filled(fill)
838 if fill.client_order_id == client_order_id && fill.trade_id == trade_id =>
839 {
840 Some(fill)
841 }
842 _ => None,
843 })
844 .collect()
845 }
846
847 fn rebuild_from_replay(&mut self) -> Option<Money> {
851 let replay_events = self.replay_events.clone();
852 let mut quantity_removed = AHashMap::<usize, Quantity>::new();
853 let mut commission_removed = AHashMap::<usize, Money>::new();
854
855 for correction in self.latest_fill_voids() {
856 let mut remaining_qty = correction.voided_qty;
857 let mut remaining_commission = correction.commission_voided;
858
859 for (index, replay_event) in replay_events.iter().enumerate().rev() {
860 let PositionReplayEvent::Filled(fill) = replay_event else {
861 continue;
862 };
863
864 if fill.client_order_id != correction.event.client_order_id
865 || fill.trade_id != correction.event.trade_id
866 {
867 continue;
868 }
869
870 if !remaining_qty.is_zero() {
871 let removed = remaining_qty.min(fill.last_qty);
872 quantity_removed.insert(index, removed);
873 remaining_qty = remaining_qty - removed;
874 }
875
876 if let (Some(remaining), Some(commission)) = (remaining_commission, fill.commission)
877 {
878 let removed_raw = remaining.raw.abs().min(commission.raw.abs());
879 let removed =
880 Money::from_raw(removed_raw * remaining.raw.signum(), remaining.currency);
881 commission_removed.insert(index, removed);
882 let next = remaining - removed;
883 remaining_commission = (!next.is_zero()).then_some(next);
884 }
885 }
886 }
887
888 self.reset_derived_state();
889
890 let mut closed_cycles_pnl: Option<Money> = None;
891
892 for (index, replay_event) in replay_events.iter().enumerate() {
893 match replay_event {
894 PositionReplayEvent::Filled(fill) => {
895 let removed = quantity_removed
896 .get(&index)
897 .copied()
898 .unwrap_or_else(|| Quantity::zero(fill.last_qty.precision));
899 let effective_qty = fill.last_qty - removed;
900 let effective_commission =
901 match (fill.commission, commission_removed.get(&index).copied()) {
902 (Some(commission), Some(removed)) => Some(commission - removed),
903 (commission, None) => commission,
904 (None, Some(_)) => None,
905 };
906
907 if effective_qty.is_zero() {
908 if let Some(commission) =
909 effective_commission.filter(|commission| !commission.is_zero())
910 {
911 self.apply_surviving_fill_commission(fill, commission);
912 }
913 continue;
914 }
915
916 if self.side == PositionSide::Flat
919 && let Some(realized_pnl) = self.realized_pnl
920 {
921 closed_cycles_pnl = Some(
922 closed_cycles_pnl.map_or(realized_pnl, |total| total + realized_pnl),
923 );
924 }
925
926 let mut effective = fill.clone();
927 effective.last_qty = effective_qty;
928 effective.commission = effective_commission;
929 self.apply_fill(&effective, false).expect_display(FAILED);
930 }
931 PositionReplayEvent::Adjusted(adjustment) => {
932 self.apply_adjustment_state(*adjustment, false);
933 }
934 }
935 }
936
937 closed_cycles_pnl
938 }
939
940 fn apply_surviving_fill_commission(&mut self, fill: &OrderFilled, commission: Money) {
941 self.commissions
942 .entry(commission.currency)
943 .and_modify(|total| *total = *total + commission)
944 .or_insert(commission);
945
946 if commission.currency == self.settlement_currency {
947 let pnl_change = Money::zero(self.settlement_currency) - commission;
948 self.realized_pnl = Some(match self.realized_pnl {
949 Some(current) => current + pnl_change,
950 None => pnl_change,
951 });
952 }
953
954 if self.is_currency_pair && self.base_currency == Some(commission.currency) {
955 let mut adjustment_id = fill.event_id.as_bytes();
956 adjustment_id[15] ^= 0x01;
957 self.apply_adjustment_state(
958 PositionAdjusted::new(
959 self.trader_id,
960 self.strategy_id,
961 self.instrument_id,
962 self.id,
963 self.account_id,
964 PositionAdjustmentType::Commission,
965 Some(-commission.as_decimal()),
966 None,
967 Some(fill.client_order_id.inner()),
968 UUID4::from_bytes(adjustment_id),
969 fill.ts_event,
970 fill.ts_init,
971 ),
972 false,
973 );
974 } else {
975 self.ts_last = fill.ts_event;
976 }
977 }
978
979 fn latest_fill_voids(&self) -> Vec<&PositionFillVoid> {
980 let mut latest = IndexMap::<(ClientOrderId, TradeId), &PositionFillVoid>::new();
981 for correction in &self.fill_voids {
982 latest.insert(
983 (correction.event.client_order_id, correction.event.trade_id),
984 correction,
985 );
986 }
987 latest.into_values().collect()
988 }
989
990 fn reset_derived_state(&mut self) {
991 self.events.clear();
992 self.adjustments.clear();
993 self.trade_ids.clear();
994 self.buy_qty = Quantity::zero(self.size_precision);
995 self.sell_qty = Quantity::zero(self.size_precision);
996 self.commissions.clear();
997 self.signed_qty = 0.0;
998 self.quantity = Quantity::zero(self.size_precision);
999 self.peak_qty = Quantity::zero(self.size_precision);
1000 self.side = PositionSide::Flat;
1001 self.closing_order_id = None;
1002 self.ts_opened = UnixNanos::default();
1003 self.ts_last = UnixNanos::default();
1004 self.ts_closed = Some(UnixNanos::default());
1005 self.duration_ns = 0;
1006 self.avg_px_open = 0.0;
1007 self.avg_px_close = None;
1008 self.realized_pnl = None;
1009 self.realized_return = 0.0;
1010 }
1011
1012 fn calculate_avg_px(
1054 &self,
1055 qty: f64,
1056 avg_pg: f64,
1057 last_px: f64,
1058 last_qty: f64,
1059 ) -> anyhow::Result<f64> {
1060 debug_assert!(
1063 qty >= 0.0 && last_qty >= 0.0,
1064 "Invariant: average price calc requires non-negative quantities \
1065 (qty={qty}, last_qty={last_qty})"
1066 );
1067
1068 if qty == 0.0 && last_qty == 0.0 {
1069 anyhow::bail!("Cannot calculate average price: both quantities are zero");
1070 }
1071
1072 if last_qty == 0.0 {
1073 anyhow::bail!("Cannot calculate average price: fill quantity is zero");
1074 }
1075
1076 if qty == 0.0 {
1077 return Ok(last_px);
1078 }
1079
1080 let start_cost = avg_pg * qty;
1081 let event_cost = last_px * last_qty;
1082 let total_qty = qty + last_qty;
1083
1084 if total_qty <= 0.0 {
1086 anyhow::bail!(
1087 "Total quantity unexpectedly zero or negative in average price calculation: qty={qty}, last_qty={last_qty}, total_qty={total_qty}"
1088 );
1089 }
1090
1091 Ok((start_cost + event_cost) / total_qty)
1092 }
1093
1094 fn calculate_avg_px_open_px(&self, last_px: f64, last_qty: f64) -> f64 {
1095 self.calculate_avg_px(self.quantity.as_f64(), self.avg_px_open, last_px, last_qty)
1096 .unwrap_or_else(|e| {
1097 log::error!("Error calculating average open price: {e}");
1098 last_px
1099 })
1100 }
1101
1102 fn calculate_avg_px_close_px(&self, last_px: f64, last_qty: f64) -> f64 {
1103 let Some(avg_px_close) = self.avg_px_close else {
1104 return last_px;
1105 };
1106 let closing_qty = if self.side == PositionSide::Long {
1107 self.sell_qty
1108 } else {
1109 self.buy_qty
1110 };
1111 self.calculate_avg_px(closing_qty.as_f64(), avg_px_close, last_px, last_qty)
1112 .unwrap_or_else(|e| {
1113 log::error!("Error calculating average close price: {e}");
1114 last_px
1115 })
1116 }
1117
1118 fn calculate_points(&self, avg_px_open: f64, avg_px_close: f64) -> f64 {
1119 match self.side {
1120 PositionSide::Long => avg_px_close - avg_px_open,
1121 PositionSide::Short => avg_px_open - avg_px_close,
1122 PositionSide::Flat => 0.0,
1123 }
1124 }
1125
1126 fn calculate_points_inverse(&self, avg_px_open: f64, avg_px_close: f64) -> anyhow::Result<f64> {
1127 const EPSILON: f64 = 1e-15;
1129
1130 if avg_px_open <= 0.0 || avg_px_open.abs() < EPSILON {
1131 anyhow::bail!(
1132 "Cannot calculate inverse points: open price is not positive or is too small ({avg_px_open})"
1133 );
1134 }
1135
1136 if avg_px_close <= 0.0 || avg_px_close.abs() < EPSILON {
1137 anyhow::bail!(
1138 "Cannot calculate inverse points: close price is not positive or is too small ({avg_px_close})"
1139 );
1140 }
1141
1142 let inverse_open = 1.0 / avg_px_open;
1143 let inverse_close = 1.0 / avg_px_close;
1144 let result = match self.side {
1145 PositionSide::Long => inverse_open - inverse_close,
1146 PositionSide::Short => inverse_close - inverse_open,
1147 PositionSide::Flat => 0.0,
1148 };
1149 Ok(result)
1150 }
1151
1152 fn calculate_return(&self, avg_px_open: f64, avg_px_close: f64) -> anyhow::Result<f64> {
1153 if avg_px_open == 0.0 {
1155 anyhow::bail!(
1156 "Cannot calculate return: open price is zero (close price: {avg_px_close})"
1157 );
1158 }
1159 Ok(self.calculate_points(avg_px_open, avg_px_close) / avg_px_open)
1160 }
1161
1162 fn calculate_pnl_raw(
1163 &self,
1164 avg_px_open: f64,
1165 avg_px_close: f64,
1166 quantity: f64,
1167 ) -> anyhow::Result<f64> {
1168 let quantity = quantity.min(self.signed_qty.abs());
1169 let result = if self.is_inverse {
1170 anyhow::ensure!(
1171 self.base_currency.is_some(),
1172 "inverse position {} has no base currency",
1173 self.instrument_id
1174 );
1175 let points = self.calculate_points_inverse(avg_px_open, avg_px_close)?;
1176 quantity * self.multiplier.as_f64() * points
1177 } else {
1178 quantity * self.multiplier.as_f64() * self.calculate_points(avg_px_open, avg_px_close)
1179 };
1180 Ok(result)
1181 }
1182
1183 pub fn try_calculate_pnl(
1190 &self,
1191 avg_px_open: f64,
1192 avg_px_close: f64,
1193 quantity: Quantity,
1194 ) -> anyhow::Result<Money> {
1195 let pnl_raw = self.calculate_pnl_raw(avg_px_open, avg_px_close, quantity.as_f64())?;
1196 Money::new_checked(pnl_raw, self.settlement_currency).map_err(Into::into)
1197 }
1198
1199 #[must_use]
1201 pub fn calculate_pnl(&self, avg_px_open: f64, avg_px_close: f64, quantity: Quantity) -> Money {
1202 self.try_calculate_pnl(avg_px_open, avg_px_close, quantity)
1203 .unwrap_or_else(|e| {
1204 log::error!("Error calculating PnL: {e}");
1205 Money::zero(self.settlement_currency)
1206 })
1207 }
1208
1209 pub fn try_total_pnl(&self, last: Price) -> anyhow::Result<Money> {
1216 let unrealized = self.try_unrealized_pnl(last)?;
1217
1218 match self.realized_pnl {
1219 Some(realized) => {
1220 anyhow::ensure!(
1221 realized.currency == unrealized.currency,
1222 "realized and unrealized PnL currencies differ"
1223 );
1224 realized
1225 .checked_add(unrealized)
1226 .ok_or_else(|| anyhow::anyhow!("total PnL overflow"))
1227 }
1228 None => Ok(unrealized),
1229 }
1230 }
1231
1232 #[must_use]
1234 pub fn total_pnl(&self, last: Price) -> Money {
1235 self.try_total_pnl(last).unwrap_or_else(|e| {
1236 log::error!("Error calculating total PnL: {e}");
1237 Money::zero(self.settlement_currency)
1238 })
1239 }
1240
1241 pub fn try_unrealized_pnl(&self, last: Price) -> anyhow::Result<Money> {
1248 if self.side == PositionSide::Flat {
1249 Ok(Money::zero(self.settlement_currency))
1250 } else {
1251 let pnl =
1252 self.calculate_pnl_raw(self.avg_px_open, last.as_f64(), self.quantity.as_f64())?;
1253 Money::new_checked(pnl, self.settlement_currency).map_err(Into::into)
1254 }
1255 }
1256
1257 #[must_use]
1259 pub fn unrealized_pnl(&self, last: Price) -> Money {
1260 self.try_unrealized_pnl(last).unwrap_or_else(|e| {
1261 log::error!("Error calculating unrealized PnL: {e}");
1262 Money::zero(self.settlement_currency)
1263 })
1264 }
1265
1266 #[must_use]
1268 pub fn closing_order_side(&self) -> Option<OrderSide> {
1269 match self.side {
1270 PositionSide::Long => Some(OrderSide::Sell),
1271 PositionSide::Short => Some(OrderSide::Buy),
1272 PositionSide::Flat => None,
1273 }
1274 }
1275
1276 #[must_use]
1278 pub fn is_opposite_side(&self, side: OrderSide) -> bool {
1279 self.entry != side
1280 }
1281
1282 #[must_use]
1284 pub fn symbol(&self) -> Symbol {
1285 self.instrument_id.symbol
1286 }
1287
1288 #[must_use]
1290 pub fn venue(&self) -> Venue {
1291 self.instrument_id.venue
1292 }
1293
1294 #[must_use]
1296 pub fn event_count(&self) -> usize {
1297 self.events.len()
1298 }
1299
1300 #[must_use]
1302 pub fn client_order_ids(&self) -> Vec<ClientOrderId> {
1303 let mut result = self
1305 .events
1306 .iter()
1307 .map(|event| event.client_order_id)
1308 .collect::<AHashSet<ClientOrderId>>()
1309 .into_iter()
1310 .collect::<Vec<ClientOrderId>>();
1311 result.sort_unstable();
1312 result
1313 }
1314
1315 #[must_use]
1317 pub fn venue_order_ids(&self) -> Vec<VenueOrderId> {
1318 let mut result = self
1320 .events
1321 .iter()
1322 .map(|event| event.venue_order_id)
1323 .collect::<AHashSet<VenueOrderId>>()
1324 .into_iter()
1325 .collect::<Vec<VenueOrderId>>();
1326 result.sort_unstable();
1327 result
1328 }
1329
1330 #[must_use]
1332 pub fn trade_ids(&self) -> Vec<TradeId> {
1333 let mut result = self
1334 .events
1335 .iter()
1336 .map(|event| event.trade_id)
1337 .collect::<AHashSet<TradeId>>()
1338 .into_iter()
1339 .collect::<Vec<TradeId>>();
1340 result.sort_unstable();
1341 result
1342 }
1343
1344 pub fn try_notional_value(&self, last: Price) -> anyhow::Result<Money> {
1351 let currency = if self.is_inverse {
1352 self.base_currency.ok_or_else(|| {
1353 anyhow::anyhow!(
1354 "inverse position {} has no base currency",
1355 self.instrument_id
1356 )
1357 })?
1358 } else {
1359 self.settlement_currency
1360 };
1361
1362 crate::instruments::try_notional_value(
1363 self.quantity,
1364 last,
1365 self.multiplier,
1366 self.is_inverse,
1367 false,
1368 currency,
1369 )
1370 }
1371
1372 #[must_use]
1378 pub fn notional_value(&self, last: Price) -> Money {
1379 self.try_notional_value(last)
1380 .expect("invalid notional value")
1381 }
1382
1383 #[must_use]
1385 pub fn last_event(&self) -> Option<OrderFilled> {
1386 self.events.last().cloned()
1387 }
1388
1389 #[must_use]
1391 pub fn last_trade_id(&self) -> Option<TradeId> {
1392 self.events.last().map(|e| e.trade_id)
1393 }
1394
1395 #[must_use]
1397 pub fn is_long(&self) -> bool {
1398 self.side == PositionSide::Long
1399 }
1400
1401 #[must_use]
1403 pub fn is_short(&self) -> bool {
1404 self.side == PositionSide::Short
1405 }
1406
1407 #[must_use]
1409 pub fn is_open(&self) -> bool {
1410 self.side != PositionSide::Flat && self.ts_closed.is_none()
1411 }
1412
1413 #[must_use]
1415 pub fn is_closed(&self) -> bool {
1416 self.side == PositionSide::Flat && self.ts_closed.is_some()
1417 }
1418
1419 #[must_use]
1424 pub fn signed_decimal_qty(&self) -> Decimal {
1425 Decimal::try_from(self.signed_qty).unwrap_or(Decimal::ZERO)
1426 }
1427
1428 #[must_use]
1430 pub fn commissions(&self) -> Vec<Money> {
1431 self.commissions.values().copied().collect()
1432 }
1433}
1434
1435impl PartialEq<Self> for Position {
1436 fn eq(&self, other: &Self) -> bool {
1437 self.id == other.id
1438 }
1439}
1440
1441impl Eq for Position {}
1442
1443impl Hash for Position {
1444 fn hash<H: Hasher>(&self, state: &mut H) {
1445 self.id.hash(state);
1446 }
1447}
1448
1449impl Display for Position {
1450 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1451 let quantity_str = if self.quantity == Quantity::zero(self.size_precision) {
1452 String::new()
1453 } else {
1454 self.quantity.to_formatted_string() + " "
1455 };
1456 write!(
1457 f,
1458 "Position({} {}{}, id={})",
1459 self.side, quantity_str, self.instrument_id, self.id
1460 )
1461 }
1462}
1463
1464#[must_use]
1476pub fn fold_net_position(legs: &[(Decimal, Decimal, u64)]) -> (Decimal, Decimal) {
1477 let mut sorted: Vec<&(Decimal, Decimal, u64)> =
1478 legs.iter().filter(|(qty, _, _)| !qty.is_zero()).collect();
1479 sorted.sort_by_key(|(_, _, ts_opened)| *ts_opened);
1480
1481 let mut net_signed_qty = Decimal::ZERO;
1482 let mut net_avg_px = Decimal::ZERO;
1483
1484 for (p_qty, p_px, _) in sorted {
1485 let p_qty = *p_qty;
1486 let p_px = *p_px;
1487
1488 if net_signed_qty.is_zero() {
1489 net_signed_qty = p_qty;
1490 net_avg_px = p_px;
1491 continue;
1492 }
1493
1494 let same_side = net_signed_qty.is_sign_negative() == p_qty.is_sign_negative();
1495 let new_net = net_signed_qty + p_qty;
1496
1497 if same_side {
1498 let total_abs = net_signed_qty.abs() + p_qty.abs();
1499 net_avg_px = (net_signed_qty.abs() * net_avg_px + p_qty.abs() * p_px) / total_abs;
1500 net_signed_qty = new_net;
1501 } else if new_net.is_zero()
1502 || new_net.is_sign_negative() == net_signed_qty.is_sign_negative()
1503 {
1504 net_signed_qty = new_net;
1505 if new_net.is_zero() {
1506 net_avg_px = Decimal::ZERO;
1507 }
1508 } else {
1509 net_signed_qty = new_net;
1510 net_avg_px = p_px;
1511 }
1512 }
1513
1514 (net_signed_qty, net_avg_px)
1515}
1516
1517#[cfg(test)]
1518mod tests {
1519 use std::str::FromStr;
1520
1521 use ahash::AHashSet;
1522 use nautilus_core::{UnixNanos, correctness::CorrectnessError};
1523 use proptest::prelude::*;
1524 use rstest::rstest;
1525 use rust_decimal::{Decimal, prelude::ToPrimitive};
1526 use rust_decimal_macros::dec;
1527
1528 use crate::{
1529 enums::{OrderSide, OrderType, PositionAdjustmentType, PositionSide},
1530 events::{
1531 OrderEventAny, OrderFilled, PositionAdjusted,
1532 order::spec::{OrderFillVoidedSpec, OrderFilledSpec},
1533 },
1534 identifiers::{
1535 AccountId, ClientOrderId, InstrumentId, PositionId, StrategyId, TradeId, VenueOrderId,
1536 stubs::uuid4,
1537 },
1538 instruments::{
1539 CryptoFuture, CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny, stubs::*,
1540 },
1541 orders::{Order, builder::OrderTestBuilder, stubs::TestOrderEventStubs},
1542 position::{Position, PositionFillVoid, fold_net_position},
1543 stubs::*,
1544 types::{Currency, Money, Price, Quantity},
1545 };
1546
1547 #[rstest]
1548 fn test_position_long_display(stub_position_long: Position) {
1549 let display = format!("{stub_position_long}");
1550 assert_eq!(display, "Position(LONG 1 AUD/USD.SIM, id=1)");
1551 }
1552
1553 #[rstest]
1554 fn test_position_short_display(stub_position_short: Position) {
1555 let display = format!("{stub_position_short}");
1556 assert_eq!(display, "Position(SHORT 1 AUD/USD.SIM, id=1)");
1557 }
1558
1559 #[rstest]
1560 #[case::open(false)]
1561 #[case::closed(true)]
1562 fn test_clone_without_events_preserves_current_state(
1563 mut stub_position_long: Position,
1564 #[case] close: bool,
1565 ) {
1566 let adjustment = PositionAdjusted::new(
1567 stub_position_long.trader_id,
1568 stub_position_long.strategy_id,
1569 stub_position_long.instrument_id,
1570 stub_position_long.id,
1571 stub_position_long.account_id,
1572 PositionAdjustmentType::Funding,
1573 None,
1574 Some(Money::from_decimal(dec!(1.25), stub_position_long.settlement_currency).unwrap()),
1575 Some("clone-test".into()),
1576 uuid4(),
1577 UnixNanos::from(2),
1578 UnixNanos::from(2),
1579 );
1580 stub_position_long.apply_adjustment(adjustment);
1581
1582 if close {
1583 let closing_fill = OrderFilledSpec::builder()
1584 .trader_id(stub_position_long.trader_id)
1585 .strategy_id(stub_position_long.strategy_id)
1586 .instrument_id(stub_position_long.instrument_id)
1587 .client_order_id(ClientOrderId::from("CLONE-CLOSE"))
1588 .venue_order_id(VenueOrderId::from("CLONE-CLOSE"))
1589 .account_id(stub_position_long.account_id)
1590 .trade_id(TradeId::from("CLONE-CLOSE"))
1591 .order_side(OrderSide::Sell)
1592 .order_type(OrderType::Market)
1593 .last_qty(stub_position_long.quantity)
1594 .last_px(Price::from("1.0012"))
1595 .currency(stub_position_long.settlement_currency)
1596 .position_id(stub_position_long.id)
1597 .ts_event(UnixNanos::from(3))
1598 .ts_init(UnixNanos::from(3))
1599 .build();
1600 stub_position_long.apply(&closing_fill);
1601 }
1602
1603 let source_fill = stub_position_long.events[0].clone();
1604 let fill_voided = OrderFillVoidedSpec::builder()
1605 .trader_id(source_fill.trader_id)
1606 .strategy_id(source_fill.strategy_id)
1607 .instrument_id(source_fill.instrument_id)
1608 .client_order_id(source_fill.client_order_id)
1609 .venue_order_id(source_fill.venue_order_id)
1610 .account_id(source_fill.account_id)
1611 .trade_id(source_fill.trade_id)
1612 .voided_qty(source_fill.last_qty)
1613 .order_side(source_fill.order_side)
1614 .order_type(source_fill.order_type)
1615 .last_px(source_fill.last_px)
1616 .currency(source_fill.currency)
1617 .liquidity_side(source_fill.liquidity_side)
1618 .position_id(stub_position_long.id)
1619 .build();
1620 stub_position_long.fill_voids.push(PositionFillVoid {
1621 event: fill_voided,
1622 voided_qty: source_fill.last_qty,
1623 commission_voided: source_fill.commission,
1624 });
1625
1626 let cloned = stub_position_long.clone_without_events();
1627 let mut expected = stub_position_long.clone();
1628 expected.events.clear();
1629 expected.adjustments.clear();
1630 expected.replay_events.clear();
1631 expected.fill_voids.clear();
1632 expected.trade_ids.clear();
1633
1634 assert!(!stub_position_long.events.is_empty());
1635 assert!(!stub_position_long.adjustments.is_empty());
1636 assert!(!stub_position_long.replay_events.is_empty());
1637 assert!(!stub_position_long.fill_voids.is_empty());
1638 assert!(!stub_position_long.trade_ids.is_empty());
1639 assert!(cloned.events.is_empty());
1640 assert!(cloned.adjustments.is_empty());
1641 assert!(cloned.replay_events.is_empty());
1642 assert!(cloned.fill_voids.is_empty());
1643 assert!(cloned.trade_ids.is_empty());
1644 assert_eq!(
1645 serde_json::to_value(cloned).unwrap(),
1646 serde_json::to_value(expected).unwrap()
1647 );
1648 }
1649
1650 #[rstest]
1651 fn test_new_checked_rejects_missing_position_id(audusd_sim: CurrencyPair) {
1652 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
1653 let fill = OrderFilledSpec::builder()
1654 .instrument_id(instrument.id())
1655 .build();
1656
1657 let error = Position::new_checked(&instrument, fill).unwrap_err();
1658
1659 assert_eq!(
1660 error,
1661 CorrectnessError::PredicateViolation {
1662 message: "`fill.position_id` was None".to_string(),
1663 }
1664 );
1665 }
1666
1667 #[rstest]
1668 fn test_new_checked_rejects_instrument_mismatch(audusd_sim: CurrencyPair) {
1669 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
1670 let fill = OrderFilledSpec::builder()
1671 .instrument_id(InstrumentId::from("GBP/USD.SIM"))
1672 .position_id(PositionId::from("P-1"))
1673 .build();
1674
1675 let error = Position::new_checked(&instrument, fill).unwrap_err();
1676
1677 assert_eq!(
1678 error,
1679 CorrectnessError::EqualityMismatch {
1680 lhs_param: "instrument.id()".to_string(),
1681 rhs_param: "fill.instrument_id".to_string(),
1682 lhs: "AUD/USD.SIM".to_string(),
1683 rhs: "GBP/USD.SIM".to_string(),
1684 type_name: "value",
1685 }
1686 );
1687 }
1688
1689 #[rstest]
1690 #[case::instrument_mismatch(
1691 "GBP/USD.SIM",
1692 Some("P-1"),
1693 "'self.instrument_id' value of AUD/USD.SIM was not equal to 'fill.instrument_id' value of GBP/USD.SIM"
1694 )]
1695 #[case::missing_position_id("AUD/USD.SIM", None, "`fill.position_id` was None")]
1696 #[case::position_mismatch(
1697 "AUD/USD.SIM",
1698 Some("P-2"),
1699 "'self.id' value of P-1 was not equal to 'fill.position_id' value of P-2"
1700 )]
1701 fn test_try_apply_rejects_invalid_fill_identity_without_mutation(
1702 #[case] fill_instrument_id: &str,
1703 #[case] fill_position_id: Option<&str>,
1704 #[case] expected_error: &str,
1705 audusd_sim: CurrencyPair,
1706 ) {
1707 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
1708 let position_id = PositionId::from("P-1");
1709 let fill_open = OrderFilledSpec::builder()
1710 .instrument_id(instrument.id())
1711 .trade_id(TradeId::from("T-1"))
1712 .position_id(position_id)
1713 .build();
1714 let mut fill_invalid = OrderFilledSpec::builder()
1715 .instrument_id(InstrumentId::from(fill_instrument_id))
1716 .trade_id(TradeId::from("T-2"))
1717 .build();
1718 fill_invalid.position_id = fill_position_id.map(PositionId::from);
1719 let mut position = Position::new(&instrument, fill_open);
1720 let state_before = serde_json::to_value(&position).unwrap();
1721
1722 let error = position.try_apply(&fill_invalid).unwrap_err();
1723
1724 assert_eq!(error.to_string(), expected_error);
1725 assert_eq!(serde_json::to_value(&position).unwrap(), state_before);
1726 }
1727
1728 #[rstest]
1729 fn test_try_apply_rejects_duplicate_trade_without_mutation(audusd_sim: CurrencyPair) {
1730 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
1731 let position_id = PositionId::from("P-1");
1732 let fill_open = OrderFilledSpec::builder()
1733 .instrument_id(instrument.id())
1734 .trade_id(TradeId::from("T-1"))
1735 .position_id(position_id)
1736 .build();
1737 let fill_duplicate = OrderFilledSpec::builder()
1738 .instrument_id(instrument.id())
1739 .client_order_id(ClientOrderId::from("O-2"))
1740 .trade_id(TradeId::from("T-1"))
1741 .position_id(position_id)
1742 .build();
1743 let mut position = Position::new(&instrument, fill_open);
1744 let state_before = serde_json::to_value(&position).unwrap();
1745
1746 let error = position.try_apply(&fill_duplicate).unwrap_err();
1747
1748 assert_eq!(
1749 error,
1750 CorrectnessError::PredicateViolation {
1751 message: "`fill.trade_id` already contained in `trade_ids`".to_string(),
1752 }
1753 );
1754 assert_eq!(serde_json::to_value(&position).unwrap(), state_before);
1755 }
1756
1757 #[rstest]
1758 #[should_panic(expected = "`fill.trade_id` already contained in `trade_ids`")]
1759 fn test_two_trades_with_same_trade_id_error(audusd_sim: CurrencyPair) {
1760 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
1761 let order1 = OrderTestBuilder::new(OrderType::Market)
1762 .instrument_id(audusd_sim.id())
1763 .side(OrderSide::Buy)
1764 .quantity(Quantity::from(100_000))
1765 .build();
1766 let order2 = OrderTestBuilder::new(OrderType::Market)
1767 .instrument_id(audusd_sim.id())
1768 .side(OrderSide::Buy)
1769 .quantity(Quantity::from(100_000))
1770 .build();
1771 let fill1 = TestOrderEventStubs::filled(
1772 &order1,
1773 &audusd_sim,
1774 Some(TradeId::new("1")),
1775 None,
1776 Some(Price::from("1.00001")),
1777 None,
1778 None,
1779 None,
1780 None,
1781 None,
1782 );
1783 let fill2 = TestOrderEventStubs::filled(
1784 &order2,
1785 &audusd_sim,
1786 Some(TradeId::new("1")),
1787 None,
1788 Some(Price::from("1.00002")),
1789 None,
1790 None,
1791 None,
1792 None,
1793 None,
1794 );
1795 let mut position = Position::new(&audusd_sim, fill1.into());
1796 position.apply(&fill2.into());
1797 }
1798
1799 #[rstest]
1800 #[case(false)]
1801 #[case(true)]
1802 fn test_historical_duplicate_trade_id_does_not_poison_fill_void_replay(
1803 #[case] causal_duplicate: bool,
1804 audusd_sim: CurrencyPair,
1805 ) {
1806 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
1807 let position_id = PositionId::from("P-DUP");
1808 let fill_open = OrderFilledSpec::builder()
1809 .instrument_id(instrument.id())
1810 .client_order_id(ClientOrderId::from("O-1"))
1811 .trade_id(TradeId::from("T-1"))
1812 .order_side(OrderSide::Buy)
1813 .last_qty(Quantity::from(10))
1814 .last_px(Price::from("1.00000"))
1815 .currency(Currency::USD())
1816 .position_id(position_id)
1817 .ts_event(UnixNanos::from(1))
1818 .build();
1819 let fill_close = OrderFilledSpec::builder()
1820 .instrument_id(instrument.id())
1821 .client_order_id(ClientOrderId::from("O-2"))
1822 .trade_id(TradeId::from("T-2"))
1823 .order_side(OrderSide::Sell)
1824 .last_qty(Quantity::from(10))
1825 .last_px(Price::from("1.00010"))
1826 .currency(Currency::USD())
1827 .position_id(position_id)
1828 .ts_event(UnixNanos::from(2))
1829 .build();
1830 let mut fill_duplicate = OrderFilledSpec::builder()
1831 .instrument_id(instrument.id())
1832 .client_order_id(ClientOrderId::from("O-1"))
1833 .trade_id(TradeId::from("T-1"))
1834 .order_side(OrderSide::Buy)
1835 .last_qty(Quantity::from(10))
1836 .last_px(Price::from("1.00020"))
1837 .currency(Currency::USD())
1838 .position_id(position_id)
1839 .ts_event(UnixNanos::from(3))
1840 .build();
1841
1842 if causal_duplicate {
1843 fill_duplicate.causation_id = Some(fill_open.event_id);
1844 }
1845 let fill_reopen = OrderFilledSpec::builder()
1846 .instrument_id(instrument.id())
1847 .client_order_id(ClientOrderId::from("O-3"))
1848 .trade_id(TradeId::from("T-3"))
1849 .order_side(OrderSide::Buy)
1850 .last_qty(Quantity::from(5))
1851 .last_px(Price::from("1.00000"))
1852 .currency(Currency::USD())
1853 .position_id(position_id)
1854 .ts_event(UnixNanos::from(4))
1855 .build();
1856 let mut fill_duplicate_open = fill_duplicate.clone();
1857 fill_duplicate_open.event_id = uuid4();
1858 fill_duplicate_open.client_order_id = ClientOrderId::from("O-4");
1859 fill_duplicate_open.ts_event = UnixNanos::from(5);
1860 let fill_voided = OrderFillVoidedSpec::builder()
1861 .instrument_id(fill_close.instrument_id)
1862 .client_order_id(fill_close.client_order_id)
1863 .venue_order_id(fill_close.venue_order_id)
1864 .account_id(fill_close.account_id)
1865 .trade_id(fill_close.trade_id)
1866 .voided_qty(Quantity::from(10))
1867 .order_side(fill_close.order_side)
1868 .order_type(fill_close.order_type)
1869 .last_px(fill_close.last_px)
1870 .currency(fill_close.currency)
1871 .liquidity_side(fill_close.liquidity_side)
1872 .position_id(position_id)
1873 .build();
1874 let mut position = Position::new(&instrument, fill_open.clone());
1875 position.try_apply(&fill_close).unwrap();
1876
1877 position.try_apply(&fill_duplicate).unwrap();
1878
1879 assert_eq!(position.side, PositionSide::Flat);
1880 assert_eq!(position.quantity, Quantity::from(0));
1881 assert_eq!(position.events, vec![fill_open.clone(), fill_close.clone()]);
1882 assert_eq!(position.replay_events.len(), 2);
1883 assert_eq!(position.trade_ids.len(), 2);
1884 assert!(position.trade_ids.contains(&TradeId::from("T-1")));
1885 assert!(position.trade_ids.contains(&TradeId::from("T-2")));
1886
1887 position.try_apply(&fill_reopen).unwrap();
1888 position.try_apply(&fill_duplicate_open).unwrap();
1889
1890 assert_eq!(position.side, PositionSide::Long);
1891 assert_eq!(position.quantity, Quantity::from(5));
1892 assert_eq!(position.opening_order_id, ClientOrderId::from("O-3"));
1893 assert_eq!(position.events, vec![fill_reopen.clone()]);
1894 assert_eq!(position.replay_events.len(), 3);
1895 assert_eq!(position.trade_ids.len(), 1);
1896 assert!(position.trade_ids.contains(&TradeId::from("T-3")));
1897
1898 position
1899 .apply_fill_void(fill_voided, Quantity::from(10), None)
1900 .unwrap();
1901
1902 assert_eq!(position.side, PositionSide::Long);
1903 assert_eq!(position.quantity, Quantity::from(15));
1904 assert_eq!(position.opening_order_id, ClientOrderId::from("O-1"));
1905 assert_eq!(position.closing_order_id, None);
1906 assert_eq!(position.avg_px_open, 1.0);
1907 assert_eq!(position.buy_qty, Quantity::from(15));
1908 assert_eq!(position.sell_qty, Quantity::from(0));
1909 assert_eq!(
1910 position.events,
1911 vec![fill_open.clone(), fill_reopen.clone()]
1912 );
1913 assert_eq!(position.replay_events.len(), 3);
1914 assert_eq!(position.fill_voids.len(), 1);
1915 assert_eq!(position.trade_ids.len(), 2);
1916 assert!(position.trade_ids.contains(&TradeId::from("T-1")));
1917 assert!(position.trade_ids.contains(&TradeId::from("T-3")));
1918
1919 let mut fill_close_duplicate = fill_close;
1920 fill_close_duplicate.event_id = uuid4();
1921 fill_close_duplicate.ts_event = UnixNanos::from(6);
1922 position.try_apply(&fill_close_duplicate).unwrap();
1923
1924 assert_eq!(position.side, PositionSide::Long);
1925 assert_eq!(position.quantity, Quantity::from(15));
1926 assert_eq!(position.events, vec![fill_open, fill_reopen]);
1927 assert_eq!(position.replay_events.len(), 3);
1928 }
1929
1930 #[rstest]
1931 fn test_position_applies_fills_with_negative_prices(audusd_sim: CurrencyPair) {
1932 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
1936 let order = OrderTestBuilder::new(OrderType::Market)
1937 .instrument_id(audusd_sim.id())
1938 .side(OrderSide::Buy)
1939 .quantity(Quantity::from(100_000))
1940 .build();
1941 let fill1 = TestOrderEventStubs::filled(
1942 &order,
1943 &audusd_sim,
1944 Some(TradeId::new("1")),
1945 None,
1946 Some(Price::from("-5.00000")),
1947 Some(Quantity::from(50_000)),
1948 None,
1949 None,
1950 None,
1951 None,
1952 );
1953 let fill2 = TestOrderEventStubs::filled(
1954 &order,
1955 &audusd_sim,
1956 Some(TradeId::new("2")),
1957 None,
1958 Some(Price::from("-7.00000")),
1959 Some(Quantity::from(50_000)),
1960 None,
1961 None,
1962 None,
1963 None,
1964 );
1965 let mut position = Position::new(&audusd_sim, fill1.into());
1966 position.apply(&fill2.into());
1967
1968 assert_eq!(position.quantity, Quantity::from(100_000));
1969 assert_eq!(position.signed_qty, 100_000.0);
1970 assert_eq!(position.side, PositionSide::Long);
1971 assert_eq!(position.avg_px_open, -6.0);
1973 }
1974
1975 #[rstest]
1976 fn test_position_filled_with_buy_order(audusd_sim: CurrencyPair) {
1977 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
1978 let order = OrderTestBuilder::new(OrderType::Market)
1979 .instrument_id(audusd_sim.id())
1980 .side(OrderSide::Buy)
1981 .quantity(Quantity::from(100_000))
1982 .build();
1983 let fill = TestOrderEventStubs::filled(
1984 &order,
1985 &audusd_sim,
1986 None,
1987 None,
1988 Some(Price::from("1.00001")),
1989 None,
1990 None,
1991 None,
1992 None,
1993 None,
1994 );
1995 let last_price = Price::from_str("1.0005").unwrap();
1996 let position = Position::new(&audusd_sim, fill.into());
1997 assert_eq!(position.symbol(), audusd_sim.id().symbol);
1998 assert_eq!(position.venue(), audusd_sim.id().venue);
1999 assert_eq!(position.closing_order_side(), Some(OrderSide::Sell));
2000 assert!(!position.is_opposite_side(OrderSide::Buy));
2001 assert_eq!(position, position); assert!(position.closing_order_id.is_none());
2003 assert_eq!(position.quantity, Quantity::from(100_000));
2004 assert_eq!(position.peak_qty, Quantity::from(100_000));
2005 assert_eq!(position.size_precision, 0);
2006 assert_eq!(position.signed_qty, 100_000.0);
2007 assert_eq!(position.entry, OrderSide::Buy);
2008 assert_eq!(position.side, PositionSide::Long);
2009 assert_eq!(position.ts_opened.as_u64(), 0);
2010 assert_eq!(position.duration_ns, 0);
2011 assert_eq!(position.avg_px_open, 1.00001);
2012 assert_eq!(position.event_count(), 1);
2013 assert_eq!(position.id, PositionId::new("1"));
2014 assert_eq!(position.events.len(), 1);
2015 assert!(position.is_long());
2016 assert!(!position.is_short());
2017 assert!(position.is_open());
2018 assert!(!position.is_closed());
2019 assert_eq!(position.realized_return, 0.0);
2020 assert_eq!(position.realized_pnl, Some(Money::from("-2.0 USD")));
2021 assert_eq!(position.unrealized_pnl(last_price), Money::from("49.0 USD"));
2022 assert_eq!(position.total_pnl(last_price), Money::from("47.0 USD"));
2023 assert_eq!(position.commissions(), vec![Money::from("2.0 USD")]);
2024 assert_eq!(
2025 format!("{position}"),
2026 "Position(LONG 100_000 AUD/USD.SIM, id=1)"
2027 );
2028 }
2029
2030 #[rstest]
2031 fn test_position_filled_with_sell_order(audusd_sim: CurrencyPair) {
2032 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2033 let order = OrderTestBuilder::new(OrderType::Market)
2034 .instrument_id(audusd_sim.id())
2035 .side(OrderSide::Sell)
2036 .quantity(Quantity::from(100_000))
2037 .build();
2038 let fill = TestOrderEventStubs::filled(
2039 &order,
2040 &audusd_sim,
2041 None,
2042 None,
2043 Some(Price::from("1.00001")),
2044 None,
2045 None,
2046 None,
2047 None,
2048 None,
2049 );
2050 let last_price = Price::from_str("1.00050").unwrap();
2051 let position = Position::new(&audusd_sim, fill.into());
2052 assert_eq!(position.symbol(), audusd_sim.id().symbol);
2053 assert_eq!(position.venue(), audusd_sim.id().venue);
2054 assert_eq!(position.closing_order_side(), Some(OrderSide::Buy));
2055 assert!(!position.is_opposite_side(OrderSide::Sell));
2056 assert_eq!(position, position); assert!(position.closing_order_id.is_none());
2058 assert_eq!(position.quantity, Quantity::from(100_000));
2059 assert_eq!(position.peak_qty, Quantity::from(100_000));
2060 assert_eq!(position.signed_qty, -100_000.0);
2061 assert_eq!(position.entry, OrderSide::Sell);
2062 assert_eq!(position.side, PositionSide::Short);
2063 assert_eq!(position.ts_opened.as_u64(), 0);
2064 assert_eq!(position.avg_px_open, 1.00001);
2065 assert_eq!(position.event_count(), 1);
2066 assert_eq!(position.id, PositionId::new("1"));
2067 assert_eq!(position.events.len(), 1);
2068 assert!(!position.is_long());
2069 assert!(position.is_short());
2070 assert!(position.is_open());
2071 assert!(!position.is_closed());
2072 assert_eq!(position.realized_return, 0.0);
2073 assert_eq!(position.realized_pnl, Some(Money::from("-2.0 USD")));
2074 assert_eq!(
2075 position.unrealized_pnl(last_price),
2076 Money::from("-49.0 USD")
2077 );
2078 assert_eq!(position.total_pnl(last_price), Money::from("-51.0 USD"));
2079 assert_eq!(position.commissions(), vec![Money::from("2.0 USD")]);
2080 assert_eq!(
2081 format!("{position}"),
2082 "Position(SHORT 100_000 AUD/USD.SIM, id=1)"
2083 );
2084 }
2085
2086 #[rstest]
2087 fn test_position_partial_fills_with_buy_order(audusd_sim: CurrencyPair) {
2088 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2089 let order = OrderTestBuilder::new(OrderType::Market)
2090 .instrument_id(audusd_sim.id())
2091 .side(OrderSide::Buy)
2092 .quantity(Quantity::from(100_000))
2093 .build();
2094 let fill = TestOrderEventStubs::filled(
2095 &order,
2096 &audusd_sim,
2097 None,
2098 None,
2099 Some(Price::from("1.00001")),
2100 Some(Quantity::from(50_000)),
2101 None,
2102 None,
2103 None,
2104 None,
2105 );
2106 let last_price = Price::from_str("1.00048").unwrap();
2107 let position = Position::new(&audusd_sim, fill.into());
2108 assert_eq!(position.quantity, Quantity::from(50_000));
2109 assert_eq!(position.peak_qty, Quantity::from(50_000));
2110 assert_eq!(position.side, PositionSide::Long);
2111 assert_eq!(position.signed_qty, 50000.0);
2112 assert_eq!(position.avg_px_open, 1.00001);
2113 assert_eq!(position.event_count(), 1);
2114 assert_eq!(position.ts_opened.as_u64(), 0);
2115 assert!(position.is_long());
2116 assert!(!position.is_short());
2117 assert!(position.is_open());
2118 assert!(!position.is_closed());
2119 assert_eq!(position.realized_return, 0.0);
2120 assert_eq!(position.realized_pnl, Some(Money::from("-2.0 USD")));
2121 assert_eq!(position.unrealized_pnl(last_price), Money::from("23.5 USD"));
2122 assert_eq!(position.total_pnl(last_price), Money::from("21.5 USD"));
2123 assert_eq!(position.commissions(), vec![Money::from("2.0 USD")]);
2124 assert_eq!(
2125 format!("{position}"),
2126 "Position(LONG 50_000 AUD/USD.SIM, id=1)"
2127 );
2128 }
2129
2130 #[rstest]
2131 fn test_position_partial_fills_with_two_sell_orders(audusd_sim: CurrencyPair) {
2132 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2133 let order = OrderTestBuilder::new(OrderType::Market)
2134 .instrument_id(audusd_sim.id())
2135 .side(OrderSide::Sell)
2136 .quantity(Quantity::from(100_000))
2137 .build();
2138 let fill1 = TestOrderEventStubs::filled(
2139 &order,
2140 &audusd_sim,
2141 Some(TradeId::new("1")),
2142 None,
2143 Some(Price::from("1.00001")),
2144 Some(Quantity::from(50_000)),
2145 None,
2146 None,
2147 None,
2148 None,
2149 );
2150 let fill2 = TestOrderEventStubs::filled(
2151 &order,
2152 &audusd_sim,
2153 Some(TradeId::new("2")),
2154 None,
2155 Some(Price::from("1.00002")),
2156 Some(Quantity::from(50_000)),
2157 None,
2158 None,
2159 None,
2160 None,
2161 );
2162 let last_price = Price::from_str("1.0005").unwrap();
2163 let mut position = Position::new(&audusd_sim, fill1.into());
2164 position.apply(&fill2.into());
2165
2166 assert_eq!(position.quantity, Quantity::from(100_000));
2167 assert_eq!(position.peak_qty, Quantity::from(100_000));
2168 assert_eq!(position.side, PositionSide::Short);
2169 assert_eq!(position.signed_qty, -100_000.0);
2170 assert_eq!(position.avg_px_open, 1.000_015);
2171 assert_eq!(position.event_count(), 2);
2172 assert_eq!(position.ts_opened, 0);
2173 assert!(position.is_short());
2174 assert!(!position.is_long());
2175 assert!(position.is_open());
2176 assert!(!position.is_closed());
2177 assert_eq!(position.realized_return, 0.0);
2178 assert_eq!(position.realized_pnl, Some(Money::from("-4.0 USD")));
2179 assert_eq!(
2180 position.unrealized_pnl(last_price),
2181 Money::from("-48.5 USD")
2182 );
2183 assert_eq!(position.total_pnl(last_price), Money::from("-52.5 USD"));
2184 assert_eq!(position.commissions(), vec![Money::from("4.0 USD")]);
2185 }
2186
2187 #[rstest]
2188 pub fn test_position_filled_with_buy_order_then_sell_order(audusd_sim: CurrencyPair) {
2189 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2190 let order = OrderTestBuilder::new(OrderType::Market)
2191 .instrument_id(audusd_sim.id())
2192 .side(OrderSide::Buy)
2193 .quantity(Quantity::from(150_000))
2194 .build();
2195 let fill = TestOrderEventStubs::filled(
2196 &order,
2197 &audusd_sim,
2198 Some(TradeId::new("1")),
2199 Some(PositionId::new("P-1")),
2200 Some(Price::from("1.00001")),
2201 None,
2202 None,
2203 None,
2204 Some(UnixNanos::from(1_000_000_000)),
2205 None,
2206 );
2207 let mut position = Position::new(&audusd_sim, fill.into());
2208
2209 let fill2 = OrderFilledSpec::builder()
2210 .trader_id(order.trader_id())
2211 .strategy_id(StrategyId::new("S-001"))
2212 .instrument_id(order.instrument_id())
2213 .client_order_id(order.client_order_id())
2214 .venue_order_id(VenueOrderId::from("2"))
2215 .account_id(order.account_id().unwrap_or(AccountId::new("SIM-001")))
2216 .trade_id(TradeId::new("2"))
2217 .order_side(OrderSide::Sell)
2218 .last_qty(order.quantity())
2219 .last_px(Price::from("1.00011"))
2220 .currency(audusd_sim.quote_currency())
2221 .ts_event(2_000_000_000.into())
2222 .position_id(PositionId::new("T1"))
2223 .commission(Money::from("0.0 USD"))
2224 .build();
2225 position.apply(&fill2);
2226 let last = Price::from_str("1.0005").unwrap();
2227
2228 assert!(position.is_opposite_side(fill2.order_side));
2229 assert_eq!(
2230 position.quantity,
2231 Quantity::zero(audusd_sim.price_precision())
2232 );
2233 assert_eq!(position.size_precision, 0);
2234 assert_eq!(position.signed_qty, 0.0);
2235 assert_eq!(position.side, PositionSide::Flat);
2236 assert_eq!(position.ts_opened, 1_000_000_000);
2237 assert_eq!(position.ts_closed, Some(UnixNanos::from(2_000_000_000)));
2238 assert_eq!(position.duration_ns, 1_000_000_000);
2239 assert_eq!(position.avg_px_open, 1.00001);
2240 assert_eq!(position.avg_px_close, Some(1.00011));
2241 assert!(!position.is_long());
2242 assert!(!position.is_short());
2243 assert!(!position.is_open());
2244 assert!(position.is_closed());
2245 assert_eq!(position.realized_return, 9.999_900_000_998_888e-5);
2246 assert_eq!(position.realized_pnl, Some(Money::from("13.0 USD")));
2247 assert_eq!(position.unrealized_pnl(last), Money::from("0 USD"));
2248 assert_eq!(position.commissions(), vec![Money::from("2 USD")]);
2249 assert_eq!(position.total_pnl(last), Money::from("13 USD"));
2250 assert_eq!(format!("{position}"), "Position(FLAT AUD/USD.SIM, id=P-1)");
2251 }
2252
2253 #[rstest]
2254 pub fn test_position_filled_with_sell_order_then_buy_order(audusd_sim: CurrencyPair) {
2255 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2256 let order1 = OrderTestBuilder::new(OrderType::Market)
2257 .instrument_id(audusd_sim.id())
2258 .side(OrderSide::Sell)
2259 .quantity(Quantity::from(100_000))
2260 .build();
2261 let order2 = OrderTestBuilder::new(OrderType::Market)
2262 .instrument_id(audusd_sim.id())
2263 .side(OrderSide::Buy)
2264 .quantity(Quantity::from(100_000))
2265 .build();
2266 let fill1 = TestOrderEventStubs::filled(
2267 &order1,
2268 &audusd_sim,
2269 None,
2270 Some(PositionId::new("P-19700101-000000-001-001-1")),
2271 Some(Price::from("1.0")),
2272 None,
2273 None,
2274 None,
2275 None,
2276 None,
2277 );
2278 let mut position = Position::new(&audusd_sim, fill1.into());
2279 let fill2 = TestOrderEventStubs::filled(
2281 &order2,
2282 &audusd_sim,
2283 Some(TradeId::new("1")),
2284 Some(PositionId::new("P-19700101-000000-001-001-1")),
2285 Some(Price::from("1.00001")),
2286 Some(Quantity::from(50_000)),
2287 None,
2288 None,
2289 None,
2290 None,
2291 );
2292 let fill3 = TestOrderEventStubs::filled(
2293 &order2,
2294 &audusd_sim,
2295 Some(TradeId::new("2")),
2296 Some(PositionId::new("P-19700101-000000-001-001-1")),
2297 Some(Price::from("1.00003")),
2298 Some(Quantity::from(50_000)),
2299 None,
2300 None,
2301 None,
2302 None,
2303 );
2304 let last = Price::from("1.0005");
2305 position.apply(&fill2.into());
2306 position.apply(&fill3.into());
2307
2308 assert_eq!(
2309 position.quantity,
2310 Quantity::zero(audusd_sim.price_precision())
2311 );
2312 assert_eq!(position.side, PositionSide::Flat);
2313 assert_eq!(position.ts_opened, 0);
2314 assert_eq!(position.avg_px_open, 1.0);
2315 assert_eq!(position.events.len(), 3);
2316 assert_eq!(position.ts_closed, Some(UnixNanos::default()));
2317 assert_eq!(position.avg_px_close, Some(1.00002));
2318 assert!(!position.is_long());
2319 assert!(!position.is_short());
2320 assert!(!position.is_open());
2321 assert!(position.is_closed());
2322 assert_eq!(position.commissions(), vec![Money::from("6.0 USD")]);
2323 assert_eq!(position.unrealized_pnl(last), Money::from("0 USD"));
2324 assert_eq!(position.realized_pnl, Some(Money::from("-8.0 USD")));
2325 assert_eq!(position.total_pnl(last), Money::from("-8.0 USD"));
2326 assert_eq!(
2327 format!("{position}"),
2328 "Position(FLAT AUD/USD.SIM, id=P-19700101-000000-001-001-1)"
2329 );
2330 }
2331
2332 #[rstest]
2333 fn test_position_filled_with_no_change(audusd_sim: CurrencyPair) {
2334 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2335 let order1 = OrderTestBuilder::new(OrderType::Market)
2336 .instrument_id(audusd_sim.id())
2337 .side(OrderSide::Buy)
2338 .quantity(Quantity::from(100_000))
2339 .build();
2340 let order2 = OrderTestBuilder::new(OrderType::Market)
2341 .instrument_id(audusd_sim.id())
2342 .side(OrderSide::Sell)
2343 .quantity(Quantity::from(100_000))
2344 .build();
2345 let fill1 = TestOrderEventStubs::filled(
2346 &order1,
2347 &audusd_sim,
2348 Some(TradeId::new("1")),
2349 Some(PositionId::new("P-19700101-000000-001-001-1")),
2350 Some(Price::from("1.0")),
2351 None,
2352 None,
2353 None,
2354 None,
2355 None,
2356 );
2357 let mut position = Position::new(&audusd_sim, fill1.into());
2358 let fill2 = TestOrderEventStubs::filled(
2359 &order2,
2360 &audusd_sim,
2361 Some(TradeId::new("2")),
2362 Some(PositionId::new("P-19700101-000000-001-001-1")),
2363 Some(Price::from("1.0")),
2364 None,
2365 None,
2366 None,
2367 None,
2368 None,
2369 );
2370 let last = Price::from("1.0005");
2371 position.apply(&fill2.into());
2372
2373 assert_eq!(
2374 position.quantity,
2375 Quantity::zero(audusd_sim.price_precision())
2376 );
2377 assert_eq!(position.closing_order_side(), None);
2378 assert_eq!(position.side, PositionSide::Flat);
2379 assert_eq!(position.ts_opened, 0);
2380 assert_eq!(position.avg_px_open, 1.0);
2381 assert_eq!(position.events.len(), 2);
2382 assert_eq!(position.ts_closed, Some(UnixNanos::default()));
2384 assert_eq!(position.avg_px_close, Some(1.0));
2385 assert!(!position.is_long());
2386 assert!(!position.is_short());
2387 assert!(!position.is_open());
2388 assert!(position.is_closed());
2389 assert_eq!(position.commissions(), vec![Money::from("4.0 USD")]);
2390 assert_eq!(position.unrealized_pnl(last), Money::from("0 USD"));
2391 assert_eq!(position.realized_pnl, Some(Money::from("-4.0 USD")));
2392 assert_eq!(position.total_pnl(last), Money::from("-4.0 USD"));
2393 assert_eq!(
2394 format!("{position}"),
2395 "Position(FLAT AUD/USD.SIM, id=P-19700101-000000-001-001-1)"
2396 );
2397 }
2398
2399 #[rstest]
2400 fn test_position_long_with_multiple_filled_orders(audusd_sim: CurrencyPair) {
2401 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2402 let order1 = OrderTestBuilder::new(OrderType::Market)
2403 .instrument_id(audusd_sim.id())
2404 .side(OrderSide::Buy)
2405 .quantity(Quantity::from(100_000))
2406 .build();
2407 let order2 = OrderTestBuilder::new(OrderType::Market)
2408 .instrument_id(audusd_sim.id())
2409 .side(OrderSide::Buy)
2410 .quantity(Quantity::from(100_000))
2411 .build();
2412 let order3 = OrderTestBuilder::new(OrderType::Market)
2413 .instrument_id(audusd_sim.id())
2414 .side(OrderSide::Sell)
2415 .quantity(Quantity::from(200_000))
2416 .build();
2417 let fill1 = TestOrderEventStubs::filled(
2418 &order1,
2419 &audusd_sim,
2420 Some(TradeId::new("1")),
2421 Some(PositionId::new("P-123456")),
2422 Some(Price::from("1.0")),
2423 None,
2424 None,
2425 None,
2426 None,
2427 None,
2428 );
2429 let fill2 = TestOrderEventStubs::filled(
2430 &order2,
2431 &audusd_sim,
2432 Some(TradeId::new("2")),
2433 Some(PositionId::new("P-123456")),
2434 Some(Price::from("1.00001")),
2435 None,
2436 None,
2437 None,
2438 None,
2439 None,
2440 );
2441 let fill3 = TestOrderEventStubs::filled(
2442 &order3,
2443 &audusd_sim,
2444 Some(TradeId::new("3")),
2445 Some(PositionId::new("P-123456")),
2446 Some(Price::from("1.0001")),
2447 None,
2448 None,
2449 None,
2450 None,
2451 None,
2452 );
2453 let mut position = Position::new(&audusd_sim, fill1.into());
2454 let last = Price::from("1.0005");
2455 position.apply(&fill2.into());
2456 position.apply(&fill3.into());
2457
2458 assert_eq!(
2459 position.quantity,
2460 Quantity::zero(audusd_sim.price_precision())
2461 );
2462 assert_eq!(position.side, PositionSide::Flat);
2463 assert_eq!(position.ts_opened, 0);
2464 assert_eq!(position.avg_px_open, 1.000_005);
2465 assert_eq!(position.events.len(), 3);
2466 assert_eq!(position.ts_closed, Some(UnixNanos::default()));
2471 assert_eq!(position.avg_px_close, Some(1.0001));
2472 assert!(position.is_closed());
2473 assert!(!position.is_open());
2474 assert!(!position.is_long());
2475 assert!(!position.is_short());
2476 assert_eq!(position.commissions(), vec![Money::from("6.0 USD")]);
2477 assert_eq!(position.realized_pnl, Some(Money::from("13.0 USD")));
2478 assert_eq!(position.unrealized_pnl(last), Money::from("0 USD"));
2479 assert_eq!(position.total_pnl(last), Money::from("13 USD"));
2480 assert_eq!(
2481 format!("{position}"),
2482 "Position(FLAT AUD/USD.SIM, id=P-123456)"
2483 );
2484 }
2485
2486 #[rstest]
2487 fn test_pnl_calculation_from_trading_technologies_example(currency_pair_ethusdt: CurrencyPair) {
2488 let ethusdt = InstrumentAny::CurrencyPair(currency_pair_ethusdt);
2489 let quantity1 = Quantity::from(12);
2490 let price1 = Price::from("100.0");
2491 let order1 = OrderTestBuilder::new(OrderType::Market)
2492 .instrument_id(ethusdt.id())
2493 .side(OrderSide::Buy)
2494 .quantity(quantity1)
2495 .build();
2496 let commission1 = calculate_commission(ðusdt, order1.quantity(), price1, None);
2497 let fill1 = TestOrderEventStubs::filled(
2498 &order1,
2499 ðusdt,
2500 Some(TradeId::new("1")),
2501 Some(PositionId::new("P-123456")),
2502 Some(price1),
2503 None,
2504 None,
2505 Some(commission1),
2506 None,
2507 None,
2508 );
2509 let mut position = Position::new(ðusdt, fill1.into());
2510 let quantity2 = Quantity::from(17);
2511 let order2 = OrderTestBuilder::new(OrderType::Market)
2512 .instrument_id(ethusdt.id())
2513 .side(OrderSide::Buy)
2514 .quantity(quantity2)
2515 .build();
2516 let price2 = Price::from("99.0");
2517 let commission2 = calculate_commission(ðusdt, order2.quantity(), price2, None);
2518 let fill2 = TestOrderEventStubs::filled(
2519 &order2,
2520 ðusdt,
2521 Some(TradeId::new("2")),
2522 Some(PositionId::new("P-123456")),
2523 Some(price2),
2524 None,
2525 None,
2526 Some(commission2),
2527 None,
2528 None,
2529 );
2530 position.apply(&fill2.into());
2531 assert_eq!(position.quantity, Quantity::from(29));
2532 assert_eq!(position.realized_pnl, Some(Money::from("-0.28830000 USDT")));
2533 assert_eq!(position.avg_px_open, 99.413_793_103_448_27);
2534 let quantity3 = Quantity::from(9);
2535 let order3 = OrderTestBuilder::new(OrderType::Market)
2536 .instrument_id(ethusdt.id())
2537 .side(OrderSide::Sell)
2538 .quantity(quantity3)
2539 .build();
2540 let price3 = Price::from("101.0");
2541 let commission3 = calculate_commission(ðusdt, order3.quantity(), price3, None);
2542 let fill3 = TestOrderEventStubs::filled(
2543 &order3,
2544 ðusdt,
2545 Some(TradeId::new("3")),
2546 Some(PositionId::new("P-123456")),
2547 Some(price3),
2548 None,
2549 None,
2550 Some(commission3),
2551 None,
2552 None,
2553 );
2554 position.apply(&fill3.into());
2555 assert_eq!(position.quantity, Quantity::from(20));
2556 assert_eq!(position.realized_pnl, Some(Money::from("13.89666207 USDT")));
2557 assert_eq!(position.avg_px_open, 99.413_793_103_448_27);
2558 let quantity4 = Quantity::from("4");
2559 let price4 = Price::from("105.0");
2560 let order4 = OrderTestBuilder::new(OrderType::Market)
2561 .instrument_id(ethusdt.id())
2562 .side(OrderSide::Sell)
2563 .quantity(quantity4)
2564 .build();
2565 let commission4 = calculate_commission(ðusdt, order4.quantity(), price4, None);
2566 let fill4 = TestOrderEventStubs::filled(
2567 &order4,
2568 ðusdt,
2569 Some(TradeId::new("4")),
2570 Some(PositionId::new("P-123456")),
2571 Some(price4),
2572 None,
2573 None,
2574 Some(commission4),
2575 None,
2576 None,
2577 );
2578 position.apply(&fill4.into());
2579 assert_eq!(position.quantity, Quantity::from("16"));
2580 assert_eq!(position.realized_pnl, Some(Money::from("36.19948966 USDT")));
2581 assert_eq!(position.avg_px_open, 99.413_793_103_448_27);
2582 let quantity5 = Quantity::from("3");
2583 let price5 = Price::from("103.0");
2584 let order5 = OrderTestBuilder::new(OrderType::Market)
2585 .instrument_id(ethusdt.id())
2586 .side(OrderSide::Buy)
2587 .quantity(quantity5)
2588 .build();
2589 let commission5 = calculate_commission(ðusdt, order5.quantity(), price5, None);
2590 let fill5 = TestOrderEventStubs::filled(
2591 &order5,
2592 ðusdt,
2593 Some(TradeId::new("5")),
2594 Some(PositionId::new("P-123456")),
2595 Some(price5),
2596 None,
2597 None,
2598 Some(commission5),
2599 None,
2600 None,
2601 );
2602 position.apply(&fill5.into());
2603 assert_eq!(position.quantity, Quantity::from("19"));
2604 assert_eq!(position.realized_pnl, Some(Money::from("36.16858966 USDT")));
2605 assert_eq!(position.avg_px_open, 99.980_036_297_640_65);
2606 assert_eq!(
2607 format!("{position}"),
2608 "Position(LONG 19.00000 ETHUSDT.BINANCE, id=P-123456)"
2609 );
2610 }
2611
2612 #[rstest]
2613 fn test_position_closed_and_reopened(audusd_sim: CurrencyPair) {
2614 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2615 let quantity1 = Quantity::from(150_000);
2616 let price1 = Price::from("1.00001");
2617 let order = OrderTestBuilder::new(OrderType::Market)
2618 .instrument_id(audusd_sim.id())
2619 .side(OrderSide::Buy)
2620 .quantity(quantity1)
2621 .build();
2622 let commission1 = calculate_commission(&audusd_sim, quantity1, price1, None);
2623 let fill1 = TestOrderEventStubs::filled(
2624 &order,
2625 &audusd_sim,
2626 Some(TradeId::new("5")),
2627 Some(PositionId::new("P-123456")),
2628 Some(Price::from("1.00001")),
2629 None,
2630 None,
2631 Some(commission1),
2632 Some(UnixNanos::from(1_000_000_000)),
2633 None,
2634 );
2635 let mut position = Position::new(&audusd_sim, fill1.into());
2636
2637 let fill2 = OrderFilledSpec::builder()
2638 .trader_id(order.trader_id())
2639 .strategy_id(order.strategy_id())
2640 .instrument_id(order.instrument_id())
2641 .client_order_id(order.client_order_id())
2642 .venue_order_id(VenueOrderId::from("2"))
2643 .account_id(order.account_id().unwrap_or(AccountId::new("SIM-001")))
2644 .trade_id(TradeId::from("2"))
2645 .order_side(OrderSide::Sell)
2646 .last_qty(order.quantity())
2647 .last_px(Price::from("1.00011"))
2648 .currency(audusd_sim.quote_currency())
2649 .ts_event(UnixNanos::from(2_000_000_000))
2650 .position_id(PositionId::from("P-123456"))
2651 .commission(Money::from("0 USD"))
2652 .build();
2653
2654 position.apply(&fill2);
2655
2656 let fill3 = OrderFilledSpec::builder()
2657 .trader_id(order.trader_id())
2658 .strategy_id(order.strategy_id())
2659 .instrument_id(order.instrument_id())
2660 .client_order_id(order.client_order_id())
2661 .venue_order_id(VenueOrderId::from("2"))
2662 .account_id(order.account_id().unwrap_or(AccountId::new("SIM-001")))
2663 .trade_id(TradeId::from("3"))
2664 .last_qty(order.quantity())
2665 .last_px(Price::from("1.00012"))
2666 .currency(audusd_sim.quote_currency())
2667 .ts_event(UnixNanos::from(3_000_000_000))
2668 .position_id(PositionId::from("P-123456"))
2669 .commission(Money::from("0 USD"))
2670 .build();
2671
2672 position.apply(&fill3);
2673
2674 let last = Price::from("1.0003");
2675 assert!(position.is_opposite_side(fill2.order_side));
2676 assert_eq!(position.quantity, Quantity::from(150_000));
2677 assert_eq!(position.peak_qty, Quantity::from(150_000));
2678 assert_eq!(position.side, PositionSide::Long);
2679 assert_eq!(position.opening_order_id, fill3.client_order_id);
2680 assert_eq!(position.closing_order_id, None);
2681 assert_eq!(position.closing_order_id, None);
2682 assert_eq!(position.ts_opened, 3_000_000_000);
2683 assert_eq!(position.duration_ns, 0);
2684 assert_eq!(position.avg_px_open, 1.00012);
2685 assert_eq!(position.event_count(), 1);
2686 assert_eq!(position.ts_closed, None);
2687 assert_eq!(position.avg_px_close, None);
2688 assert!(position.is_long());
2689 assert!(!position.is_short());
2690 assert!(position.is_open());
2691 assert!(!position.is_closed());
2692 assert_eq!(position.realized_return, 0.0);
2693 assert_eq!(position.realized_pnl, Some(Money::from("0 USD")));
2694 assert_eq!(position.unrealized_pnl(last), Money::from("27 USD"));
2695 assert_eq!(position.total_pnl(last), Money::from("27 USD"));
2696 assert_eq!(position.commissions(), vec![Money::from("0 USD")]);
2697 assert_eq!(
2698 format!("{position}"),
2699 "Position(LONG 150_000 AUD/USD.SIM, id=P-123456)"
2700 );
2701 }
2702
2703 #[rstest]
2704 fn test_fill_void_replays_across_position_close_and_reopen(audusd_sim: CurrencyPair) {
2705 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2706 let position_id = PositionId::from("P-VOID-REPLAY");
2707 let fill1 = OrderFilledSpec::builder()
2708 .instrument_id(instrument.id())
2709 .client_order_id(ClientOrderId::from("O-OPEN"))
2710 .trade_id(TradeId::from("T-OPEN"))
2711 .order_side(OrderSide::Buy)
2712 .last_qty(Quantity::from(10))
2713 .last_px(Price::from("1.00000"))
2714 .currency(Currency::USD())
2715 .position_id(position_id)
2716 .commission(Money::from("1.00 USD"))
2717 .ts_event(UnixNanos::from(1))
2718 .build();
2719 let fill2 = OrderFilledSpec::builder()
2720 .instrument_id(instrument.id())
2721 .client_order_id(ClientOrderId::from("O-CLOSE"))
2722 .trade_id(TradeId::from("T-CLOSE"))
2723 .order_side(OrderSide::Sell)
2724 .last_qty(Quantity::from(10))
2725 .last_px(Price::from("1.10000"))
2726 .currency(Currency::USD())
2727 .position_id(position_id)
2728 .commission(Money::from("1.00 USD"))
2729 .ts_event(UnixNanos::from(2))
2730 .build();
2731 let fill3 = OrderFilledSpec::builder()
2732 .instrument_id(instrument.id())
2733 .client_order_id(ClientOrderId::from("O-REOPEN"))
2734 .trade_id(TradeId::from("T-REOPEN"))
2735 .order_side(OrderSide::Buy)
2736 .last_qty(Quantity::from(5))
2737 .last_px(Price::from("1.20000"))
2738 .currency(Currency::USD())
2739 .position_id(position_id)
2740 .commission(Money::from("1.00 USD"))
2741 .ts_event(UnixNanos::from(3))
2742 .build();
2743 let fill_voided = OrderFillVoidedSpec::builder()
2744 .instrument_id(fill2.instrument_id)
2745 .client_order_id(fill2.client_order_id)
2746 .venue_order_id(fill2.venue_order_id)
2747 .account_id(fill2.account_id)
2748 .trade_id(fill2.trade_id)
2749 .voided_qty(Quantity::from(5))
2750 .commission_voided(Money::from("0.50 USD"))
2751 .order_side(fill2.order_side)
2752 .order_type(fill2.order_type)
2753 .last_px(fill2.last_px)
2754 .currency(fill2.currency)
2755 .liquidity_side(fill2.liquidity_side)
2756 .position_id(position_id)
2757 .build();
2758 let mut position = Position::new(&instrument, fill1);
2759 position.apply(&fill2);
2760 position.apply(&fill3);
2761
2762 position
2763 .apply_fill_void(
2764 fill_voided,
2765 Quantity::from(5),
2766 Some(Money::from("0.50 USD")),
2767 )
2768 .unwrap();
2769 let encoded = serde_json::to_string(&position).unwrap();
2770 let restored: Position = serde_json::from_str(&encoded).unwrap();
2771
2772 assert_eq!(position.side, PositionSide::Long);
2773 assert_eq!(position.quantity, Quantity::from(10));
2774 assert_eq!(position.opening_order_id, ClientOrderId::from("O-OPEN"));
2775 assert_eq!(position.buy_qty, Quantity::from(15));
2776 assert_eq!(position.sell_qty, Quantity::from(5));
2777 assert_eq!(position.commissions(), vec![Money::from("2.50 USD")]);
2778 assert_eq!(position.replay_events.len(), 3);
2779 assert_eq!(position.fill_voids.len(), 1);
2780 assert_eq!(restored.quantity, position.quantity);
2781 assert_eq!(restored.opening_order_id, position.opening_order_id);
2782 assert_eq!(restored.commissions(), position.commissions());
2783 assert_eq!(restored.replay_events.len(), position.replay_events.len());
2784 assert_eq!(restored.fill_voids.len(), position.fill_voids.len());
2785 }
2786
2787 #[rstest]
2788 fn test_full_fill_void_preserves_unvoided_commission(audusd_sim: CurrencyPair) {
2789 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2790 let position_id = PositionId::from("P-FEE-VOID");
2791 let fill = OrderFilledSpec::builder()
2792 .instrument_id(instrument.id())
2793 .client_order_id(ClientOrderId::from("O-FEE"))
2794 .trade_id(TradeId::from("T-FEE"))
2795 .order_side(OrderSide::Buy)
2796 .last_qty(Quantity::from(10))
2797 .last_px(Price::from("1.00000"))
2798 .currency(Currency::USD())
2799 .position_id(position_id)
2800 .commission(Money::from("1.00 USD"))
2801 .build();
2802 let fill_voided = OrderFillVoidedSpec::builder()
2803 .instrument_id(fill.instrument_id)
2804 .client_order_id(fill.client_order_id)
2805 .venue_order_id(fill.venue_order_id)
2806 .account_id(fill.account_id)
2807 .trade_id(fill.trade_id)
2808 .voided_qty(fill.last_qty)
2809 .order_side(fill.order_side)
2810 .order_type(fill.order_type)
2811 .last_px(fill.last_px)
2812 .currency(fill.currency)
2813 .liquidity_side(fill.liquidity_side)
2814 .build();
2815 let mut position = Position::new(&instrument, fill);
2816
2817 position
2818 .apply_fill_void(fill_voided, Quantity::from(10), None)
2819 .unwrap();
2820
2821 assert_eq!(position.side, PositionSide::Flat);
2822 assert_eq!(position.quantity, Quantity::from(0));
2823 assert_eq!(position.commissions(), vec![Money::from("1.00 USD")]);
2824 assert_eq!(position.realized_pnl, Some(Money::from("-1.00 USD")));
2825 assert!(position.events.is_empty());
2826 }
2827
2828 #[rstest]
2829 fn test_fill_void_replays_netting_flip_fragments_with_one_trade_id(audusd_sim: CurrencyPair) {
2830 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2831 let position_id = PositionId::from("P-FLIP-VOID");
2832 let opening = OrderFilledSpec::builder()
2833 .instrument_id(instrument.id())
2834 .client_order_id(ClientOrderId::from("O-OPEN"))
2835 .trade_id(TradeId::from("T-OPEN"))
2836 .order_side(OrderSide::Buy)
2837 .last_qty(Quantity::from(10))
2838 .last_px(Price::from("1.00000"))
2839 .currency(Currency::USD())
2840 .position_id(position_id)
2841 .build();
2842 let closing = OrderFilledSpec::builder()
2843 .instrument_id(instrument.id())
2844 .client_order_id(ClientOrderId::from("O-FLIP"))
2845 .trade_id(TradeId::from("T-FLIP"))
2846 .order_side(OrderSide::Sell)
2847 .last_qty(Quantity::from(10))
2848 .last_px(Price::from("1.10000"))
2849 .currency(Currency::USD())
2850 .position_id(position_id)
2851 .build();
2852 let mut reopening = closing.clone();
2853 reopening.last_qty = Quantity::from(5);
2854 reopening.event_id = uuid4();
2855 reopening.causation_id = Some(closing.event_id);
2856 let fill_voided = OrderFillVoidedSpec::builder()
2857 .instrument_id(closing.instrument_id)
2858 .client_order_id(closing.client_order_id)
2859 .venue_order_id(closing.venue_order_id)
2860 .account_id(closing.account_id)
2861 .trade_id(closing.trade_id)
2862 .voided_qty(Quantity::from(12))
2863 .order_side(closing.order_side)
2864 .order_type(closing.order_type)
2865 .last_px(closing.last_px)
2866 .currency(closing.currency)
2867 .liquidity_side(closing.liquidity_side)
2868 .position_id(position_id)
2869 .build();
2870 let mut position = Position::new(&instrument, opening);
2871 position.apply(&closing);
2872 assert!(!position.is_duplicate_replay_fill(&reopening));
2873 position.apply(&reopening);
2874
2875 position
2876 .apply_fill_void(fill_voided, Quantity::from(12), None)
2877 .unwrap();
2878
2879 assert_eq!(position.side, PositionSide::Long);
2880 assert_eq!(position.quantity, Quantity::from(7));
2881 assert_eq!(position.buy_qty, Quantity::from(10));
2882 assert_eq!(position.sell_qty, Quantity::from(3));
2883 assert_eq!(position.replay_events.len(), 3);
2884 assert!(position.is_duplicate_replay_fill(&reopening));
2885 }
2886
2887 #[rstest]
2888 fn test_fill_void_replays_split_fragments_in_one_corrected_cycle(audusd_sim: CurrencyPair) {
2889 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2890 let position_id = PositionId::from("P-FLIP-CYCLE-VOID");
2891 let opening = OrderFilledSpec::builder()
2892 .instrument_id(instrument.id())
2893 .client_order_id(ClientOrderId::from("O-SELL-1"))
2894 .trade_id(TradeId::from("T-SELL-1"))
2895 .order_side(OrderSide::Sell)
2896 .last_qty(Quantity::from(17))
2897 .last_px(Price::from("1.00000"))
2898 .currency(Currency::USD())
2899 .position_id(position_id)
2900 .build();
2901 let second_sell = OrderFilledSpec::builder()
2902 .instrument_id(instrument.id())
2903 .client_order_id(ClientOrderId::from("O-SELL-2"))
2904 .trade_id(TradeId::from("T-SELL-2"))
2905 .order_side(OrderSide::Sell)
2906 .last_qty(Quantity::from(17))
2907 .last_px(Price::from("1.00000"))
2908 .currency(Currency::USD())
2909 .position_id(position_id)
2910 .build();
2911 let closing = OrderFilledSpec::builder()
2912 .instrument_id(instrument.id())
2913 .client_order_id(ClientOrderId::from("O-FLIP"))
2914 .trade_id(TradeId::from("T-FLIP"))
2915 .order_side(OrderSide::Buy)
2916 .last_qty(Quantity::from(34))
2917 .last_px(Price::from("1.10000"))
2918 .currency(Currency::USD())
2919 .position_id(position_id)
2920 .build();
2921 let mut reopening = closing.clone();
2922 reopening.last_qty = Quantity::from(591);
2923 reopening.event_id = uuid4();
2924 reopening.causation_id = Some(closing.event_id);
2925 let fill_voided = OrderFillVoidedSpec::builder()
2926 .instrument_id(second_sell.instrument_id)
2927 .client_order_id(second_sell.client_order_id)
2928 .venue_order_id(second_sell.venue_order_id)
2929 .account_id(second_sell.account_id)
2930 .trade_id(second_sell.trade_id)
2931 .voided_qty(Quantity::from(2))
2932 .order_side(second_sell.order_side)
2933 .order_type(second_sell.order_type)
2934 .last_px(second_sell.last_px)
2935 .currency(second_sell.currency)
2936 .liquidity_side(second_sell.liquidity_side)
2937 .position_id(position_id)
2938 .build();
2939 let mut position = Position::new(&instrument, opening);
2940 position.apply(&second_sell);
2941 position.apply(&closing);
2942 position.apply(&reopening);
2943
2944 position
2945 .apply_fill_void(fill_voided, Quantity::from(2), None)
2946 .unwrap();
2947
2948 assert_eq!(position.side, PositionSide::Long);
2949 assert_eq!(position.quantity, Quantity::from(593));
2950 assert_eq!(position.buy_qty, Quantity::from(625));
2951 assert_eq!(position.sell_qty, Quantity::from(32));
2952 assert_eq!(position.events.len(), 4);
2953 assert_eq!(position.replay_events.len(), 4);
2954 assert_eq!(position.fill_voids.len(), 1);
2955 assert_eq!(position.trade_ids.len(), 3);
2956 assert!(position.trade_ids.contains(&TradeId::from("T-FLIP")));
2957 }
2958
2959 #[rstest]
2960 fn test_position_realized_pnl_with_interleaved_order_sides(
2961 currency_pair_btcusdt: CurrencyPair,
2962 ) {
2963 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
2964 let order1 = OrderTestBuilder::new(OrderType::Market)
2965 .instrument_id(btcusdt.id())
2966 .side(OrderSide::Buy)
2967 .quantity(Quantity::from(12))
2968 .build();
2969 let commission1 =
2970 calculate_commission(&btcusdt, order1.quantity(), Price::from("10000.0"), None);
2971 let fill1 = TestOrderEventStubs::filled(
2972 &order1,
2973 &btcusdt,
2974 Some(TradeId::from("1")),
2975 Some(PositionId::from("P-19700101-000000-001-001-1")),
2976 Some(Price::from("10000.0")),
2977 None,
2978 None,
2979 Some(commission1),
2980 None,
2981 None,
2982 );
2983 let mut position = Position::new(&btcusdt, fill1.into());
2984 let order2 = OrderTestBuilder::new(OrderType::Market)
2985 .instrument_id(btcusdt.id())
2986 .side(OrderSide::Buy)
2987 .quantity(Quantity::from(17))
2988 .build();
2989 let commission2 =
2990 calculate_commission(&btcusdt, order2.quantity(), Price::from("9999.0"), None);
2991 let fill2 = TestOrderEventStubs::filled(
2992 &order2,
2993 &btcusdt,
2994 Some(TradeId::from("2")),
2995 Some(PositionId::from("P-19700101-000000-001-001-1")),
2996 Some(Price::from("9999.0")),
2997 None,
2998 None,
2999 Some(commission2),
3000 None,
3001 None,
3002 );
3003 position.apply(&fill2.into());
3004 assert_eq!(position.quantity, Quantity::from(29));
3005 assert_eq!(
3006 position.realized_pnl,
3007 Some(Money::from("-289.98300000 USDT"))
3008 );
3009 assert_eq!(position.avg_px_open, 9_999.413_793_103_447);
3010 let order3 = OrderTestBuilder::new(OrderType::Market)
3011 .instrument_id(btcusdt.id())
3012 .side(OrderSide::Sell)
3013 .quantity(Quantity::from(9))
3014 .build();
3015 let commission3 =
3016 calculate_commission(&btcusdt, order3.quantity(), Price::from("10001.0"), None);
3017 let fill3 = TestOrderEventStubs::filled(
3018 &order3,
3019 &btcusdt,
3020 Some(TradeId::from("3")),
3021 Some(PositionId::from("P-19700101-000000-001-001-1")),
3022 Some(Price::from("10001.0")),
3023 None,
3024 None,
3025 Some(commission3),
3026 None,
3027 None,
3028 );
3029 position.apply(&fill3.into());
3030 assert_eq!(position.quantity, Quantity::from(20));
3031 assert_eq!(
3032 position.realized_pnl,
3033 Some(Money::from("-365.71613793 USDT"))
3034 );
3035 assert_eq!(position.avg_px_open, 9_999.413_793_103_447);
3036 let order4 = OrderTestBuilder::new(OrderType::Market)
3037 .instrument_id(btcusdt.id())
3038 .side(OrderSide::Buy)
3039 .quantity(Quantity::from(3))
3040 .build();
3041 let commission4 =
3042 calculate_commission(&btcusdt, order4.quantity(), Price::from("10003.0"), None);
3043 let fill4 = TestOrderEventStubs::filled(
3044 &order4,
3045 &btcusdt,
3046 Some(TradeId::from("4")),
3047 Some(PositionId::from("P-19700101-000000-001-001-1")),
3048 Some(Price::from("10003.0")),
3049 None,
3050 None,
3051 Some(commission4),
3052 None,
3053 None,
3054 );
3055 position.apply(&fill4.into());
3056 assert_eq!(position.quantity, Quantity::from(23));
3057 assert_eq!(
3058 position.realized_pnl,
3059 Some(Money::from("-395.72513793 USDT"))
3060 );
3061 assert_eq!(position.avg_px_open, 9_999.881_559_220_39);
3062 let order5 = OrderTestBuilder::new(OrderType::Market)
3063 .instrument_id(btcusdt.id())
3064 .side(OrderSide::Sell)
3065 .quantity(Quantity::from(4))
3066 .build();
3067 let commission5 =
3068 calculate_commission(&btcusdt, order5.quantity(), Price::from("10005.0"), None);
3069 let fill5 = TestOrderEventStubs::filled(
3070 &order5,
3071 &btcusdt,
3072 Some(TradeId::from("5")),
3073 Some(PositionId::from("P-19700101-000000-001-001-1")),
3074 Some(Price::from("10005.0")),
3075 None,
3076 None,
3077 Some(commission5),
3078 None,
3079 None,
3080 );
3081 position.apply(&fill5.into());
3082 assert_eq!(position.quantity, Quantity::from(19));
3083 assert_eq!(
3084 position.realized_pnl,
3085 Some(Money::from("-415.27137481 USDT"))
3086 );
3087 assert_eq!(position.avg_px_open, 9_999.881_559_220_39);
3088 assert_eq!(
3089 format!("{position}"),
3090 "Position(LONG 19.000000 BTCUSDT.BINANCE, id=P-19700101-000000-001-001-1)"
3091 );
3092 }
3093
3094 #[rstest]
3095 fn test_calculate_pnl_when_given_position_side_flat_returns_zero(
3096 currency_pair_btcusdt: CurrencyPair,
3097 ) {
3098 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
3099 let order = OrderTestBuilder::new(OrderType::Market)
3100 .instrument_id(btcusdt.id())
3101 .side(OrderSide::Buy)
3102 .quantity(Quantity::from(12))
3103 .build();
3104 let fill = TestOrderEventStubs::filled(
3105 &order,
3106 &btcusdt,
3107 None,
3108 Some(PositionId::from("P-123456")),
3109 Some(Price::from("10500.0")),
3110 None,
3111 None,
3112 None,
3113 None,
3114 None,
3115 );
3116 let position = Position::new(&btcusdt, fill.into());
3117 let result = position.calculate_pnl(10500.0, 10500.0, Quantity::from("100000.0"));
3118 assert_eq!(result, Money::from("0 USDT"));
3119 }
3120
3121 #[rstest]
3122 fn test_calculate_pnl_for_long_position_win(currency_pair_btcusdt: CurrencyPair) {
3123 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
3124 let order = OrderTestBuilder::new(OrderType::Market)
3125 .instrument_id(btcusdt.id())
3126 .side(OrderSide::Buy)
3127 .quantity(Quantity::from(12))
3128 .build();
3129 let commission =
3130 calculate_commission(&btcusdt, order.quantity(), Price::from("10500.0"), None);
3131 let fill = TestOrderEventStubs::filled(
3132 &order,
3133 &btcusdt,
3134 None,
3135 Some(PositionId::from("P-123456")),
3136 Some(Price::from("10500.0")),
3137 None,
3138 None,
3139 Some(commission),
3140 None,
3141 None,
3142 );
3143 let position = Position::new(&btcusdt, fill.into());
3144 let pnl = position.calculate_pnl(10500.0, 10510.0, Quantity::from("12.0"));
3145 assert_eq!(pnl, Money::from("120 USDT"));
3146 assert_eq!(position.realized_pnl, Some(Money::from("-126 USDT")));
3147 assert_eq!(
3148 position.unrealized_pnl(Price::from("10510.0")),
3149 Money::from("120.0 USDT")
3150 );
3151 assert_eq!(
3152 position.total_pnl(Price::from("10510.0")),
3153 Money::from("-6 USDT")
3154 );
3155 assert_eq!(position.commissions(), vec![Money::from("126.0 USDT")]);
3156 }
3157
3158 #[rstest]
3159 fn test_calculate_pnl_for_long_position_loss(currency_pair_btcusdt: CurrencyPair) {
3160 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
3161 let order = OrderTestBuilder::new(OrderType::Market)
3162 .instrument_id(btcusdt.id())
3163 .side(OrderSide::Buy)
3164 .quantity(Quantity::from(12))
3165 .build();
3166 let commission =
3167 calculate_commission(&btcusdt, order.quantity(), Price::from("10500.0"), None);
3168 let fill = TestOrderEventStubs::filled(
3169 &order,
3170 &btcusdt,
3171 None,
3172 Some(PositionId::from("P-123456")),
3173 Some(Price::from("10500.0")),
3174 None,
3175 None,
3176 Some(commission),
3177 None,
3178 None,
3179 );
3180 let position = Position::new(&btcusdt, fill.into());
3181 let pnl = position.calculate_pnl(10500.0, 10480.5, Quantity::from("10.0"));
3182 assert_eq!(pnl, Money::from("-195 USDT"));
3183 assert_eq!(position.realized_pnl, Some(Money::from("-126 USDT")));
3184 assert_eq!(
3185 position.unrealized_pnl(Price::from("10480.50")),
3186 Money::from("-234.0 USDT")
3187 );
3188 assert_eq!(
3189 position.total_pnl(Price::from("10480.50")),
3190 Money::from("-360 USDT")
3191 );
3192 assert_eq!(position.commissions(), vec![Money::from("126.0 USDT")]);
3193 }
3194
3195 #[rstest]
3196 fn test_calculate_pnl_for_short_position_winning(currency_pair_btcusdt: CurrencyPair) {
3197 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
3198 let order = OrderTestBuilder::new(OrderType::Market)
3199 .instrument_id(btcusdt.id())
3200 .side(OrderSide::Sell)
3201 .quantity(Quantity::from("10.15"))
3202 .build();
3203 let commission =
3204 calculate_commission(&btcusdt, order.quantity(), Price::from("10500.0"), None);
3205 let fill = TestOrderEventStubs::filled(
3206 &order,
3207 &btcusdt,
3208 None,
3209 Some(PositionId::from("P-123456")),
3210 Some(Price::from("10500.0")),
3211 None,
3212 None,
3213 Some(commission),
3214 None,
3215 None,
3216 );
3217 let position = Position::new(&btcusdt, fill.into());
3218 let pnl = position.calculate_pnl(10500.0, 10390.0, Quantity::from("10.15"));
3219 assert_eq!(pnl, Money::from("1116.5 USDT"));
3220 assert_eq!(
3221 position.unrealized_pnl(Price::from("10390.0")),
3222 Money::from("1116.5 USDT")
3223 );
3224 assert_eq!(position.realized_pnl, Some(Money::from("-106.575 USDT")));
3225 assert_eq!(position.commissions(), vec![Money::from("106.575 USDT")]);
3226 assert_eq!(
3227 position.notional_value(Price::from("10390.0")),
3228 Money::from("105458.5 USDT")
3229 );
3230 }
3231
3232 #[rstest]
3233 fn test_calculate_pnl_for_short_position_loss(currency_pair_btcusdt: CurrencyPair) {
3234 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
3235 let order = OrderTestBuilder::new(OrderType::Market)
3236 .instrument_id(btcusdt.id())
3237 .side(OrderSide::Sell)
3238 .quantity(Quantity::from("10.0"))
3239 .build();
3240 let commission =
3241 calculate_commission(&btcusdt, order.quantity(), Price::from("10500.0"), None);
3242 let fill = TestOrderEventStubs::filled(
3243 &order,
3244 &btcusdt,
3245 None,
3246 Some(PositionId::from("P-123456")),
3247 Some(Price::from("10500.0")),
3248 None,
3249 None,
3250 Some(commission),
3251 None,
3252 None,
3253 );
3254 let position = Position::new(&btcusdt, fill.into());
3255 let pnl = position.calculate_pnl(10500.0, 10670.5, Quantity::from("10.0"));
3256 assert_eq!(pnl, Money::from("-1705 USDT"));
3257 assert_eq!(
3258 position.unrealized_pnl(Price::from("10670.5")),
3259 Money::from("-1705 USDT")
3260 );
3261 assert_eq!(position.realized_pnl, Some(Money::from("-105 USDT")));
3262 assert_eq!(position.commissions(), vec![Money::from("105 USDT")]);
3263 assert_eq!(
3264 position.notional_value(Price::from("10670.5")),
3265 Money::from("106705 USDT")
3266 );
3267 }
3268
3269 #[rstest]
3270 fn test_calculate_pnl_for_inverse1(xbtusd_bitmex: CryptoPerpetual) {
3271 let xbtusd_bitmex = InstrumentAny::CryptoPerpetual(xbtusd_bitmex);
3272 let order = OrderTestBuilder::new(OrderType::Market)
3273 .instrument_id(xbtusd_bitmex.id())
3274 .side(OrderSide::Sell)
3275 .quantity(Quantity::from("100000"))
3276 .build();
3277 let commission = calculate_commission(
3278 &xbtusd_bitmex,
3279 order.quantity(),
3280 Price::from("10000.0"),
3281 None,
3282 );
3283 let fill = TestOrderEventStubs::filled(
3284 &order,
3285 &xbtusd_bitmex,
3286 None,
3287 Some(PositionId::from("P-123456")),
3288 Some(Price::from("10000.0")),
3289 None,
3290 None,
3291 Some(commission),
3292 None,
3293 None,
3294 );
3295 let position = Position::new(&xbtusd_bitmex, fill.into());
3296 let pnl = position.calculate_pnl(10000.0, 11000.0, Quantity::from("100000.0"));
3297 assert_eq!(pnl, Money::from("-0.90909091 BTC"));
3298 assert_eq!(
3299 position.unrealized_pnl(Price::from("11000.0")),
3300 Money::from("-0.90909091 BTC")
3301 );
3302 assert_eq!(position.realized_pnl, Some(Money::from("-0.00750000 BTC")));
3303 assert_eq!(
3304 position.notional_value(Price::from("11000.0")),
3305 Money::from("9.09090909 BTC")
3306 );
3307 }
3308
3309 #[rstest]
3310 fn test_try_notional_value_for_inverse_zero_price_returns_error(
3311 xbtusd_bitmex: CryptoPerpetual,
3312 ) {
3313 let xbtusd_bitmex = InstrumentAny::CryptoPerpetual(xbtusd_bitmex);
3314 let order = OrderTestBuilder::new(OrderType::Market)
3315 .instrument_id(xbtusd_bitmex.id())
3316 .side(OrderSide::Sell)
3317 .quantity(Quantity::from("100000"))
3318 .build();
3319 let fill = TestOrderEventStubs::filled(
3320 &order,
3321 &xbtusd_bitmex,
3322 None,
3323 Some(PositionId::from("P-ZERO-PRICE")),
3324 Some(Price::from("10000.0")),
3325 None,
3326 None,
3327 None,
3328 None,
3329 None,
3330 );
3331 let mut position = Position::new(&xbtusd_bitmex, fill.into());
3332
3333 let result = position.try_notional_value(Price::new(0.0, 1));
3334
3335 assert_eq!(
3336 result.unwrap_err().to_string(),
3337 "price must be positive for inverse notional valuation"
3338 );
3339 assert!(
3340 position
3341 .try_calculate_pnl(10_000.0, 0.0, position.quantity)
3342 .is_err()
3343 );
3344 assert!(position.try_unrealized_pnl(Price::new(0.0, 1)).is_err());
3345 assert!(position.try_total_pnl(Price::new(0.0, 1)).is_err());
3346 assert!(position.try_unrealized_pnl(Price::new(-1.0, 1)).is_err());
3347
3348 position.base_currency = None;
3349 let result = position.try_notional_value(Price::from("10000.0"));
3350
3351 assert_eq!(
3352 result.unwrap_err().to_string(),
3353 "inverse position BTCUSDT.BITMEX has no base currency"
3354 );
3355 assert!(position.try_unrealized_pnl(Price::from("10000.0")).is_err());
3356 }
3357
3358 #[rstest]
3359 fn test_calculate_pnl_for_inverse2(ethusdt_bitmex: CryptoPerpetual) {
3360 let ethusdt_bitmex = InstrumentAny::CryptoPerpetual(ethusdt_bitmex);
3361 let order = OrderTestBuilder::new(OrderType::Market)
3362 .instrument_id(ethusdt_bitmex.id())
3363 .side(OrderSide::Sell)
3364 .quantity(Quantity::from("100000"))
3365 .build();
3366 let commission = calculate_commission(
3367 ðusdt_bitmex,
3368 order.quantity(),
3369 Price::from("375.95"),
3370 None,
3371 );
3372 let fill = TestOrderEventStubs::filled(
3373 &order,
3374 ðusdt_bitmex,
3375 None,
3376 Some(PositionId::from("P-123456")),
3377 Some(Price::from("375.95")),
3378 None,
3379 None,
3380 Some(commission),
3381 None,
3382 None,
3383 );
3384 let position = Position::new(ðusdt_bitmex, fill.into());
3385
3386 assert_eq!(
3387 position.unrealized_pnl(Price::from("370.00")),
3388 Money::from("4.27745208 ETH")
3389 );
3390 assert_eq!(
3391 position.notional_value(Price::from("370.00")),
3392 Money::from("270.27027027 ETH")
3393 );
3394 }
3395
3396 #[rstest]
3397 fn test_notional_value_for_quanto_uses_settlement_currency(ethbtc_quanto: CryptoFuture) {
3398 let instrument = InstrumentAny::CryptoFuture(ethbtc_quanto);
3399 let order = OrderTestBuilder::new(OrderType::Market)
3400 .instrument_id(instrument.id())
3401 .side(OrderSide::Buy)
3402 .quantity(Quantity::from("5"))
3403 .build();
3404 let price = Price::from("0.03600");
3405 let fill = TestOrderEventStubs::filled(
3406 &order,
3407 &instrument,
3408 None,
3409 Some(PositionId::from("P-QUANTO-NOTIONAL")),
3410 Some(price),
3411 None,
3412 None,
3413 None,
3414 None,
3415 None,
3416 );
3417 let position = Position::new(&instrument, fill.into());
3418 let position_notional = position.notional_value(price);
3419 let instrument_notional =
3420 instrument.calculate_notional_value(position.quantity, price, None);
3421
3422 assert_eq!(position_notional, instrument_notional);
3423 assert_eq!(position_notional, Money::from("0.18 USDT"));
3424 }
3425
3426 #[rstest]
3427 fn test_calculate_unrealized_pnl_for_long(currency_pair_btcusdt: CurrencyPair) {
3428 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
3429 let order1 = OrderTestBuilder::new(OrderType::Market)
3430 .instrument_id(btcusdt.id())
3431 .side(OrderSide::Buy)
3432 .quantity(Quantity::from("2.000000"))
3433 .build();
3434 let order2 = OrderTestBuilder::new(OrderType::Market)
3435 .instrument_id(btcusdt.id())
3436 .side(OrderSide::Buy)
3437 .quantity(Quantity::from("2.000000"))
3438 .build();
3439 let commission1 =
3440 calculate_commission(&btcusdt, order1.quantity(), Price::from("10500.0"), None);
3441 let fill1 = TestOrderEventStubs::filled(
3442 &order1,
3443 &btcusdt,
3444 Some(TradeId::new("1")),
3445 Some(PositionId::new("P-123456")),
3446 Some(Price::from("10500.00")),
3447 None,
3448 None,
3449 Some(commission1),
3450 None,
3451 None,
3452 );
3453 let commission2 =
3454 calculate_commission(&btcusdt, order2.quantity(), Price::from("10500.0"), None);
3455 let fill2 = TestOrderEventStubs::filled(
3456 &order2,
3457 &btcusdt,
3458 Some(TradeId::new("2")),
3459 Some(PositionId::new("P-123456")),
3460 Some(Price::from("10500.00")),
3461 None,
3462 None,
3463 Some(commission2),
3464 None,
3465 None,
3466 );
3467 let mut position = Position::new(&btcusdt, fill1.into());
3468 position.apply(&fill2.into());
3469 let pnl = position.unrealized_pnl(Price::from("11505.60"));
3470 assert_eq!(pnl, Money::from("4022.40000000 USDT"));
3471 assert_eq!(
3472 position.realized_pnl,
3473 Some(Money::from("-42.00000000 USDT"))
3474 );
3475 assert_eq!(
3476 position.commissions(),
3477 vec![Money::from("42.00000000 USDT")]
3478 );
3479 }
3480
3481 #[rstest]
3482 fn test_calculate_unrealized_pnl_for_short(currency_pair_btcusdt: CurrencyPair) {
3483 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
3484 let order = OrderTestBuilder::new(OrderType::Market)
3485 .instrument_id(btcusdt.id())
3486 .side(OrderSide::Sell)
3487 .quantity(Quantity::from("5.912000"))
3488 .build();
3489 let commission =
3490 calculate_commission(&btcusdt, order.quantity(), Price::from("10505.60"), None);
3491 let fill = TestOrderEventStubs::filled(
3492 &order,
3493 &btcusdt,
3494 Some(TradeId::new("1")),
3495 Some(PositionId::new("P-123456")),
3496 Some(Price::from("10505.60")),
3497 None,
3498 None,
3499 Some(commission),
3500 None,
3501 None,
3502 );
3503 let position = Position::new(&btcusdt, fill.into());
3504 let pnl = position.unrealized_pnl(Price::from("10407.15"));
3505 assert_eq!(pnl, Money::from("582.03640000 USDT"));
3506 assert_eq!(
3507 position.realized_pnl,
3508 Some(Money::from("-62.10910720 USDT"))
3509 );
3510 assert_eq!(
3511 position.commissions(),
3512 vec![Money::from("62.10910720 USDT")]
3513 );
3514 }
3515
3516 #[rstest]
3517 fn test_calculate_unrealized_pnl_for_long_inverse(xbtusd_bitmex: CryptoPerpetual) {
3518 let xbtusd_bitmex = InstrumentAny::CryptoPerpetual(xbtusd_bitmex);
3519 let order = OrderTestBuilder::new(OrderType::Market)
3520 .instrument_id(xbtusd_bitmex.id())
3521 .side(OrderSide::Buy)
3522 .quantity(Quantity::from("100000"))
3523 .build();
3524 let commission = calculate_commission(
3525 &xbtusd_bitmex,
3526 order.quantity(),
3527 Price::from("10500.0"),
3528 None,
3529 );
3530 let fill = TestOrderEventStubs::filled(
3531 &order,
3532 &xbtusd_bitmex,
3533 Some(TradeId::new("1")),
3534 Some(PositionId::new("P-123456")),
3535 Some(Price::from("10500.00")),
3536 None,
3537 None,
3538 Some(commission),
3539 None,
3540 None,
3541 );
3542
3543 let position = Position::new(&xbtusd_bitmex, fill.into());
3544 let pnl = position.unrealized_pnl(Price::from("11505.60"));
3545 assert_eq!(pnl, Money::from("0.83238969 BTC"));
3546 assert_eq!(position.realized_pnl, Some(Money::from("-0.00714286 BTC")));
3547 assert_eq!(position.commissions(), vec![Money::from("0.00714286 BTC")]);
3548 }
3549
3550 #[rstest]
3551 fn test_calculate_unrealized_pnl_for_short_inverse(xbtusd_bitmex: CryptoPerpetual) {
3552 let xbtusd_bitmex = InstrumentAny::CryptoPerpetual(xbtusd_bitmex);
3553 let order = OrderTestBuilder::new(OrderType::Market)
3554 .instrument_id(xbtusd_bitmex.id())
3555 .side(OrderSide::Sell)
3556 .quantity(Quantity::from("1250000"))
3557 .build();
3558 let commission = calculate_commission(
3559 &xbtusd_bitmex,
3560 order.quantity(),
3561 Price::from("15500.00"),
3562 None,
3563 );
3564 let fill = TestOrderEventStubs::filled(
3565 &order,
3566 &xbtusd_bitmex,
3567 Some(TradeId::new("1")),
3568 Some(PositionId::new("P-123456")),
3569 Some(Price::from("15500.00")),
3570 None,
3571 None,
3572 Some(commission),
3573 None,
3574 None,
3575 );
3576 let position = Position::new(&xbtusd_bitmex, fill.into());
3577 let pnl = position.unrealized_pnl(Price::from("12506.65"));
3578
3579 assert_eq!(pnl, Money::from("19.30166700 BTC"));
3580 assert_eq!(position.realized_pnl, Some(Money::from("-0.06048387 BTC")));
3581 assert_eq!(position.commissions(), vec![Money::from("0.06048387 BTC")]);
3582 }
3583
3584 #[rstest]
3585 #[case(OrderSide::Buy, 25, 25.0)]
3586 #[case(OrderSide::Sell,25,-25.0)]
3587 fn test_signed_qty_decimal_qty_for_equity(
3588 #[case] order_side: OrderSide,
3589 #[case] quantity: i64,
3590 #[case] expected: f64,
3591 audusd_sim: CurrencyPair,
3592 ) {
3593 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3594 let order = OrderTestBuilder::new(OrderType::Market)
3595 .instrument_id(audusd_sim.id())
3596 .side(order_side)
3597 .quantity(Quantity::from(quantity))
3598 .build();
3599
3600 let commission =
3601 calculate_commission(&audusd_sim, order.quantity(), Price::from("1.0"), None);
3602 let fill = TestOrderEventStubs::filled(
3603 &order,
3604 &audusd_sim,
3605 None,
3606 Some(PositionId::from("P-123456")),
3607 None,
3608 None,
3609 None,
3610 Some(commission),
3611 None,
3612 None,
3613 );
3614 let position = Position::new(&audusd_sim, fill.into());
3615 assert_eq!(position.signed_qty, expected);
3616 }
3617
3618 #[rstest]
3619 fn test_position_with_commission_none(audusd_sim: CurrencyPair) {
3620 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3621 let fill = OrderFilledSpec::builder()
3622 .position_id(PositionId::from("1"))
3623 .build();
3624
3625 let position = Position::new(&audusd_sim, fill);
3626 assert_eq!(position.realized_pnl, Some(Money::from("0 USD")));
3627 }
3628
3629 #[rstest]
3630 fn test_position_with_commission_zero(audusd_sim: CurrencyPair) {
3631 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3632 let fill = OrderFilledSpec::builder()
3633 .position_id(PositionId::from("1"))
3634 .commission(Money::from("0 USD"))
3635 .build();
3636
3637 let position = Position::new(&audusd_sim, fill);
3638 assert_eq!(position.realized_pnl, Some(Money::from("0 USD")));
3639 }
3640
3641 #[rstest]
3642 fn test_cache_purge_order_events() {
3643 let audusd_sim = audusd_sim();
3644 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3645
3646 let order1 = OrderTestBuilder::new(OrderType::Market)
3647 .client_order_id(ClientOrderId::new("O-1"))
3648 .instrument_id(audusd_sim.id())
3649 .side(OrderSide::Buy)
3650 .quantity(Quantity::from(50_000))
3651 .build();
3652
3653 let order2 = OrderTestBuilder::new(OrderType::Market)
3654 .client_order_id(ClientOrderId::new("O-2"))
3655 .instrument_id(audusd_sim.id())
3656 .side(OrderSide::Buy)
3657 .quantity(Quantity::from(50_000))
3658 .build();
3659
3660 let position_id = PositionId::new("P-123456");
3661
3662 let fill1 = TestOrderEventStubs::filled(
3663 &order1,
3664 &audusd_sim,
3665 Some(TradeId::new("1")),
3666 Some(position_id),
3667 Some(Price::from("1.00001")),
3668 None,
3669 None,
3670 None,
3671 None,
3672 None,
3673 );
3674
3675 let mut position = Position::new(&audusd_sim, fill1.into());
3676
3677 let fill2 = TestOrderEventStubs::filled(
3678 &order2,
3679 &audusd_sim,
3680 Some(TradeId::new("2")),
3681 Some(position_id),
3682 Some(Price::from("1.00002")),
3683 None,
3684 None,
3685 None,
3686 None,
3687 None,
3688 );
3689
3690 position.apply(&fill2.into());
3691 position.purge_events_for_order(order1.client_order_id());
3692
3693 assert_eq!(position.events.len(), 1);
3694 assert_eq!(position.trade_ids.len(), 1);
3695 assert_eq!(position.events[0].client_order_id, order2.client_order_id());
3696 assert!(position.trade_ids.contains(&TradeId::new("2")));
3697 }
3698
3699 #[rstest]
3700 fn test_purge_all_events_returns_none_for_last_event_and_trade_id() {
3701 let audusd_sim = audusd_sim();
3702 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3703
3704 let order = OrderTestBuilder::new(OrderType::Market)
3705 .client_order_id(ClientOrderId::new("O-1"))
3706 .instrument_id(audusd_sim.id())
3707 .side(OrderSide::Buy)
3708 .quantity(Quantity::from(100_000))
3709 .build();
3710
3711 let position_id = PositionId::new("P-123456");
3712 let fill = TestOrderEventStubs::filled(
3713 &order,
3714 &audusd_sim,
3715 Some(TradeId::new("1")),
3716 Some(position_id),
3717 Some(Price::from("1.00050")),
3718 None,
3719 None,
3720 None,
3721 Some(UnixNanos::from(1_000_000_000)), None,
3723 );
3724
3725 let mut position = Position::new(&audusd_sim, fill.into());
3726
3727 assert_eq!(position.events.len(), 1);
3728 assert!(position.last_event().is_some());
3729 assert!(position.last_trade_id().is_some());
3730
3731 let original_ts_opened = position.ts_opened;
3733 let original_ts_last = position.ts_last;
3734 assert_ne!(original_ts_opened, UnixNanos::default());
3735 assert_ne!(original_ts_last, UnixNanos::default());
3736
3737 position.purge_events_for_order(order.client_order_id());
3738
3739 assert_eq!(position.events.len(), 0);
3740 assert_eq!(position.trade_ids.len(), 0);
3741 assert!(position.last_event().is_none());
3742 assert!(position.last_trade_id().is_none());
3743
3744 assert_eq!(position.ts_opened, UnixNanos::default());
3747 assert_eq!(position.ts_last, UnixNanos::default());
3748 assert_eq!(position.ts_closed, Some(UnixNanos::default()));
3749 assert_eq!(position.duration_ns, 0);
3750
3751 assert!(position.is_closed());
3754 assert!(!position.is_open());
3755 assert_eq!(position.side, PositionSide::Flat);
3756 }
3757
3758 #[rstest]
3759 fn test_revive_from_empty_shell(audusd_sim: CurrencyPair) {
3760 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3762
3763 let order1 = OrderTestBuilder::new(OrderType::Market)
3765 .instrument_id(audusd_sim.id())
3766 .side(OrderSide::Buy)
3767 .quantity(Quantity::from(100_000))
3768 .build();
3769
3770 let fill1 = TestOrderEventStubs::filled(
3771 &order1,
3772 &audusd_sim,
3773 None,
3774 Some(PositionId::new("P-1")),
3775 Some(Price::from("1.00000")),
3776 None,
3777 None,
3778 None,
3779 Some(UnixNanos::from(1_000_000_000)),
3780 None,
3781 );
3782
3783 let mut position = Position::new(&audusd_sim, fill1.into());
3784 position.purge_events_for_order(order1.client_order_id());
3785
3786 assert!(position.is_closed());
3788 assert_eq!(position.ts_closed, Some(UnixNanos::default()));
3789 assert_eq!(position.event_count(), 0);
3790
3791 let order2 = OrderTestBuilder::new(OrderType::Market)
3793 .instrument_id(audusd_sim.id())
3794 .side(OrderSide::Buy)
3795 .quantity(Quantity::from(50_000))
3796 .build();
3797
3798 let fill2 = TestOrderEventStubs::filled(
3799 &order2,
3800 &audusd_sim,
3801 None,
3802 Some(PositionId::new("P-1")),
3803 Some(Price::from("1.00020")),
3804 None,
3805 None,
3806 None,
3807 Some(UnixNanos::from(3_000_000_000)),
3808 None,
3809 );
3810
3811 let fill2_typed: OrderFilled = fill2.clone().into();
3812 position.apply(&fill2_typed);
3813
3814 assert!(position.is_long());
3816 assert!(!position.is_closed());
3817 assert!(position.ts_closed.is_none());
3818 assert_eq!(position.ts_opened, fill2.ts_event());
3819 assert_eq!(position.ts_last, fill2.ts_event());
3820 assert_eq!(position.event_count(), 1);
3821 assert_eq!(position.quantity, Quantity::from(50_000));
3822 }
3823
3824 #[rstest]
3825 fn test_empty_shell_position_invariants(audusd_sim: CurrencyPair) {
3826 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3828
3829 let order = OrderTestBuilder::new(OrderType::Market)
3830 .instrument_id(audusd_sim.id())
3831 .side(OrderSide::Buy)
3832 .quantity(Quantity::from(100_000))
3833 .build();
3834
3835 let fill = TestOrderEventStubs::filled(
3836 &order,
3837 &audusd_sim,
3838 None,
3839 Some(PositionId::new("P-1")),
3840 Some(Price::from("1.00000")),
3841 None,
3842 None,
3843 None,
3844 Some(UnixNanos::from(1_000_000_000)),
3845 None,
3846 );
3847
3848 let mut position = Position::new(&audusd_sim, fill.into());
3849 position.purge_events_for_order(order.client_order_id());
3850
3851 assert_eq!(
3853 position.event_count(),
3854 0,
3855 "Precondition: event_count must be 0"
3856 );
3857
3858 assert!(
3860 position.is_closed(),
3861 "INV1: Empty shell must report is_closed() == true"
3862 );
3863 assert!(
3864 !position.is_open(),
3865 "INV1: Empty shell must report is_open() == false"
3866 );
3867
3868 assert_eq!(
3870 position.side,
3871 PositionSide::Flat,
3872 "INV2: Empty shell must be FLAT"
3873 );
3874
3875 assert!(
3877 position.ts_closed.is_some(),
3878 "INV3: Empty shell must have ts_closed.is_some()"
3879 );
3880 assert_eq!(
3881 position.ts_closed,
3882 Some(UnixNanos::default()),
3883 "INV3: Empty shell ts_closed must be 0"
3884 );
3885
3886 assert_eq!(
3888 position.ts_opened,
3889 UnixNanos::default(),
3890 "INV4: Empty shell ts_opened must be 0"
3891 );
3892 assert_eq!(
3893 position.ts_last,
3894 UnixNanos::default(),
3895 "INV4: Empty shell ts_last must be 0"
3896 );
3897 assert_eq!(
3898 position.duration_ns, 0,
3899 "INV4: Empty shell duration_ns must be 0"
3900 );
3901
3902 assert_eq!(
3904 position.quantity,
3905 Quantity::zero(audusd_sim.size_precision()),
3906 "INV5: Empty shell quantity must be 0"
3907 );
3908
3909 assert!(
3911 position.events.is_empty(),
3912 "INV6: Empty shell must have no events"
3913 );
3914 assert!(
3915 position.trade_ids.is_empty(),
3916 "INV6: Empty shell must have no trade IDs"
3917 );
3918 assert!(
3919 position.last_event().is_none(),
3920 "INV6: Empty shell must have no last event"
3921 );
3922 assert!(
3923 position.last_trade_id().is_none(),
3924 "INV6: Empty shell must have no last trade ID"
3925 );
3926 }
3927
3928 #[rstest]
3929 fn test_position_pnl_precision_with_very_small_amounts(audusd_sim: CurrencyPair) {
3930 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3933 let order = OrderTestBuilder::new(OrderType::Market)
3934 .instrument_id(audusd_sim.id())
3935 .side(OrderSide::Buy)
3936 .quantity(Quantity::from(100))
3937 .build();
3938
3939 let small_commission = Money::new(0.01, Currency::USD());
3941 let fill = TestOrderEventStubs::filled(
3942 &order,
3943 &audusd_sim,
3944 None,
3945 None,
3946 Some(Price::from("1.00001")),
3947 Some(Quantity::from(100)),
3948 None,
3949 Some(small_commission),
3950 None,
3951 None,
3952 );
3953
3954 let position = Position::new(&audusd_sim, fill.into());
3955
3956 assert_eq!(position.commissions().len(), 1);
3958 let recorded_commission = position.commissions()[0];
3959 assert!(
3960 recorded_commission.as_f64() > 0.0,
3961 "Commission of 0.01 should be preserved"
3962 );
3963
3964 let realized = position.realized_pnl.unwrap().as_f64();
3966 assert!(
3967 realized < 0.0,
3968 "Realized PnL should be negative due to commission"
3969 );
3970 }
3971
3972 #[rstest]
3973 fn test_position_pnl_precision_with_high_precision_instrument() {
3974 use crate::instruments::stubs::crypto_perpetual_ethusdt;
3976 let ethusdt = crypto_perpetual_ethusdt();
3977 let ethusdt = InstrumentAny::CryptoPerpetual(ethusdt);
3978
3979 let size_precision = ethusdt.size_precision();
3981
3982 let order = OrderTestBuilder::new(OrderType::Market)
3983 .instrument_id(ethusdt.id())
3984 .side(OrderSide::Buy)
3985 .quantity(Quantity::from("1.123456789"))
3986 .build();
3987
3988 let fill = TestOrderEventStubs::filled(
3989 &order,
3990 ðusdt,
3991 None,
3992 None,
3993 Some(Price::from("2345.123456789")),
3994 Some(Quantity::from("1.123456789")),
3995 None,
3996 Some(Money::from("0.1 USDT")),
3997 None,
3998 None,
3999 );
4000
4001 let position = Position::new(ðusdt, fill.into());
4002
4003 let avg_px = position.avg_px_open;
4005 assert!(
4006 (avg_px - 2_345.123_456_789).abs() < 1e-6,
4007 "High precision price should be preserved within f64 tolerance"
4008 );
4009
4010 assert_eq!(
4013 position.quantity.precision, size_precision,
4014 "Quantity precision should match instrument"
4015 );
4016
4017 let qty_f64 = position.quantity.as_f64();
4019 assert!(
4020 qty_f64 > 1.0 && qty_f64 < 2.0,
4021 "Quantity should be in expected range"
4022 );
4023 }
4024
4025 #[rstest]
4026 fn test_position_pnl_accumulation_across_many_fills(audusd_sim: CurrencyPair) {
4027 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
4029 let order = OrderTestBuilder::new(OrderType::Market)
4030 .instrument_id(audusd_sim.id())
4031 .side(OrderSide::Buy)
4032 .quantity(Quantity::from(1000))
4033 .build();
4034
4035 let initial_fill = TestOrderEventStubs::filled(
4036 &order,
4037 &audusd_sim,
4038 Some(TradeId::new("1")),
4039 None,
4040 Some(Price::from("1.00000")),
4041 Some(Quantity::from(10)),
4042 None,
4043 Some(Money::from("0.01 USD")),
4044 None,
4045 None,
4046 );
4047
4048 let mut position = Position::new(&audusd_sim, initial_fill.into());
4049
4050 for i in 2..=100 {
4052 let price_offset = f64::from(i) * 0.00001;
4053 let fill = TestOrderEventStubs::filled(
4054 &order,
4055 &audusd_sim,
4056 Some(TradeId::new(i.to_string())),
4057 None,
4058 Some(Price::from(&format!("{:.5}", 1.0 + price_offset))),
4059 Some(Quantity::from(10)),
4060 None,
4061 Some(Money::from("0.01 USD")),
4062 None,
4063 None,
4064 );
4065 position.apply(&fill.into());
4066 }
4067
4068 assert_eq!(position.events.len(), 100);
4070 assert_eq!(position.quantity, Quantity::from(1000));
4071
4072 let total_commission: f64 = position.commissions().iter().map(Money::as_f64).sum();
4074 assert!(
4075 (total_commission - 1.0).abs() < 1e-10,
4076 "Commission accumulation should be accurate: expected 1.0, was {total_commission}"
4077 );
4078
4079 let avg_px = position.avg_px_open;
4081 assert!(
4082 avg_px > 1.0 && avg_px < 1.001,
4083 "Average price should be reasonable: got {avg_px}"
4084 );
4085 }
4086
4087 #[rstest]
4088 fn test_position_pnl_with_extreme_price_values(audusd_sim: CurrencyPair) {
4089 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
4091
4092 let order_small = OrderTestBuilder::new(OrderType::Market)
4094 .instrument_id(audusd_sim.id())
4095 .side(OrderSide::Buy)
4096 .quantity(Quantity::from(100_000))
4097 .build();
4098
4099 let fill_small = TestOrderEventStubs::filled(
4100 &order_small,
4101 &audusd_sim,
4102 None,
4103 None,
4104 Some(Price::from("0.00001")),
4105 Some(Quantity::from(100_000)),
4106 None,
4107 None,
4108 None,
4109 None,
4110 );
4111
4112 let position_small = Position::new(&audusd_sim, fill_small.into());
4113 assert_eq!(position_small.avg_px_open, 0.00001);
4114
4115 let last_price_small = Price::from("0.00002");
4117 let unrealized = position_small.unrealized_pnl(last_price_small);
4118 assert!(
4119 unrealized.as_f64() > 0.0,
4120 "Unrealized PnL should be positive when price doubles"
4121 );
4122
4123 let order_large = OrderTestBuilder::new(OrderType::Market)
4125 .instrument_id(audusd_sim.id())
4126 .side(OrderSide::Buy)
4127 .quantity(Quantity::from(100))
4128 .build();
4129
4130 let fill_large = TestOrderEventStubs::filled(
4131 &order_large,
4132 &audusd_sim,
4133 None,
4134 None,
4135 Some(Price::from("99999.99999")),
4136 Some(Quantity::from(100)),
4137 None,
4138 None,
4139 None,
4140 None,
4141 );
4142
4143 let position_large = Position::new(&audusd_sim, fill_large.into());
4144 assert!(
4145 (position_large.avg_px_open - 99999.99999).abs() < 1e-6,
4146 "Large price should be preserved within f64 tolerance"
4147 );
4148 }
4149
4150 #[rstest]
4151 fn test_position_pnl_roundtrip_precision(audusd_sim: CurrencyPair) {
4152 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
4154 let buy_order = OrderTestBuilder::new(OrderType::Market)
4155 .instrument_id(audusd_sim.id())
4156 .side(OrderSide::Buy)
4157 .quantity(Quantity::from(100_000))
4158 .build();
4159
4160 let sell_order = OrderTestBuilder::new(OrderType::Market)
4161 .instrument_id(audusd_sim.id())
4162 .side(OrderSide::Sell)
4163 .quantity(Quantity::from(100_000))
4164 .build();
4165
4166 let open_fill = TestOrderEventStubs::filled(
4168 &buy_order,
4169 &audusd_sim,
4170 Some(TradeId::new("1")),
4171 None,
4172 Some(Price::from("1.123456")),
4173 None,
4174 None,
4175 Some(Money::from("0.50 USD")),
4176 None,
4177 None,
4178 );
4179
4180 let mut position = Position::new(&audusd_sim, open_fill.into());
4181
4182 let close_fill = TestOrderEventStubs::filled(
4184 &sell_order,
4185 &audusd_sim,
4186 Some(TradeId::new("2")),
4187 None,
4188 Some(Price::from("1.123456")),
4189 None,
4190 None,
4191 Some(Money::from("0.50 USD")),
4192 None,
4193 None,
4194 );
4195
4196 position.apply(&close_fill.into());
4197
4198 assert!(position.is_closed());
4200
4201 let realized = position.realized_pnl.unwrap().as_f64();
4203 assert!(
4204 (realized - (-1.0)).abs() < 1e-10,
4205 "Realized PnL should be exactly -1.0 USD (commissions), was {realized}"
4206 );
4207 }
4208
4209 #[rstest]
4210 fn test_position_commission_in_base_currency_buy() {
4211 let btc_usdt = currency_pair_btcusdt();
4213 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
4214
4215 let order = OrderTestBuilder::new(OrderType::Market)
4216 .instrument_id(btc_usdt.id())
4217 .side(OrderSide::Buy)
4218 .quantity(Quantity::from("1.0"))
4219 .build();
4220
4221 let fill = match TestOrderEventStubs::filled(
4223 &order,
4224 &btc_usdt,
4225 Some(TradeId::new("1")),
4226 None,
4227 Some(Price::from("50000.0")),
4228 Some(Quantity::from("1.0")),
4229 None,
4230 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
4231 None,
4232 None,
4233 ) {
4234 OrderEventAny::Filled(fill) => fill,
4235 _ => unreachable!(),
4236 };
4237
4238 let position = Position::new(&btc_usdt, fill.clone());
4239 let replayed_position = Position::new(&btc_usdt, fill);
4240
4241 assert!(
4243 (position.quantity.as_f64() - 0.999).abs() < 1e-9,
4244 "Position quantity should be 0.999 BTC (1.0 - 0.001 commission), was {}",
4245 position.quantity.as_f64()
4246 );
4247
4248 assert!(
4250 (position.signed_qty - 0.999).abs() < 1e-9,
4251 "Signed qty should be 0.999, was {}",
4252 position.signed_qty
4253 );
4254
4255 assert_eq!(
4257 position.adjustments.len(),
4258 1,
4259 "Should have 1 adjustment event"
4260 );
4261 let adjustment = &position.adjustments[0];
4262 assert_eq!(
4263 adjustment.adjustment_type,
4264 PositionAdjustmentType::Commission
4265 );
4266 assert_eq!(
4267 adjustment.quantity_change,
4268 Some(rust_decimal_macros::dec!(-0.001))
4269 );
4270 assert_eq!(adjustment.pnl_change, None);
4271 assert_eq!(
4272 adjustment.event_id,
4273 replayed_position.adjustments[0].event_id
4274 );
4275 }
4276
4277 #[rstest]
4278 fn test_position_commission_in_base_currency_sell() {
4279 let btc_usdt = currency_pair_btcusdt();
4281 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
4282
4283 let order = OrderTestBuilder::new(OrderType::Market)
4284 .instrument_id(btc_usdt.id())
4285 .side(OrderSide::Sell)
4286 .quantity(Quantity::from("1.0"))
4287 .build();
4288
4289 let fill = TestOrderEventStubs::filled(
4291 &order,
4292 &btc_usdt,
4293 Some(TradeId::new("1")),
4294 None,
4295 Some(Price::from("50000.0")),
4296 Some(Quantity::from("1.0")),
4297 None,
4298 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
4299 None,
4300 None,
4301 );
4302
4303 let position = Position::new(&btc_usdt, fill.into());
4304
4305 assert!(
4308 (position.quantity.as_f64() - 1.001).abs() < 1e-9,
4309 "Position quantity should be 1.001 BTC (1.0 + 0.001 commission), was {}",
4310 position.quantity.as_f64()
4311 );
4312
4313 assert!(
4315 (position.signed_qty - (-1.001)).abs() < 1e-9,
4316 "Signed qty should be -1.001, was {}",
4317 position.signed_qty
4318 );
4319
4320 assert_eq!(
4322 position.adjustments.len(),
4323 1,
4324 "Should have 1 adjustment event"
4325 );
4326 let adjustment = &position.adjustments[0];
4327 assert_eq!(
4328 adjustment.adjustment_type,
4329 PositionAdjustmentType::Commission
4330 );
4331 assert_eq!(
4333 adjustment.quantity_change,
4334 Some(rust_decimal_macros::dec!(-0.001))
4335 );
4336 assert_eq!(adjustment.pnl_change, None);
4337 }
4338
4339 #[rstest]
4340 fn test_position_commission_in_quote_currency_no_adjustment() {
4341 let btc_usdt = currency_pair_btcusdt();
4343 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
4344
4345 let order = OrderTestBuilder::new(OrderType::Market)
4346 .instrument_id(btc_usdt.id())
4347 .side(OrderSide::Buy)
4348 .quantity(Quantity::from("1.0"))
4349 .build();
4350
4351 let fill = TestOrderEventStubs::filled(
4353 &order,
4354 &btc_usdt,
4355 Some(TradeId::new("1")),
4356 None,
4357 Some(Price::from("50000.0")),
4358 Some(Quantity::from("1.0")),
4359 None,
4360 Some(Money::new(50.0, Currency::USD())),
4361 None,
4362 None,
4363 );
4364
4365 let position = Position::new(&btc_usdt, fill.into());
4366
4367 assert!(
4369 (position.quantity.as_f64() - 1.0).abs() < 1e-9,
4370 "Position quantity should be 1.0 BTC (no adjustment for quote currency commission), was {}",
4371 position.quantity.as_f64()
4372 );
4373
4374 assert_eq!(
4376 position.adjustments.len(),
4377 0,
4378 "Should have no adjustment events for quote currency commission"
4379 );
4380 }
4381
4382 #[rstest]
4383 fn test_position_reset_clears_adjustments() {
4384 let btc_usdt = currency_pair_btcusdt();
4386 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
4387
4388 let buy_order = OrderTestBuilder::new(OrderType::Market)
4390 .instrument_id(btc_usdt.id())
4391 .side(OrderSide::Buy)
4392 .quantity(Quantity::from("1.0"))
4393 .build();
4394
4395 let buy_fill = TestOrderEventStubs::filled(
4396 &buy_order,
4397 &btc_usdt,
4398 Some(TradeId::new("1")),
4399 None,
4400 Some(Price::from("50000.0")),
4401 Some(Quantity::from("1.0")),
4402 None,
4403 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
4404 None,
4405 None,
4406 );
4407
4408 let mut position = Position::new(&btc_usdt, buy_fill.into());
4409 assert_eq!(position.adjustments.len(), 1, "Should have 1 adjustment");
4410
4411 let sell_order = OrderTestBuilder::new(OrderType::Market)
4413 .instrument_id(btc_usdt.id())
4414 .side(OrderSide::Sell)
4415 .quantity(Quantity::from("0.999"))
4416 .build();
4417
4418 let sell_fill = TestOrderEventStubs::filled(
4419 &sell_order,
4420 &btc_usdt,
4421 Some(TradeId::new("2")),
4422 None,
4423 Some(Price::from("51000.0")),
4424 Some(Quantity::from("0.999")),
4425 None,
4426 Some(Money::new(50.0, Currency::USD())), None,
4428 None,
4429 );
4430
4431 position.apply(&sell_fill.into());
4432 assert_eq!(position.side, PositionSide::Flat);
4433 assert_eq!(
4434 position.adjustments.len(),
4435 1,
4436 "Should still have 1 adjustment (no new one from quote commission)"
4437 );
4438
4439 let buy_order2 = OrderTestBuilder::new(OrderType::Market)
4441 .instrument_id(btc_usdt.id())
4442 .side(OrderSide::Buy)
4443 .quantity(Quantity::from("2.0"))
4444 .build();
4445
4446 let buy_fill2 = TestOrderEventStubs::filled(
4447 &buy_order2,
4448 &btc_usdt,
4449 Some(TradeId::new("3")),
4450 None,
4451 Some(Price::from("52000.0")),
4452 Some(Quantity::from("2.0")),
4453 None,
4454 Some(Money::new(0.002, btc_usdt.base_currency().unwrap())),
4455 None,
4456 None,
4457 );
4458
4459 position.apply(&buy_fill2.into());
4460
4461 assert_eq!(
4463 position.adjustments.len(),
4464 1,
4465 "Adjustments should be cleared on position reset, only new adjustment"
4466 );
4467 assert_eq!(
4468 position.adjustments[0].quantity_change,
4469 Some(rust_decimal_macros::dec!(-0.002)),
4470 "New adjustment should be for the new fill"
4471 );
4472 assert_eq!(position.events.len(), 1, "Events should also be reset");
4473 }
4474
4475 #[rstest]
4476 fn test_purge_events_for_order_clears_adjustments_when_flat() {
4477 let btc_usdt = currency_pair_btcusdt();
4479 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
4480
4481 let order = OrderTestBuilder::new(OrderType::Market)
4482 .instrument_id(btc_usdt.id())
4483 .side(OrderSide::Buy)
4484 .quantity(Quantity::from("1.0"))
4485 .build();
4486
4487 let fill = TestOrderEventStubs::filled(
4488 &order,
4489 &btc_usdt,
4490 Some(TradeId::new("1")),
4491 None,
4492 Some(Price::from("50000.0")),
4493 Some(Quantity::from("1.0")),
4494 None,
4495 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
4496 None,
4497 None,
4498 );
4499
4500 let mut position = Position::new(&btc_usdt, fill.into());
4501 assert_eq!(position.adjustments.len(), 1, "Should have 1 adjustment");
4502 assert_eq!(position.events.len(), 1);
4503
4504 position.purge_events_for_order(order.client_order_id());
4506
4507 assert_eq!(position.side, PositionSide::Flat);
4508 assert_eq!(position.events.len(), 0, "Events should be cleared");
4509 assert_eq!(
4510 position.adjustments.len(),
4511 0,
4512 "Adjustments should be cleared when position goes flat"
4513 );
4514 assert_eq!(position.quantity, Quantity::zero(btc_usdt.size_precision()));
4515 }
4516
4517 #[rstest]
4518 fn test_purge_events_for_order_clears_adjustments_on_rebuild() {
4519 let btc_usdt = currency_pair_btcusdt();
4521 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
4522
4523 let order1 = OrderTestBuilder::new(OrderType::Market)
4525 .instrument_id(btc_usdt.id())
4526 .side(OrderSide::Buy)
4527 .quantity(Quantity::from("1.0"))
4528 .client_order_id(ClientOrderId::new("O-001"))
4529 .build();
4530
4531 let fill1 = TestOrderEventStubs::filled(
4532 &order1,
4533 &btc_usdt,
4534 Some(TradeId::new("1")),
4535 None,
4536 Some(Price::from("50000.0")),
4537 Some(Quantity::from("1.0")),
4538 None,
4539 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
4540 None,
4541 None,
4542 );
4543
4544 let mut position = Position::new(&btc_usdt, fill1.into());
4545 assert_eq!(position.adjustments.len(), 1);
4546
4547 let order2 = OrderTestBuilder::new(OrderType::Market)
4549 .instrument_id(btc_usdt.id())
4550 .side(OrderSide::Buy)
4551 .quantity(Quantity::from("2.0"))
4552 .client_order_id(ClientOrderId::new("O-002"))
4553 .build();
4554
4555 let fill2 = TestOrderEventStubs::filled(
4556 &order2,
4557 &btc_usdt,
4558 Some(TradeId::new("2")),
4559 None,
4560 Some(Price::from("51000.0")),
4561 Some(Quantity::from("2.0")),
4562 None,
4563 Some(Money::new(0.002, btc_usdt.base_currency().unwrap())),
4564 None,
4565 None,
4566 );
4567
4568 position.apply(&fill2.into());
4569 assert_eq!(position.adjustments.len(), 2, "Should have 2 adjustments");
4570 assert_eq!(position.events.len(), 2);
4571
4572 position.purge_events_for_order(order1.client_order_id());
4574
4575 assert_eq!(position.events.len(), 1, "Should have 1 remaining event");
4576 assert_eq!(
4577 position.adjustments.len(),
4578 1,
4579 "Should have only the adjustment from remaining fill"
4580 );
4581 assert_eq!(
4582 position.adjustments[0].quantity_change,
4583 Some(rust_decimal_macros::dec!(-0.002)),
4584 "Should be the adjustment from order2"
4585 );
4586 assert!(
4587 (position.quantity.as_f64() - 1.998).abs() < 1e-9,
4588 "Quantity should be 2.0 - 0.002 commission"
4589 );
4590 }
4591
4592 #[rstest]
4593 fn test_purge_events_preserves_manual_adjustments() {
4594 let btc_usdt = currency_pair_btcusdt();
4596 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
4597
4598 let order1 = OrderTestBuilder::new(OrderType::Market)
4600 .instrument_id(btc_usdt.id())
4601 .side(OrderSide::Buy)
4602 .quantity(Quantity::from("1.0"))
4603 .client_order_id(ClientOrderId::new("O-001"))
4604 .build();
4605
4606 let fill1 = TestOrderEventStubs::filled(
4607 &order1,
4608 &btc_usdt,
4609 Some(TradeId::new("1")),
4610 None,
4611 Some(Price::from("50000.0")),
4612 Some(Quantity::from("1.0")),
4613 None,
4614 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
4615 None,
4616 None,
4617 );
4618
4619 let mut position = Position::new(&btc_usdt, fill1.into());
4620 assert_eq!(position.adjustments.len(), 1);
4621
4622 let funding_adjustment = PositionAdjusted::new(
4624 position.trader_id,
4625 position.strategy_id,
4626 position.instrument_id,
4627 position.id,
4628 position.account_id,
4629 PositionAdjustmentType::Funding,
4630 None,
4631 Some(Money::new(10.0, btc_usdt.quote_currency())),
4632 None, uuid4(),
4634 UnixNanos::default(),
4635 UnixNanos::default(),
4636 );
4637 position.apply_adjustment(funding_adjustment);
4638 assert_eq!(position.adjustments.len(), 2);
4639
4640 let order2 = OrderTestBuilder::new(OrderType::Market)
4642 .instrument_id(btc_usdt.id())
4643 .side(OrderSide::Buy)
4644 .quantity(Quantity::from("2.0"))
4645 .client_order_id(ClientOrderId::new("O-002"))
4646 .build();
4647
4648 let fill2 = TestOrderEventStubs::filled(
4649 &order2,
4650 &btc_usdt,
4651 Some(TradeId::new("2")),
4652 None,
4653 Some(Price::from("51000.0")),
4654 Some(Quantity::from("2.0")),
4655 None,
4656 Some(Money::new(0.002, btc_usdt.base_currency().unwrap())),
4657 None,
4658 None,
4659 );
4660
4661 position.apply(&fill2.into());
4662 assert_eq!(
4663 position.adjustments.len(),
4664 3,
4665 "Should have 3 adjustments: 2 commissions + 1 funding"
4666 );
4667
4668 position.purge_events_for_order(order1.client_order_id());
4670
4671 assert_eq!(position.events.len(), 1, "Should have 1 remaining event");
4672 assert_eq!(
4673 position.adjustments.len(),
4674 2,
4675 "Should have funding adjustment + commission from remaining fill"
4676 );
4677
4678 let has_funding = position.adjustments.iter().any(|adj| {
4680 adj.adjustment_type == PositionAdjustmentType::Funding
4681 && adj.pnl_change == Some(Money::new(10.0, btc_usdt.quote_currency()))
4682 });
4683 assert!(has_funding, "Funding adjustment should be preserved");
4684
4685 assert_eq!(
4688 position.realized_pnl,
4689 Some(Money::new(10.0, btc_usdt.quote_currency())),
4690 "Realized PnL should be the funding payment only (commission is in BTC, not USDT)"
4691 );
4692 }
4693
4694 #[rstest]
4695 fn test_position_commission_affects_buy_and_sell_qty() {
4696 let btc_usdt = currency_pair_btcusdt();
4698 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
4699
4700 let buy_order = OrderTestBuilder::new(OrderType::Market)
4701 .instrument_id(btc_usdt.id())
4702 .side(OrderSide::Buy)
4703 .quantity(Quantity::from("1.0"))
4704 .build();
4705
4706 let fill = TestOrderEventStubs::filled(
4708 &buy_order,
4709 &btc_usdt,
4710 Some(TradeId::new("1")),
4711 None,
4712 Some(Price::from("50000.0")),
4713 Some(Quantity::from("1.0")),
4714 None,
4715 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
4716 None,
4717 None,
4718 );
4719
4720 let position = Position::new(&btc_usdt, fill.into());
4721
4722 assert!(
4724 (position.buy_qty.as_f64() - 1.0).abs() < 1e-9,
4725 "buy_qty should be 1.0 (order fill amount), was {}",
4726 position.buy_qty.as_f64()
4727 );
4728
4729 assert!(
4731 (position.quantity.as_f64() - 0.999).abs() < 1e-9,
4732 "position.quantity should be 0.999 (1.0 - 0.001 commission), was {}",
4733 position.quantity.as_f64()
4734 );
4735
4736 assert_eq!(position.adjustments.len(), 1);
4738 assert_eq!(
4739 position.adjustments[0].quantity_change,
4740 Some(rust_decimal_macros::dec!(-0.001))
4741 );
4742 }
4743
4744 #[rstest]
4745 fn test_position_perpetual_commission_no_adjustment() {
4746 let eth_perp = crypto_perpetual_ethusdt();
4748 let eth_perp = InstrumentAny::CryptoPerpetual(eth_perp);
4749
4750 let order = OrderTestBuilder::new(OrderType::Market)
4751 .instrument_id(eth_perp.id())
4752 .side(OrderSide::Buy)
4753 .quantity(Quantity::from("1.0"))
4754 .build();
4755
4756 let fill = TestOrderEventStubs::filled(
4758 &order,
4759 ð_perp,
4760 Some(TradeId::new("1")),
4761 None,
4762 Some(Price::from("3000.0")),
4763 Some(Quantity::from("1.0")),
4764 None,
4765 Some(Money::new(0.001, eth_perp.base_currency().unwrap())),
4766 None,
4767 None,
4768 );
4769
4770 let position = Position::new(ð_perp, fill.into());
4771
4772 assert!(
4774 (position.quantity.as_f64() - 1.0).abs() < 1e-9,
4775 "Perpetual position should be 1.0 contracts (no adjustment), was {}",
4776 position.quantity.as_f64()
4777 );
4778
4779 assert!(
4781 (position.signed_qty - 1.0).abs() < 1e-9,
4782 "Signed qty should be 1.0, was {}",
4783 position.signed_qty
4784 );
4785 }
4786
4787 #[rstest]
4788 fn test_signed_decimal_qty_long(stub_position_long: Position) {
4789 let signed_qty = stub_position_long.signed_decimal_qty();
4790 assert!(signed_qty > Decimal::ZERO);
4791 assert_eq!(
4792 signed_qty,
4793 Decimal::try_from(stub_position_long.signed_qty).unwrap()
4794 );
4795 }
4796
4797 #[rstest]
4798 fn test_signed_decimal_qty_short(stub_position_short: Position) {
4799 let signed_qty = stub_position_short.signed_decimal_qty();
4800 assert!(signed_qty < Decimal::ZERO);
4801 assert_eq!(
4802 signed_qty,
4803 Decimal::try_from(stub_position_short.signed_qty).unwrap()
4804 );
4805 }
4806
4807 #[rstest]
4808 fn test_signed_decimal_qty_flat(audusd_sim: CurrencyPair) {
4809 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
4810 let order = OrderTestBuilder::new(OrderType::Market)
4811 .instrument_id(audusd_sim.id())
4812 .side(OrderSide::Buy)
4813 .quantity(Quantity::from(100_000))
4814 .build();
4815 let fill = TestOrderEventStubs::filled(
4816 &order,
4817 &audusd_sim,
4818 Some(TradeId::new("1")),
4819 None,
4820 Some(Price::from("1.00001")),
4821 None,
4822 None,
4823 None,
4824 None,
4825 None,
4826 );
4827 let mut position = Position::new(&audusd_sim, fill.into());
4828
4829 let close_order = OrderTestBuilder::new(OrderType::Market)
4830 .instrument_id(audusd_sim.id())
4831 .side(OrderSide::Sell)
4832 .quantity(Quantity::from(100_000))
4833 .build();
4834 let close_fill = TestOrderEventStubs::filled(
4835 &close_order,
4836 &audusd_sim,
4837 Some(TradeId::new("2")),
4838 None,
4839 Some(Price::from("1.00002")),
4840 None,
4841 None,
4842 None,
4843 None,
4844 None,
4845 );
4846 position.apply(&close_fill.into());
4847
4848 assert_eq!(position.side, PositionSide::Flat);
4849 assert_eq!(position.signed_decimal_qty(), Decimal::ZERO);
4850 }
4851
4852 #[rstest]
4853 fn test_position_flat_with_floating_point_precision_edge_case() {
4854 let btc_usdt = currency_pair_btcusdt();
4858 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
4859
4860 let order1 = OrderTestBuilder::new(OrderType::Market)
4861 .instrument_id(btc_usdt.id())
4862 .side(OrderSide::Buy)
4863 .quantity(Quantity::from("0.123456789"))
4864 .build();
4865 let fill1 = TestOrderEventStubs::filled(
4866 &order1,
4867 &btc_usdt,
4868 Some(TradeId::new("1")),
4869 None,
4870 Some(Price::from("50000.00")),
4871 None,
4872 None,
4873 None,
4874 None,
4875 None,
4876 );
4877 let mut position = Position::new(&btc_usdt, fill1.into());
4878
4879 assert_eq!(position.side, PositionSide::Long);
4880 assert!(position.quantity.is_positive());
4881
4882 let order2 = OrderTestBuilder::new(OrderType::Market)
4883 .instrument_id(btc_usdt.id())
4884 .side(OrderSide::Sell)
4885 .quantity(Quantity::from("0.123456789"))
4886 .build();
4887 let fill2 = TestOrderEventStubs::filled(
4888 &order2,
4889 &btc_usdt,
4890 Some(TradeId::new("2")),
4891 None,
4892 Some(Price::from("50000.00")),
4893 None,
4894 None,
4895 None,
4896 None,
4897 None,
4898 );
4899 position.apply(&fill2.into());
4900
4901 assert_eq!(
4902 position.side,
4903 PositionSide::Flat,
4904 "Position should be FLAT, not {:?}",
4905 position.side
4906 );
4907 assert!(
4908 position.quantity.is_zero(),
4909 "Quantity should be zero, was {}",
4910 position.quantity
4911 );
4912 assert_eq!(
4913 position.signed_qty, 0.0,
4914 "signed_qty should be normalized to 0.0, was {}",
4915 position.signed_qty
4916 );
4917 assert!(position.is_closed());
4918 }
4919
4920 #[rstest]
4921 #[case(OrderSide::Buy, OrderSide::Sell, "162.50", "176.50", 171.5)]
4922 #[case(OrderSide::Sell, OrderSide::Buy, "140.00", "126.00", 131.0)]
4923 fn test_position_exact_close_after_partial_fills_preserves_open_average(
4924 #[case] entry: OrderSide,
4925 #[case] exit: OrderSide,
4926 #[case] first_close_px: &str,
4927 #[case] final_close_px: &str,
4928 #[case] expected_avg_close: f64,
4929 ) {
4930 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
4931 let position_id = PositionId::new("P-PARTIAL-CLOSE");
4932 let open_order = OrderTestBuilder::new(OrderType::Market)
4933 .instrument_id(instrument.id())
4934 .client_order_id(ClientOrderId::new("O-OPEN"))
4935 .side(entry)
4936 .quantity(Quantity::from("0.7"))
4937 .build();
4938 let open_fill = TestOrderEventStubs::filled(
4939 &open_order,
4940 &instrument,
4941 Some(TradeId::new("T-OPEN")),
4942 Some(position_id),
4943 Some(Price::from("151.25")),
4944 None,
4945 None,
4946 Some(Money::from("0 USDT")),
4947 Some(UnixNanos::from(1_000)),
4948 None,
4949 );
4950 let mut position = Position::new(&instrument, open_fill.into());
4951
4952 for (client_order_id, trade_id, quantity, price, ts_event) in [
4953 ("O-CLOSE-1", "T-CLOSE-1", "0.25", first_close_px, 1_100),
4954 ("O-CLOSE-2", "T-CLOSE-2", "0.45", final_close_px, 1_250),
4955 ] {
4956 let close_order = OrderTestBuilder::new(OrderType::Market)
4957 .instrument_id(instrument.id())
4958 .client_order_id(ClientOrderId::new(client_order_id))
4959 .side(exit)
4960 .quantity(Quantity::from(quantity))
4961 .build();
4962 let close_fill = TestOrderEventStubs::filled(
4963 &close_order,
4964 &instrument,
4965 Some(TradeId::new(trade_id)),
4966 Some(position_id),
4967 Some(Price::from(price)),
4968 None,
4969 None,
4970 Some(Money::from("0 USDT")),
4971 Some(UnixNanos::from(ts_event)),
4972 None,
4973 );
4974 position.apply(&close_fill.into());
4975 }
4976
4977 assert_eq!(position.entry, entry);
4978 assert_eq!(position.side, PositionSide::Flat);
4979 assert_eq!(position.signed_qty, 0.0);
4980 assert_eq!(position.quantity, Quantity::zero(6));
4981 assert_eq!(position.peak_qty, Quantity::from("0.7"));
4982 assert_eq!(position.buy_qty, Quantity::from("0.7"));
4983 assert_eq!(position.sell_qty, Quantity::from("0.7"));
4984 assert_eq!(position.avg_px_open, 151.25);
4985 assert_eq!(position.avg_px_close, Some(expected_avg_close));
4986 assert_eq!(position.realized_return, 0.133_884_297_520_661_17);
4987 assert_eq!(position.realized_pnl, Some(Money::from("14.17500000 USDT")));
4988 assert_eq!(position.commissions(), vec![Money::from("0 USDT")]);
4989 assert_eq!(position.opening_order_id, ClientOrderId::new("O-OPEN"));
4990 assert_eq!(
4991 position.closing_order_id,
4992 Some(ClientOrderId::new("O-CLOSE-2"))
4993 );
4994 assert_eq!(position.ts_opened, UnixNanos::from(1_000));
4995 assert_eq!(position.ts_last, UnixNanos::from(1_250));
4996 assert_eq!(position.ts_closed, Some(UnixNanos::from(1_250)));
4997 assert_eq!(position.duration_ns, 250);
4998 assert_eq!(position.event_count(), 3);
4999 assert!(position.is_closed());
5000 }
5001
5002 #[rstest]
5003 #[case(
5004 OrderSide::Buy,
5005 OrderSide::Sell,
5006 "140.00",
5007 "126.00",
5008 PositionSide::Short,
5009 -0.000_001
5010 )]
5011 #[case(
5012 OrderSide::Sell,
5013 OrderSide::Buy,
5014 "162.50",
5015 "176.50",
5016 PositionSide::Long,
5017 0.000_001
5018 )]
5019 fn test_position_true_reversal_uses_fill_price(
5020 #[case] entry: OrderSide,
5021 #[case] exit: OrderSide,
5022 #[case] first_close_px: &str,
5023 #[case] reversal_px: &str,
5024 #[case] expected_side: PositionSide,
5025 #[case] expected_signed_qty: f64,
5026 ) {
5027 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
5028 let position_id = PositionId::new("P-REVERSAL");
5029 let open_order = OrderTestBuilder::new(OrderType::Market)
5030 .instrument_id(instrument.id())
5031 .client_order_id(ClientOrderId::new("O-REVERSAL-OPEN"))
5032 .side(entry)
5033 .quantity(Quantity::from("0.7"))
5034 .build();
5035 let open_fill = TestOrderEventStubs::filled(
5036 &open_order,
5037 &instrument,
5038 Some(TradeId::new("T-REVERSAL-OPEN")),
5039 Some(position_id),
5040 Some(Price::from("151.25")),
5041 None,
5042 None,
5043 Some(Money::from("0 USDT")),
5044 Some(UnixNanos::from(2_000)),
5045 None,
5046 );
5047 let mut position = Position::new(&instrument, open_fill.into());
5048
5049 let close_order = OrderTestBuilder::new(OrderType::Market)
5050 .instrument_id(instrument.id())
5051 .client_order_id(ClientOrderId::new("O-REVERSAL-CLOSE"))
5052 .side(exit)
5053 .quantity(Quantity::from("0.25"))
5054 .build();
5055 let close_fill = TestOrderEventStubs::filled(
5056 &close_order,
5057 &instrument,
5058 Some(TradeId::new("T-REVERSAL-CLOSE")),
5059 Some(position_id),
5060 Some(Price::from(first_close_px)),
5061 None,
5062 None,
5063 Some(Money::from("0 USDT")),
5064 Some(UnixNanos::from(2_050)),
5065 None,
5066 );
5067 position.apply(&close_fill.into());
5068
5069 let reversal_order = OrderTestBuilder::new(OrderType::Market)
5070 .instrument_id(instrument.id())
5071 .client_order_id(ClientOrderId::new("O-REVERSAL"))
5072 .side(exit)
5073 .quantity(Quantity::from("0.450001"))
5074 .build();
5075 let reversal_fill = TestOrderEventStubs::filled(
5076 &reversal_order,
5077 &instrument,
5078 Some(TradeId::new("T-REVERSAL")),
5079 Some(position_id),
5080 Some(Price::from(reversal_px)),
5081 None,
5082 None,
5083 Some(Money::from("0 USDT")),
5084 Some(UnixNanos::from(2_100)),
5085 None,
5086 );
5087 position.apply(&reversal_fill.into());
5088
5089 assert_eq!(position.entry, exit);
5090 assert_eq!(position.side, expected_side);
5091 assert!((position.signed_qty - expected_signed_qty).abs() < 1e-12);
5092 assert_eq!(position.quantity, Quantity::from("0.000001"));
5093 assert_eq!(position.peak_qty, Quantity::from("0.7"));
5094 assert_eq!(position.avg_px_open, Price::from(reversal_px).as_f64());
5095 assert_eq!(
5096 position.realized_pnl,
5097 Some(Money::from("-14.17500000 USDT"))
5098 );
5099 assert_eq!(position.commissions(), vec![Money::from("0 USDT")]);
5100 assert_eq!(
5101 position.opening_order_id,
5102 ClientOrderId::new("O-REVERSAL-OPEN")
5103 );
5104 assert_eq!(position.closing_order_id, None);
5105 assert_eq!(position.ts_opened, UnixNanos::from(2_000));
5106 assert_eq!(position.ts_last, UnixNanos::from(2_100));
5107 assert_eq!(position.ts_closed, None);
5108 assert_eq!(position.duration_ns, 0);
5109 assert_eq!(position.event_count(), 3);
5110 assert!(position.is_open());
5111 }
5112
5113 #[rstest]
5114 fn test_position_adjustment_floating_point_precision_edge_case() {
5115 let btc_usdt = currency_pair_btcusdt();
5117 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
5118
5119 let order = OrderTestBuilder::new(OrderType::Market)
5120 .instrument_id(btc_usdt.id())
5121 .side(OrderSide::Buy)
5122 .quantity(Quantity::from("1.0"))
5123 .build();
5124 let fill = TestOrderEventStubs::filled(
5125 &order,
5126 &btc_usdt,
5127 Some(TradeId::new("1")),
5128 None,
5129 Some(Price::from("50000.00")),
5130 None,
5131 None,
5132 None,
5133 None,
5134 None,
5135 );
5136 let mut position = Position::new(&btc_usdt, fill.into());
5137
5138 let adjustment = PositionAdjusted::new(
5139 position.trader_id,
5140 position.strategy_id,
5141 position.instrument_id,
5142 position.id,
5143 position.account_id,
5144 PositionAdjustmentType::Commission,
5145 Some(Decimal::from_str("-1.0").unwrap()),
5146 None,
5147 None,
5148 uuid4(),
5149 UnixNanos::default(),
5150 UnixNanos::default(),
5151 );
5152 position.apply_adjustment(adjustment);
5153
5154 assert_eq!(
5155 position.side,
5156 PositionSide::Flat,
5157 "Position should be FLAT after zeroing adjustment"
5158 );
5159 assert!(
5160 position.quantity.is_zero(),
5161 "Quantity should be zero after adjustment"
5162 );
5163 assert_eq!(
5164 position.signed_qty, 0.0,
5165 "signed_qty should be normalized to 0.0"
5166 );
5167 }
5168
5169 #[rstest]
5170 fn test_position_spot_buy_partial_fills_with_base_commission() {
5171 let eth_usdt = currency_pair_ethusdt();
5174 let eth_usdt = InstrumentAny::CurrencyPair(eth_usdt);
5175
5176 let order1 = OrderTestBuilder::new(OrderType::Market)
5177 .instrument_id(eth_usdt.id())
5178 .side(OrderSide::Buy)
5179 .quantity(Quantity::from("0.00350"))
5180 .build();
5181
5182 let fill1 = TestOrderEventStubs::filled(
5183 &order1,
5184 ð_usdt,
5185 Some(TradeId::new("1")),
5186 None,
5187 Some(Price::from("2042.69")),
5188 Some(Quantity::from("0.00350")),
5189 None,
5190 Some(Money::new(0.00001, eth_usdt.base_currency().unwrap())),
5191 None,
5192 None,
5193 );
5194
5195 let mut position = Position::new(ð_usdt, fill1.into());
5196
5197 assert_eq!(position.quantity, Quantity::from("0.00349"));
5198 assert!((position.signed_qty - 0.00349).abs() < 1e-9);
5199 assert_eq!(position.side, PositionSide::Long);
5200 assert_eq!(position.adjustments.len(), 1);
5201 assert_eq!(
5202 position.adjustments[0].quantity_change,
5203 Some(rust_decimal_macros::dec!(-0.00001))
5204 );
5205
5206 let order2 = OrderTestBuilder::new(OrderType::Market)
5207 .instrument_id(eth_usdt.id())
5208 .side(OrderSide::Buy)
5209 .quantity(Quantity::from("0.00350"))
5210 .build();
5211
5212 let fill2 = TestOrderEventStubs::filled(
5213 &order2,
5214 ð_usdt,
5215 Some(TradeId::new("2")),
5216 None,
5217 Some(Price::from("2042.69")),
5218 Some(Quantity::from("0.00350")),
5219 None,
5220 Some(Money::new(0.00001, eth_usdt.base_currency().unwrap())),
5221 None,
5222 None,
5223 );
5224
5225 position.apply(&fill2.into());
5226
5227 assert_eq!(position.quantity, Quantity::from("0.00698"));
5228 assert!((position.signed_qty - 0.00698).abs() < 1e-9);
5229 assert_eq!(position.adjustments.len(), 2);
5230
5231 let order3 = OrderTestBuilder::new(OrderType::Market)
5232 .instrument_id(eth_usdt.id())
5233 .side(OrderSide::Buy)
5234 .quantity(Quantity::from("0.00300"))
5235 .build();
5236
5237 let fill3 = TestOrderEventStubs::filled(
5238 &order3,
5239 ð_usdt,
5240 Some(TradeId::new("3")),
5241 None,
5242 Some(Price::from("2042.69")),
5243 Some(Quantity::from("0.00300")),
5244 None,
5245 Some(Money::new(0.00001, eth_usdt.base_currency().unwrap())),
5246 None,
5247 None,
5248 );
5249
5250 position.apply(&fill3.into());
5251
5252 assert_eq!(position.quantity, Quantity::from("0.00997"));
5255 assert!((position.signed_qty - 0.00997).abs() < 1e-9);
5256 assert_eq!(position.side, PositionSide::Long);
5257 assert_eq!(position.adjustments.len(), 3);
5258
5259 assert_eq!(position.buy_qty, Quantity::from("0.01000"));
5261 }
5262
5263 #[rstest]
5264 fn test_position_spot_sell_partial_fills_with_base_commission() {
5265 let btc_usdt = currency_pair_btcusdt();
5266 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
5267
5268 let order1 = OrderTestBuilder::new(OrderType::Market)
5269 .instrument_id(btc_usdt.id())
5270 .side(OrderSide::Sell)
5271 .quantity(Quantity::from("0.5"))
5272 .build();
5273
5274 let fill1 = TestOrderEventStubs::filled(
5275 &order1,
5276 &btc_usdt,
5277 Some(TradeId::new("1")),
5278 None,
5279 Some(Price::from("50000.0")),
5280 Some(Quantity::from("0.5")),
5281 None,
5282 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
5283 None,
5284 None,
5285 );
5286
5287 let mut position = Position::new(&btc_usdt, fill1.into());
5288
5289 assert!((position.signed_qty - (-0.501)).abs() < 1e-9);
5291 assert_eq!(position.side, PositionSide::Short);
5292 assert_eq!(position.adjustments.len(), 1);
5293
5294 let order2 = OrderTestBuilder::new(OrderType::Market)
5295 .instrument_id(btc_usdt.id())
5296 .side(OrderSide::Sell)
5297 .quantity(Quantity::from("0.5"))
5298 .build();
5299
5300 let fill2 = TestOrderEventStubs::filled(
5301 &order2,
5302 &btc_usdt,
5303 Some(TradeId::new("2")),
5304 None,
5305 Some(Price::from("50000.0")),
5306 Some(Quantity::from("0.5")),
5307 None,
5308 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
5309 None,
5310 None,
5311 );
5312
5313 position.apply(&fill2.into());
5314
5315 assert!((position.signed_qty - (-1.002)).abs() < 1e-9);
5317 assert!((position.quantity.as_f64() - 1.002).abs() < 1e-9);
5318 assert_eq!(position.adjustments.len(), 2);
5319 assert_eq!(position.sell_qty, Quantity::from("1.0"));
5320 }
5321
5322 #[rstest]
5323 fn test_position_spot_round_trip_close_flat_with_quote_commission() {
5324 let eth_usdt = currency_pair_ethusdt();
5325 let eth_usdt = InstrumentAny::CurrencyPair(eth_usdt);
5326
5327 let buy_order = OrderTestBuilder::new(OrderType::Market)
5328 .instrument_id(eth_usdt.id())
5329 .side(OrderSide::Buy)
5330 .quantity(Quantity::from("1.00000"))
5331 .build();
5332
5333 let buy_fill = TestOrderEventStubs::filled(
5334 &buy_order,
5335 ð_usdt,
5336 Some(TradeId::new("1")),
5337 None,
5338 Some(Price::from("2000.00")),
5339 Some(Quantity::from("1.00000")),
5340 None,
5341 Some(Money::new(0.001, eth_usdt.base_currency().unwrap())),
5342 None,
5343 None,
5344 );
5345
5346 let mut position = Position::new(ð_usdt, buy_fill.into());
5347
5348 assert_eq!(position.quantity, Quantity::from("0.99900"));
5350 assert_eq!(position.side, PositionSide::Long);
5351
5352 let sell_order = OrderTestBuilder::new(OrderType::Market)
5353 .instrument_id(eth_usdt.id())
5354 .side(OrderSide::Sell)
5355 .quantity(Quantity::from("0.99900"))
5356 .build();
5357
5358 let sell_fill = TestOrderEventStubs::filled(
5359 &sell_order,
5360 ð_usdt,
5361 Some(TradeId::new("2")),
5362 None,
5363 Some(Price::from("2100.00")),
5364 Some(Quantity::from("0.99900")),
5365 None,
5366 Some(Money::new(2.0, Currency::USDT())),
5367 None,
5368 None,
5369 );
5370
5371 position.apply(&sell_fill.into());
5372
5373 assert_eq!(position.side, PositionSide::Flat);
5374 assert_eq!(position.signed_qty, 0.0);
5375 assert!(position.is_closed());
5376 assert_eq!(position.adjustments.len(), 1);
5378
5379 let realized = position.realized_pnl.unwrap().as_f64();
5381 assert!(
5382 (realized - 97.9).abs() < 0.01,
5383 "Realized PnL should be ~97.90 USDT, was {realized}"
5384 );
5385 }
5386
5387 #[rstest]
5388 fn test_position_spot_commission_accumulation_multiple_partial_fills() {
5389 let eth_usdt = currency_pair_ethusdt();
5390 let eth_usdt = InstrumentAny::CurrencyPair(eth_usdt);
5391
5392 let order1 = OrderTestBuilder::new(OrderType::Market)
5393 .instrument_id(eth_usdt.id())
5394 .side(OrderSide::Buy)
5395 .quantity(Quantity::from("0.50000"))
5396 .build();
5397
5398 let fill1 = TestOrderEventStubs::filled(
5399 &order1,
5400 ð_usdt,
5401 Some(TradeId::new("1")),
5402 None,
5403 Some(Price::from("2000.00")),
5404 Some(Quantity::from("0.50000")),
5405 None,
5406 Some(Money::new(0.0005, eth_usdt.base_currency().unwrap())),
5407 None,
5408 None,
5409 );
5410
5411 let mut position = Position::new(ð_usdt, fill1.into());
5412
5413 let order2 = OrderTestBuilder::new(OrderType::Market)
5414 .instrument_id(eth_usdt.id())
5415 .side(OrderSide::Buy)
5416 .quantity(Quantity::from("0.50000"))
5417 .build();
5418
5419 let fill2 = TestOrderEventStubs::filled(
5420 &order2,
5421 ð_usdt,
5422 Some(TradeId::new("2")),
5423 None,
5424 Some(Price::from("2010.00")),
5425 Some(Quantity::from("0.50000")),
5426 None,
5427 Some(Money::new(0.0005, eth_usdt.base_currency().unwrap())),
5428 None,
5429 None,
5430 );
5431
5432 position.apply(&fill2.into());
5433
5434 assert_eq!(position.quantity, Quantity::from("0.99900"));
5436 assert_eq!(position.buy_qty, Quantity::from("1.00000"));
5437
5438 assert_eq!(position.adjustments.len(), 2);
5439 for adj in &position.adjustments {
5440 assert_eq!(adj.adjustment_type, PositionAdjustmentType::Commission);
5441 assert_eq!(
5442 adj.quantity_change,
5443 Some(rust_decimal_macros::dec!(-0.0005))
5444 );
5445 }
5446
5447 let commissions = position.commissions();
5448 assert_eq!(commissions.len(), 1);
5449 let eth_commission = commissions[0];
5450 assert!(
5451 (eth_commission.as_f64() - 0.001).abs() < 1e-9,
5452 "Total ETH commission should be 0.001, was {}",
5453 eth_commission.as_f64()
5454 );
5455 }
5456
5457 #[rstest]
5458 fn test_position_apply_fill_with_earlier_timestamp_adjusts_ts_opened(audusd_sim: CurrencyPair) {
5459 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
5460 let order1 = OrderTestBuilder::new(OrderType::Market)
5461 .instrument_id(audusd_sim.id())
5462 .side(OrderSide::Buy)
5463 .quantity(Quantity::from(100_000))
5464 .build();
5465 let order2 = OrderTestBuilder::new(OrderType::Market)
5466 .instrument_id(audusd_sim.id())
5467 .side(OrderSide::Buy)
5468 .quantity(Quantity::from(100_000))
5469 .build();
5470
5471 let fill1 = TestOrderEventStubs::filled(
5473 &order1,
5474 &audusd_sim,
5475 Some(TradeId::new("t1")),
5476 None,
5477 Some(Price::from("1.00001")),
5478 None,
5479 None,
5480 None,
5481 Some(UnixNanos::from(2_000u64)),
5482 None,
5483 );
5484 let mut position = Position::new(&audusd_sim, fill1.into());
5485 assert_eq!(position.ts_opened, UnixNanos::from(2_000u64));
5486
5487 let fill2 = TestOrderEventStubs::filled(
5489 &order2,
5490 &audusd_sim,
5491 Some(TradeId::new("t2")),
5492 None,
5493 Some(Price::from("1.00002")),
5494 None,
5495 None,
5496 None,
5497 Some(UnixNanos::from(1_000u64)),
5498 None,
5499 );
5500
5501 position.apply(&fill2.into());
5503 assert_eq!(position.ts_opened, UnixNanos::from(2_000u64));
5504 assert_eq!(position.opening_order_id, order1.client_order_id());
5505 assert_eq!(position.events.len(), 2);
5506 }
5507
5508 #[rstest]
5509 fn test_position_close_before_open_clamps_duration(audusd_sim: CurrencyPair) {
5510 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
5511 let opening_order = OrderTestBuilder::new(OrderType::Market)
5512 .instrument_id(audusd_sim.id())
5513 .side(OrderSide::Buy)
5514 .quantity(Quantity::from(100_000))
5515 .build();
5516 let closing_order = OrderTestBuilder::new(OrderType::Market)
5517 .instrument_id(audusd_sim.id())
5518 .side(OrderSide::Sell)
5519 .quantity(Quantity::from(100_000))
5520 .build();
5521 let opening_fill = TestOrderEventStubs::filled(
5522 &opening_order,
5523 &audusd_sim,
5524 Some(TradeId::new("OPEN")),
5525 None,
5526 Some(Price::from("1.00001")),
5527 None,
5528 None,
5529 None,
5530 Some(UnixNanos::from(2_000u64)),
5531 None,
5532 );
5533 let closing_fill = TestOrderEventStubs::filled(
5534 &closing_order,
5535 &audusd_sim,
5536 Some(TradeId::new("CLOSE")),
5537 None,
5538 Some(Price::from("1.00002")),
5539 None,
5540 None,
5541 None,
5542 Some(UnixNanos::from(1_000u64)),
5543 None,
5544 );
5545 let mut position = Position::new(&audusd_sim, opening_fill.into());
5546
5547 position.apply(&closing_fill.into());
5548
5549 assert_eq!(position.side, PositionSide::Flat);
5550 assert_eq!(position.ts_opened, UnixNanos::from(2_000u64));
5551 assert_eq!(position.ts_closed, Some(UnixNanos::from(1_000u64)));
5552 assert_eq!(position.duration_ns, 0);
5553 assert_eq!(
5554 position.closing_order_id,
5555 Some(closing_order.client_order_id())
5556 );
5557 }
5558
5559 #[rstest]
5560 fn test_position_commissions_multi_currency_insertion_order(audusd_sim: CurrencyPair) {
5561 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
5566 let order_template = OrderTestBuilder::new(OrderType::Market)
5567 .instrument_id(audusd_sim.id())
5568 .side(OrderSide::Buy)
5569 .quantity(Quantity::from(100_000))
5570 .build();
5571
5572 let fill_usd = TestOrderEventStubs::filled(
5573 &order_template,
5574 &audusd_sim,
5575 Some(TradeId::new("t1")),
5576 None,
5577 Some(Price::from("1.00001")),
5578 None,
5579 None,
5580 Some(Money::from("1.0 USD")),
5581 None,
5582 None,
5583 );
5584 let mut position = Position::new(&audusd_sim, fill_usd.into());
5585
5586 let fill_usdt = TestOrderEventStubs::filled(
5587 &order_template,
5588 &audusd_sim,
5589 Some(TradeId::new("t2")),
5590 None,
5591 Some(Price::from("1.00001")),
5592 None,
5593 None,
5594 Some(Money::from("2.0 USDT")),
5595 None,
5596 None,
5597 );
5598 position.apply(&fill_usdt.into());
5599
5600 let fill_usd_again = TestOrderEventStubs::filled(
5601 &order_template,
5602 &audusd_sim,
5603 Some(TradeId::new("t3")),
5604 None,
5605 Some(Price::from("1.00001")),
5606 None,
5607 None,
5608 Some(Money::from("0.5 USD")),
5609 None,
5610 None,
5611 );
5612 position.apply(&fill_usd_again.into());
5613
5614 let fill_btc = TestOrderEventStubs::filled(
5615 &order_template,
5616 &audusd_sim,
5617 Some(TradeId::new("t4")),
5618 None,
5619 Some(Price::from("1.00001")),
5620 None,
5621 None,
5622 Some(Money::from("0.0001 BTC")),
5623 None,
5624 None,
5625 );
5626 position.apply(&fill_btc.into());
5627
5628 assert_eq!(
5631 position.commissions(),
5632 vec![
5633 Money::from("1.5 USD"),
5634 Money::from("2.0 USDT"),
5635 Money::from("0.0001 BTC"),
5636 ]
5637 );
5638 }
5639
5640 #[rstest]
5641 fn test_fold_net_position_empty() {
5642 let (net_qty, net_px) = fold_net_position(&[]);
5643 assert_eq!(net_qty, Decimal::ZERO);
5644 assert_eq!(net_px, Decimal::ZERO);
5645 }
5646
5647 #[rstest]
5648 fn test_fold_net_position_single_long() {
5649 let legs = [(dec!(100), dec!(1.5), 1u64)];
5650 let (net_qty, net_px) = fold_net_position(&legs);
5651 assert_eq!(net_qty, dec!(100));
5652 assert_eq!(net_px, dec!(1.5));
5653 }
5654
5655 #[rstest]
5656 fn test_fold_net_position_single_short() {
5657 let legs = [(dec!(-100), dec!(1.5), 1u64)];
5658 let (net_qty, net_px) = fold_net_position(&legs);
5659 assert_eq!(net_qty, dec!(-100));
5660 assert_eq!(net_px, dec!(1.5));
5661 }
5662
5663 #[rstest]
5664 fn test_fold_net_position_same_side_weighted_average() {
5665 let legs = [(dec!(100), dec!(1.0), 1u64), (dec!(200), dec!(0.5), 2u64)];
5667 let (net_qty, net_px) = fold_net_position(&legs);
5668 assert_eq!(net_qty, dec!(300));
5669 assert_eq!(net_px, dec!(200) / dec!(300));
5671 }
5672
5673 #[rstest]
5674 fn test_fold_net_position_partial_close_preserves_avg() {
5675 let legs = [
5677 (dec!(300), dec!(0.80), 1u64),
5678 (dec!(-100), dec!(1.00), 2u64),
5679 ];
5680 let (net_qty, net_px) = fold_net_position(&legs);
5681 assert_eq!(net_qty, dec!(200));
5682 assert_eq!(net_px, dec!(0.80));
5683 }
5684
5685 #[rstest]
5686 fn test_fold_net_position_full_close() {
5687 let legs = [(dec!(100), dec!(1.0), 1u64), (dec!(-100), dec!(2.0), 2u64)];
5688 let (net_qty, net_px) = fold_net_position(&legs);
5689 assert_eq!(net_qty, Decimal::ZERO);
5690 assert_eq!(net_px, Decimal::ZERO);
5691 }
5692
5693 #[rstest]
5694 fn test_fold_net_position_single_flip_uses_flipping_price() {
5695 let legs = [
5697 (dec!(100), dec!(1.00), 1u64),
5698 (dec!(-50), dec!(2.00), 2u64),
5699 (dec!(-100), dec!(3.00), 3u64),
5700 ];
5701 let (net_qty, net_px) = fold_net_position(&legs);
5702 assert_eq!(net_qty, dec!(-50));
5703 assert_eq!(net_px, dec!(3.00));
5704 }
5705
5706 #[rstest]
5707 fn test_fold_net_position_double_flip() {
5708 let legs = [
5710 (dec!(50), dec!(1.00), 1u64),
5711 (dec!(-100), dec!(2.00), 2u64),
5712 (dec!(100), dec!(3.00), 3u64),
5713 ];
5714 let (net_qty, net_px) = fold_net_position(&legs);
5715 assert_eq!(net_qty, dec!(50));
5716 assert_eq!(net_px, dec!(3.00));
5717 }
5718
5719 #[rstest]
5720 fn test_fold_net_position_zero_quantity_legs_skipped() {
5721 let legs = [
5723 (dec!(100), dec!(1.0), 1u64),
5724 (Decimal::ZERO, dec!(99.0), 2u64),
5725 (dec!(50), dec!(2.0), 3u64),
5726 ];
5727 let (net_qty, net_px) = fold_net_position(&legs);
5728 assert_eq!(net_qty, dec!(150));
5729 assert_eq!(net_px, dec!(200) / dec!(150));
5731 }
5732
5733 #[rstest]
5734 fn test_fold_net_position_stable_sort_preserves_input_order_for_equal_ts() {
5735 let leg_a = (dec!(100), dec!(1.00), 1u64);
5737 let leg_b = (dec!(-100), dec!(2.00), 1u64);
5738
5739 let ab = [leg_a, leg_b];
5740 let ba = [leg_b, leg_a];
5741
5742 assert_eq!(fold_net_position(&ab), (Decimal::ZERO, Decimal::ZERO));
5744 assert_eq!(fold_net_position(&ba), (Decimal::ZERO, Decimal::ZERO));
5746
5747 let leg_c = (dec!(150), dec!(1.00), 1u64);
5749 let leg_d = (dec!(-100), dec!(2.00), 1u64);
5750 let cd = [leg_c, leg_d];
5751 let dc = [leg_d, leg_c];
5752 assert_eq!(fold_net_position(&cd), (dec!(50), dec!(1.00)));
5754 assert_eq!(fold_net_position(&dc), (dec!(50), dec!(1.00)));
5756 }
5757
5758 #[rstest]
5759 fn test_fold_net_position_close_then_reopen() {
5760 let legs = [
5762 (dec!(100), dec!(1.00), 1u64),
5763 (dec!(-100), dec!(1.50), 2u64),
5764 (dec!(50), dec!(3.00), 3u64),
5765 ];
5766 let (net_qty, net_px) = fold_net_position(&legs);
5767 assert_eq!(net_qty, dec!(50));
5768 assert_eq!(net_px, dec!(3.00));
5769 }
5770
5771 #[rstest]
5772 fn test_fold_net_position_orders_by_ts_opened() {
5773 let in_order = [
5775 (dec!(100), dec!(1.00), 1u64),
5776 (dec!(-50), dec!(2.00), 2u64),
5777 (dec!(-100), dec!(3.00), 3u64),
5778 ];
5779 let shuffled = [
5780 (dec!(-100), dec!(3.00), 3u64),
5781 (dec!(100), dec!(1.00), 1u64),
5782 (dec!(-50), dec!(2.00), 2u64),
5783 ];
5784 assert_eq!(fold_net_position(&in_order), fold_net_position(&shuffled));
5785 }
5786
5787 fn netting_reference(
5790 instrument: &InstrumentAny,
5791 fills: &[(OrderSide, u32, u32, u64)],
5792 ) -> (Decimal, Decimal) {
5793 let mut sorted_fills = fills.to_vec();
5794 sorted_fills.sort_by_key(|(_, _, _, ts)| *ts);
5795
5796 let mut position: Option<Position> = None;
5797
5798 for (idx, &(side, qty, px, ts)) in sorted_fills.iter().enumerate() {
5799 let order = OrderTestBuilder::new(OrderType::Market)
5800 .instrument_id(instrument.id())
5801 .side(side)
5802 .quantity(Quantity::from(qty))
5803 .build();
5804 let fill = TestOrderEventStubs::filled(
5805 &order,
5806 instrument,
5807 Some(TradeId::new(format!("T{idx}").as_str())),
5808 Some(PositionId::new("P-NET")),
5809 Some(Price::from(px.to_string().as_str())),
5810 None,
5811 None,
5812 Some(Money::new(0.0, instrument.quote_currency())),
5813 Some(UnixNanos::from(ts)),
5814 None,
5815 );
5816 let event: OrderFilled = fill.into();
5817 if let Some(p) = position.as_mut() {
5818 p.apply(&event);
5819 } else {
5820 position = Some(Position::new(instrument, event));
5821 }
5822 }
5823 let p = position.expect("at least one fill");
5824 let signed = Decimal::try_from(p.signed_qty).unwrap_or(Decimal::ZERO);
5825 let px = Decimal::try_from(p.avg_px_open).unwrap_or(Decimal::ZERO);
5826 (signed, px)
5827 }
5828
5829 fn hedging_legs(fills: &[(OrderSide, u32, u32, u64)]) -> Vec<(Decimal, Decimal, u64)> {
5831 fills
5832 .iter()
5833 .map(|&(side, qty, px, ts)| {
5834 let signed = if side == OrderSide::Buy {
5835 Decimal::from(qty)
5836 } else {
5837 -Decimal::from(qty)
5838 };
5839 (signed, Decimal::from(px), ts)
5840 })
5841 .collect()
5842 }
5843
5844 proptest! {
5845 #[rstest]
5851 fn prop_fold_matches_netting_replay(
5852 fills in proptest::collection::vec(
5853 (
5854 prop_oneof![Just(OrderSide::Buy), Just(OrderSide::Sell)],
5855 1u32..1_000u32,
5856 1u32..100u32,
5857 0u64..1_000_000u64,
5858 ),
5859 1..6,
5860 )
5861 ) {
5862 let mut seen_ts: AHashSet<u64> = AHashSet::new();
5865 for &(_, _, _, ts) in &fills {
5866 if !seen_ts.insert(ts) {
5867 prop_assume!(false);
5868 }
5869 }
5870
5871 let mut sorted_fills = fills.clone();
5875 sorted_fills.sort_by_key(|(_, _, _, ts)| *ts);
5876 let mut running: i64 = 0;
5877 let mut zero_mid = false;
5878
5879 for (idx, &(side, qty, _, _)) in sorted_fills.iter().enumerate() {
5880 let qty_i64 = i64::from(qty);
5881 let signed: i64 = if side == OrderSide::Buy {
5882 qty_i64
5883 } else {
5884 -qty_i64
5885 };
5886 running += signed;
5887 if idx + 1 < sorted_fills.len() && running == 0 {
5888 zero_mid = true;
5889 break;
5890 }
5891 }
5892 prop_assume!(!zero_mid);
5893
5894 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
5895 let (ref_qty, ref_px) = netting_reference(&instrument, &fills);
5896 let legs = hedging_legs(&fills);
5897 let (fold_qty, fold_px) = fold_net_position(&legs);
5898
5899 prop_assert_eq!(fold_qty, ref_qty);
5900
5901 if !ref_qty.is_zero() {
5905 let fold_px_f64 = fold_px.to_f64().unwrap_or(0.0);
5906 let ref_px_f64 = ref_px.to_f64().unwrap_or(0.0);
5907 let max_mag = fold_px_f64.abs().max(ref_px_f64.abs()).max(1.0);
5908 prop_assert!(
5909 (fold_px_f64 - ref_px_f64).abs() < 1e-9 * max_mag,
5910 "fold_px {fold_px_f64} vs ref_px {ref_px_f64}",
5911 );
5912 }
5913 }
5914 }
5915}