1use std::{
39 fmt::Display,
40 hash::{Hash, Hasher},
41};
42
43use ahash::{AHashMap, AHashSet};
44use indexmap::IndexMap;
45use nautilus_core::{
46 DurationNanos, UUID4, UnixNanos,
47 correctness::{
48 CorrectnessError, CorrectnessResult, CorrectnessResultExt, FAILED, check_equal,
49 check_predicate_true,
50 },
51};
52use ruint::aliases::{U256, U512};
53use rust_decimal::Decimal;
54use serde::{Deserialize, Serialize};
55
56use crate::{
57 data::InstrumentClose,
58 enums::{
59 InstrumentClass, InstrumentCloseType, OrderSide, PositionAdjustmentType, PositionSide,
60 },
61 events::{OrderFillVoided, OrderFilled, PositionAdjusted},
62 identifiers::{
63 AccountId, ClientOrderId, InstrumentId, PositionId, StrategyId, Symbol, TradeId, TraderId,
64 Venue, VenueOrderId,
65 },
66 instruments::{Instrument, InstrumentAny},
67 types::{
68 Currency, Money, Price, Quantity,
69 fixed::{FIXED_PRECISION, FIXED_SCALAR_RAW, checked_mul_div_raw, raw_scales_match},
70 quantity::QuantityRaw,
71 },
72};
73
74#[repr(C)]
95#[derive(Debug, Clone, Serialize, Deserialize)]
96#[cfg_attr(
97 feature = "python",
98 pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
99)]
100#[cfg_attr(
101 feature = "python",
102 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
103)]
104pub struct Position {
105 pub events: Vec<OrderFilled>,
106 pub adjustments: Vec<PositionAdjusted>,
107 #[serde(default)]
108 pub replay_events: Vec<PositionReplayEvent>,
109 #[serde(default)]
110 pub fill_voids: Vec<PositionFillVoid>,
111 pub trader_id: TraderId,
112 pub strategy_id: StrategyId,
113 pub instrument_id: InstrumentId,
114 pub id: PositionId,
115 pub account_id: AccountId,
116 pub opening_order_id: ClientOrderId,
117 pub closing_order_id: Option<ClientOrderId>,
118 pub entry: OrderSide,
119 pub side: PositionSide,
120 pub signed_qty: Decimal,
121 pub quantity: Quantity,
122 pub peak_qty: Quantity,
123 pub price_precision: u8,
124 pub size_precision: u8,
125 pub multiplier: Quantity,
126 pub is_inverse: bool,
127 pub is_currency_pair: bool,
128 pub instrument_class: InstrumentClass,
129 pub base_currency: Option<Currency>,
130 pub quote_currency: Currency,
131 pub settlement_currency: Currency,
132 pub ts_init: UnixNanos,
133 pub ts_opened: UnixNanos,
134 pub ts_last: UnixNanos,
135 pub ts_closed: Option<UnixNanos>,
136 pub duration_ns: DurationNanos,
137 pub avg_px_open: Decimal,
138 pub avg_px_close: Option<Decimal>,
139 pub realized_return: f64,
140 pub realized_pnl: Option<Money>,
141 #[serde(with = "nautilus_core::serialization::sorted_hashset")]
142 pub trade_ids: AHashSet<TradeId>,
143 pub buy_qty: Quantity,
145 pub sell_qty: Quantity,
147 pub commissions: IndexMap<Currency, Money>,
148 pub cost_open: Decimal,
149 pub cost_close: Decimal,
150 pub qty_closed: Decimal,
151 pub realized_pnl_remainder: Decimal,
152}
153
154#[expect(clippy::large_enum_variant)]
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub enum PositionReplayEvent {
157 Filled(OrderFilled),
158 Adjusted(PositionAdjusted),
159 InstrumentClosed(InstrumentClose),
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct PositionFillVoid {
164 pub event: OrderFillVoided,
165 pub voided_qty: Quantity,
166 pub commission_voided: Option<Money>,
167}
168
169impl Position {
170 #[must_use]
176 #[allow(
177 clippy::needless_pass_by_value,
178 reason = "constructor takes the opening fill by value as the position's seed event"
179 )]
180 pub fn new(instrument: &InstrumentAny, fill: OrderFilled) -> Self {
181 Self::new_checked(instrument, fill).expect_display(FAILED)
182 }
183
184 #[allow(
191 clippy::needless_pass_by_value,
192 reason = "constructor takes the opening fill by value as the position's seed event"
193 )]
194 pub fn new_checked(instrument: &InstrumentAny, fill: OrderFilled) -> CorrectnessResult<Self> {
195 Self::check_fill_instrument(instrument.id(), "instrument.id()", &fill)?;
196 let position_id = Self::fill_position_id(&fill)?;
197
198 let mut item = Self {
199 events: Vec::<OrderFilled>::new(),
200 adjustments: Vec::<PositionAdjusted>::new(),
201 replay_events: Vec::new(),
202 fill_voids: Vec::new(),
203 trade_ids: AHashSet::<TradeId>::new(),
204 buy_qty: Quantity::zero(instrument.size_precision()),
205 sell_qty: Quantity::zero(instrument.size_precision()),
206 commissions: IndexMap::<Currency, Money>::new(),
207 trader_id: fill.trader_id,
208 strategy_id: fill.strategy_id,
209 instrument_id: fill.instrument_id,
210 id: position_id,
211 account_id: fill.account_id,
212 opening_order_id: fill.client_order_id,
213 closing_order_id: None,
214 entry: fill.order_side,
215 side: PositionSide::Flat,
216 signed_qty: Decimal::ZERO,
217 quantity: fill.last_qty,
218 peak_qty: fill.last_qty,
219 price_precision: instrument.price_precision(),
220 size_precision: instrument.size_precision(),
221 multiplier: instrument.multiplier(),
222 is_inverse: instrument.is_inverse(),
223 is_currency_pair: matches!(
224 instrument,
225 InstrumentAny::CurrencyPair(_) | InstrumentAny::TokenizedAsset(_)
226 ),
227 instrument_class: instrument.instrument_class(),
228 base_currency: instrument.base_currency(),
229 quote_currency: instrument.quote_currency(),
230 settlement_currency: instrument.cost_currency(),
231 ts_init: fill.ts_init,
232 ts_opened: fill.ts_event,
233 ts_last: fill.ts_event,
234 ts_closed: None,
235 duration_ns: DurationNanos::default(),
236 avg_px_open: price(fill.last_px)?,
237 avg_px_close: None,
238 realized_return: 0.0,
239 realized_pnl: None,
240 cost_open: Decimal::ZERO,
241 cost_close: Decimal::ZERO,
242 qty_closed: Decimal::ZERO,
243 realized_pnl_remainder: Decimal::ZERO,
244 };
245
246 item.check_accounting()?;
247 item.apply_fill(&fill, true)?;
248 Ok(item)
249 }
250
251 #[must_use]
258 pub fn clone_without_events(&self) -> Self {
259 Self {
260 events: Vec::new(),
261 adjustments: Vec::new(),
262 replay_events: Vec::new(),
263 fill_voids: Vec::new(),
264 trader_id: self.trader_id,
265 strategy_id: self.strategy_id,
266 instrument_id: self.instrument_id,
267 id: self.id,
268 account_id: self.account_id,
269 opening_order_id: self.opening_order_id,
270 closing_order_id: self.closing_order_id,
271 entry: self.entry,
272 side: self.side,
273 signed_qty: self.signed_qty,
274 quantity: self.quantity,
275 peak_qty: self.peak_qty,
276 price_precision: self.price_precision,
277 size_precision: self.size_precision,
278 multiplier: self.multiplier,
279 is_inverse: self.is_inverse,
280 is_currency_pair: self.is_currency_pair,
281 instrument_class: self.instrument_class,
282 base_currency: self.base_currency,
283 quote_currency: self.quote_currency,
284 settlement_currency: self.settlement_currency,
285 ts_init: self.ts_init,
286 ts_opened: self.ts_opened,
287 ts_last: self.ts_last,
288 ts_closed: self.ts_closed,
289 duration_ns: self.duration_ns,
290 avg_px_open: self.avg_px_open,
291 avg_px_close: self.avg_px_close,
292 realized_return: self.realized_return,
293 realized_pnl: self.realized_pnl,
294 trade_ids: AHashSet::new(),
295 buy_qty: self.buy_qty,
296 sell_qty: self.sell_qty,
297 commissions: self.commissions.clone(),
298 cost_open: self.cost_open,
299 cost_close: self.cost_close,
300 qty_closed: self.qty_closed,
301 realized_pnl_remainder: self.realized_pnl_remainder,
302 }
303 }
304
305 #[must_use]
307 pub fn clone_for_snapshot(&self) -> Self {
308 let mut snapshot = self.clone_without_events();
309 snapshot.events.clone_from(&self.events);
310 snapshot.adjustments.clone_from(&self.adjustments);
311 snapshot.trade_ids.clone_from(&self.trade_ids);
312 snapshot
313 }
314
315 pub fn transfer_replay_state_from(&mut self, prior: &mut Self) {
317 let current_replay = std::mem::take(&mut self.replay_events);
318 self.replay_events = std::mem::take(&mut prior.replay_events);
319 self.fill_voids = std::mem::take(&mut prior.fill_voids);
320 self.replay_events.extend(current_replay);
321 }
322
323 pub fn purge_events_for_order(
335 &mut self,
336 client_order_id: ClientOrderId,
337 ) -> CorrectnessResult<()> {
338 let mut next = self.clone();
339 next.purge_order(client_order_id)?;
340 *self = next;
341 Ok(())
342 }
343
344 fn purge_order(&mut self, client_order_id: ClientOrderId) -> CorrectnessResult<()> {
345 self.replay_events.retain(|event| {
346 !matches!(event, PositionReplayEvent::Filled(fill) if fill.client_order_id == client_order_id)
347 });
348 self.fill_voids
349 .retain(|record| record.event.client_order_id != client_order_id);
350
351 let filtered_events: Vec<OrderFilled> = self
352 .events
353 .iter()
354 .filter(|e| e.client_order_id != client_order_id)
355 .cloned()
356 .collect();
357
358 let preserved_adjustments: Vec<PositionAdjusted> = self
361 .adjustments
362 .iter()
363 .filter(|adj| {
364 adj.adjustment_type != PositionAdjustmentType::Commission
367 })
368 .copied()
369 .collect();
370
371 if filtered_events.is_empty() {
373 log::warn!(
374 "Position {} has no fills remaining after purging order {}; consider closing the position instead",
375 self.id,
376 client_order_id
377 );
378 self.events.clear();
379 self.trade_ids.clear();
380 self.adjustments.clear();
381 self.buy_qty = Quantity::zero(self.size_precision);
382 self.sell_qty = Quantity::zero(self.size_precision);
383 self.commissions.clear();
384 self.signed_qty = Decimal::ZERO;
385 self.quantity = Quantity::zero(self.size_precision);
386 self.side = PositionSide::Flat;
387 self.avg_px_close = None;
388 self.realized_pnl = None;
389 self.realized_return = 0.0;
390 self.ts_opened = UnixNanos::default();
391 self.ts_last = UnixNanos::default();
392 self.ts_closed = Some(UnixNanos::default());
393 self.duration_ns = DurationNanos::default();
394 self.cost_open = Decimal::ZERO;
395 self.cost_close = Decimal::ZERO;
396 self.qty_closed = Decimal::ZERO;
397 self.realized_pnl_remainder = Decimal::ZERO;
398 return Ok(());
399 }
400
401 let position_id = self.id;
403 self.reset_derived_state();
404
405 let first_event = &filtered_events[0];
407 self.entry = first_event.order_side;
408 self.opening_order_id = first_event.client_order_id;
409 self.ts_opened = first_event.ts_event;
410 self.ts_init = first_event.ts_init;
411 self.closing_order_id = None;
412 self.ts_closed = None;
413 self.duration_ns = DurationNanos::default();
414
415 let restatement_indexes = Self::price_restatement_indexes(&self.replay_events);
416
417 let start = self.replay_events.iter().position(|event| {
418 matches!(
419 event,
420 PositionReplayEvent::Filled(fill) if fill.event_id == first_event.event_id
421 )
422 });
423
424 let remaining_orders: AHashSet<_> = filtered_events
425 .iter()
426 .map(|event| event.client_order_id)
427 .collect();
428 let effective_by_id: AHashMap<_, _> = filtered_events
429 .iter()
430 .map(|event| (event.event_id, event))
431 .collect();
432
433 if let Some(start) = start {
434 let replay = self.replay_events.clone();
435 for (index, event) in replay.iter().enumerate().skip(start) {
436 let PositionReplayEvent::Filled(fill) = event else {
437 continue;
438 };
439
440 if !remaining_orders.contains(&fill.client_order_id) {
441 continue;
442 }
443
444 if restatement_indexes.contains(&index) {
445 self.apply_price_restatement(fill, false)?;
446 } else if let Some(effective) = effective_by_id.get(&fill.event_id) {
447 self.apply_fill(effective, false)?;
448 }
449 }
450 } else {
451 for event in &filtered_events {
452 self.apply_fill(event, false)?;
453 }
454 }
455
456 for adjustment in preserved_adjustments {
458 self.apply_adjustment_state(adjustment, false)?;
459 }
460
461 log::info!(
462 "Purged fills for order {} from position {}; recalculated state: qty={}, signed_qty={}, side={:?}",
463 client_order_id,
464 position_id,
465 self.quantity,
466 self.signed_qty,
467 self.side
468 );
469 Ok(())
470 }
471
472 pub fn apply(&mut self, fill: &OrderFilled) {
485 self.apply_fill(fill, true).expect_display(FAILED);
486 }
487
488 pub fn try_apply(&mut self, fill: &OrderFilled) -> CorrectnessResult<()> {
504 Self::check_fill_instrument(self.instrument_id, "self.instrument_id", fill)?;
505 let position_id = Self::fill_position_id(fill)?;
506 check_equal(&self.id, &position_id, "self.id", "fill.position_id")?;
507 self.apply_fill(fill, true)
508 }
509
510 fn replay_has_event(&self, event_id: UUID4) -> bool {
511 self.replay_events.iter().any(|event| {
512 matches!(
513 event,
514 PositionReplayEvent::Filled(existing) if existing.event_id == event_id
515 )
516 })
517 }
518
519 fn check_fill_instrument(
520 instrument_id: InstrumentId,
521 instrument_param: &str,
522 fill: &OrderFilled,
523 ) -> CorrectnessResult<()> {
524 check_equal(
525 &instrument_id,
526 &fill.instrument_id,
527 instrument_param,
528 "fill.instrument_id",
529 )
530 }
531
532 fn fill_position_id(fill: &OrderFilled) -> CorrectnessResult<PositionId> {
533 fill.position_id
534 .ok_or_else(|| CorrectnessError::PredicateViolation {
535 message: "`fill.position_id` was None".to_string(),
536 })
537 }
538
539 fn check_fill_accounting(&self, fill: &OrderFilled) -> CorrectnessResult<(Decimal, Decimal)> {
540 let last_px = price(fill.last_px)?;
541 let last_qty = qty(fill.last_qty)?;
542 if let Some(commission) = fill.commission {
543 amount(commission)?;
544 }
545
546 Ok((last_px, last_qty))
547 }
548
549 pub fn check_accounting(&self) -> CorrectnessResult<()> {
555 for quantity in [
556 self.quantity,
557 self.peak_qty,
558 self.buy_qty,
559 self.sell_qty,
560 self.multiplier,
561 ] {
562 qty(quantity)?;
563 }
564
565 for amount in self.commissions.values().copied().chain(self.realized_pnl) {
566 self::amount(amount)?;
567 }
568
569 for fill in &self.events {
570 self.check_fill_accounting(fill)?;
571 }
572
573 for event in &self.replay_events {
574 match event {
575 PositionReplayEvent::Filled(fill) => {
576 self.check_fill_accounting(fill)?;
577 }
578 PositionReplayEvent::InstrumentClosed(close) => {
579 price(close.close_price)?;
580 }
581 PositionReplayEvent::Adjusted(_) => {}
582 }
583 }
584
585 Ok(())
586 }
587
588 fn apply_fill(&mut self, fill: &OrderFilled, record_replay: bool) -> CorrectnessResult<()> {
589 if record_replay
590 && self.trade_ids.contains(&fill.trade_id)
591 && self.replay_has_event(fill.event_id)
592 {
593 return Ok(());
594 }
595
596 let (last_px, last_qty) = self.check_fill_accounting(fill)?;
597
598 if self.is_price_restatement(fill) {
599 let mut next = self.clone();
600 next.apply_price_restatement(fill, record_replay)?;
601 *self = next;
602 return Ok(());
603 }
604
605 if record_replay
606 && (self.side == PositionSide::Flat || !self.trade_ids.contains(&fill.trade_id))
607 && self.is_duplicate_replay_fill(fill)
608 {
609 log::warn!(
610 "Ignoring historical duplicate fill {} for position {}; durable replay already contains this trade",
611 fill.trade_id,
612 self.id,
613 );
614 return Ok(());
615 }
616
617 if fill.ts_event < self.ts_opened {
618 log::warn!(
619 "Fill ts_event {} for {} is before position ts_opened {}",
620 fill.ts_event,
621 self.id,
622 self.ts_opened,
623 );
624 }
625
626 let resets = self.side == PositionSide::Flat;
627 if record_replay && !resets {
628 check_predicate_true(
629 !self.trade_ids.contains(&fill.trade_id),
630 "`fill.trade_id` already contained in `trade_ids`",
631 )?;
632 }
633
634 let mut next = self.prepare_fill(fill, last_px, last_qty)?;
635 if !resets {
636 next.events = std::mem::take(&mut self.events);
637 next.trade_ids = std::mem::take(&mut self.trade_ids);
638 let mut adjustments = std::mem::take(&mut self.adjustments);
639 adjustments.append(&mut next.adjustments);
640 next.adjustments = adjustments;
641 }
642
643 next.replay_events = std::mem::take(&mut self.replay_events);
644 next.fill_voids = std::mem::take(&mut self.fill_voids);
645 next.events.push(fill.clone());
646 next.trade_ids.insert(fill.trade_id);
647 if record_replay {
648 next.replay_events
649 .push(PositionReplayEvent::Filled(fill.clone()));
650 }
651
652 next.debug_assert_invariants();
653 *self = next;
654 Ok(())
655 }
656
657 fn prepare_fill(
658 &self,
659 fill: &OrderFilled,
660 last_px: Decimal,
661 last_qty: Decimal,
662 ) -> CorrectnessResult<Self> {
663 let mut next = self.clone_without_events();
664 if self.side == PositionSide::Flat {
665 next.reset_cycle(fill)?;
666 }
667
668 if let Some(commission) = fill.commission {
669 let total = match next.commissions.get(&commission.currency) {
670 Some(total) => total
671 .checked_add(commission)
672 .ok_or_else(|| invalid("position commission overflow"))?,
673 None => commission,
674 };
675
676 next.commissions.insert(commission.currency, total);
677 }
678
679 next.handle_order_fill(fill, last_px, last_qty)?;
680 if next.is_currency_pair
681 && let Some(commission) = fill.commission
682 && next.base_currency == Some(commission.currency)
683 {
684 next.apply_base_commission_adjustment(fill, commission)?;
685 }
686
687 next.quantity = quantity(next.signed_qty.abs(), next.size_precision)?;
688 next.peak_qty = next.peak_qty.max(next.quantity);
689 if next.quantity.is_zero() {
690 next.side = PositionSide::Flat;
691 next.signed_qty = Decimal::ZERO;
692 next.closing_order_id = Some(fill.client_order_id);
693 next.ts_closed = Some(fill.ts_event);
694 next.duration_ns = fill.ts_event.saturating_duration_since(next.ts_opened);
695 } else if next.signed_qty > Decimal::ZERO {
696 next.entry = OrderSide::Buy;
697 next.side = PositionSide::Long;
698 } else {
699 next.entry = OrderSide::Sell;
700 next.side = PositionSide::Short;
701 }
702
703 next.ts_last = fill.ts_event;
704 Ok(next)
705 }
706
707 fn reset_cycle(&mut self, fill: &OrderFilled) -> CorrectnessResult<()> {
708 self.events.clear();
709 self.trade_ids.clear();
710 self.adjustments.clear();
711 self.buy_qty = Quantity::zero(self.size_precision);
712 self.sell_qty = Quantity::zero(self.size_precision);
713 self.commissions.clear();
714 self.opening_order_id = fill.client_order_id;
715 self.closing_order_id = None;
716 self.peak_qty = Quantity::zero(self.size_precision);
717 self.ts_init = fill.ts_init;
718 self.ts_opened = fill.ts_event;
719 self.ts_closed = None;
720 self.duration_ns = DurationNanos::default();
721 self.avg_px_open = price(fill.last_px)?;
722 self.avg_px_close = None;
723 self.realized_return = 0.0;
724 self.realized_pnl = None;
725 self.cost_open = Decimal::ZERO;
726 self.cost_close = Decimal::ZERO;
727 self.qty_closed = Decimal::ZERO;
728 self.realized_pnl_remainder = Decimal::ZERO;
729 Ok(())
730 }
731
732 #[must_use]
734 pub fn has_replay_trade_id(&self, trade_id: TradeId) -> bool {
735 self.replay_events.iter().any(
736 |event| matches!(event, PositionReplayEvent::Filled(fill) if fill.trade_id == trade_id),
737 )
738 }
739
740 fn is_duplicate_replay_fill(&self, fill: &OrderFilled) -> bool {
741 let continues_latest_fill = fill.causation_id.is_some_and(|source_id| {
742 self.events.last().is_some_and(|latest| {
743 latest.trade_id == fill.trade_id && latest.event_id == source_id
744 })
745 });
746
747 if self.trade_ids.contains(&fill.trade_id) {
748 return !continues_latest_fill
749 || self.replay_events.iter().any(|event| {
750 matches!(
751 event,
752 PositionReplayEvent::Filled(replayed)
753 if replayed.trade_id == fill.trade_id
754 && replayed.causation_id == fill.causation_id
755 )
756 });
757 }
758
759 let replay_starts_current_cycle = self.replay_events.is_empty()
760 || matches!(
761 (self.replay_events.first(), self.events.first()),
762 (
763 Some(PositionReplayEvent::Filled(replayed)),
764 Some(current),
765 ) if replayed.event_id == current.event_id
766 );
767 let corrected_trade = self
768 .fill_voids
769 .iter()
770 .any(|record| record.event.trade_id == fill.trade_id);
771 let current_cycle_only = replay_starts_current_cycle && !corrected_trade;
772 if current_cycle_only {
773 return false;
774 }
775
776 self.has_replay_trade_id(fill.trade_id)
777 }
778
779 #[must_use]
783 pub fn accepts_price_restatement(&self, fill: &OrderFilled) -> bool {
784 if !self.is_open()
785 || !self.trade_ids.contains(&fill.trade_id)
786 || fill.order_side != self.entry
787 {
788 return false;
789 }
790
791 let Some(existing) = self.events.iter().find(|event| {
792 event.trade_id == fill.trade_id && event.client_order_id == fill.client_order_id
793 }) else {
794 return false;
795 };
796
797 if existing.last_qty != fill.last_qty {
798 return false;
799 }
800
801 let mut latest = None;
802
803 for event in &self.replay_events {
804 let PositionReplayEvent::Filled(existing) = event else {
805 continue;
806 };
807
808 if existing.trade_id == fill.trade_id
809 && existing.client_order_id == fill.client_order_id
810 {
811 latest = Some(existing.last_px);
812 }
813 }
814
815 latest.is_some_and(|price| price != fill.last_px)
816 }
817
818 fn is_price_restatement(&self, fill: &OrderFilled) -> bool {
819 if self.side == PositionSide::Flat
820 || self.quantity.is_zero()
821 || !self.trade_ids.contains(&fill.trade_id)
822 {
823 return false;
824 }
825
826 let mut latest = None;
827
828 for event in &self.replay_events {
829 let PositionReplayEvent::Filled(existing) = event else {
830 continue;
831 };
832
833 if existing.trade_id != fill.trade_id
834 || existing.client_order_id != fill.client_order_id
835 {
836 continue;
837 }
838
839 if existing.event_id == fill.event_id {
840 return latest.is_some_and(|price| price != fill.last_px);
841 }
842
843 latest = Some(existing.last_px);
844 }
845
846 latest.is_some_and(|price| price != fill.last_px)
847 }
848
849 fn apply_price_restatement(
850 &mut self,
851 fill: &OrderFilled,
852 record_replay: bool,
853 ) -> CorrectnessResult<()> {
854 if self.side != PositionSide::Flat && !self.quantity.is_zero() {
855 self.restate_open_average(fill)?;
856 }
857
858 self.ts_last = fill.ts_event;
859
860 if record_replay {
861 self.replay_events
862 .push(PositionReplayEvent::Filled(fill.clone()));
863 }
864
865 Ok(())
866 }
867
868 fn has_replay_price_restatement(&self) -> bool {
869 let mut seen = AHashMap::new();
870
871 for event in &self.replay_events {
872 let PositionReplayEvent::Filled(fill) = event else {
873 continue;
874 };
875
876 if let Some(price) = seen.get(&fill.trade_id) {
877 if *price != fill.last_px {
878 return true;
879 }
880 } else {
881 seen.insert(fill.trade_id, fill.last_px);
882 }
883 }
884
885 false
886 }
887
888 fn restate_open_average(&mut self, fill: &OrderFilled) -> CorrectnessResult<()> {
889 if !self.trade_ids.contains(&fill.trade_id)
890 || fill.order_side != self.entry
891 || !self.extends_current_cycle(fill)?
892 {
893 return Ok(());
894 }
895
896 let new_px = price(fill.last_px)?;
897
898 let Some(previous_px) = self.accounting_px_before(fill)? else {
899 return Ok(());
900 };
901
902 let open_qty = qty(self.quantity)?;
903 let trade_qty = self.cycle_open_qty_for_trade(fill.trade_id)?.min(open_qty);
904 if open_qty.is_zero() || trade_qty.is_zero() {
905 return Ok(());
906 }
907
908 let delta = mul_rounded(sub(new_px, previous_px)?, trade_qty)?;
909 self.cost_open = add_rounded(self.cost_open, delta)?;
910 self.avg_px_open = div(self.cost_open, open_qty)?;
911 Ok(())
912 }
913
914 fn extends_current_cycle(&self, fill: &OrderFilled) -> CorrectnessResult<bool> {
915 let long = self.entry == OrderSide::Buy;
916 let mut signed = Decimal::ZERO;
917
918 for event in &self.events {
919 let qty = qty(event.last_qty)?;
920 let delta = if event.order_side == OrderSide::Buy {
921 qty
922 } else {
923 -qty
924 };
925
926 let after = add(signed, delta)?;
927 if event.trade_id == fill.trade_id && event.client_order_id == fill.client_order_id {
928 return Ok((signed.is_zero() || (signed > Decimal::ZERO) == long)
929 && ((after > Decimal::ZERO) == long));
930 }
931
932 signed = after;
933 }
934
935 Ok(false)
936 }
937
938 fn cycle_open_qty_for_trade(&self, trade_id: TradeId) -> CorrectnessResult<Decimal> {
939 let long = self.entry == OrderSide::Buy;
940 let mut remaining: Vec<(TradeId, Decimal)> = Vec::new();
941 let mut signed = Decimal::ZERO;
942
943 for fill in &self.events {
944 let qty = qty(fill.last_qty)?;
945 let delta = if fill.order_side == OrderSide::Buy {
946 qty
947 } else {
948 -qty
949 };
950
951 let after = add(signed, delta)?;
952 if !signed.is_zero() && (after > Decimal::ZERO) != (signed > Decimal::ZERO) {
953 remaining.clear();
954 if !after.is_zero() && (after > Decimal::ZERO) == long {
955 remaining.push((fill.trade_id, after.abs()));
956 }
957 } else if fill.order_side == self.entry {
958 remaining.push((fill.trade_id, qty));
959 } else {
960 Self::reduce_open_lots(&mut remaining, qty)?;
961 }
962
963 signed = after;
964 }
965
966 remaining
967 .into_iter()
968 .filter(|(id, _)| *id == trade_id)
969 .try_fold(Decimal::ZERO, |total, (_, qty)| add_rounded(total, qty))
970 }
971
972 fn reduce_open_lots(
973 remaining: &mut [(TradeId, Decimal)],
974 qty: Decimal,
975 ) -> CorrectnessResult<()> {
976 let open_qty = remaining
977 .iter()
978 .try_fold(Decimal::ZERO, |total, (_, qty)| add_rounded(total, *qty))?;
979
980 if !open_qty.is_zero() {
981 let keep = div(sub(open_qty, qty.min(open_qty))?, open_qty)?;
982 for (_, qty) in remaining {
983 *qty = mul_rounded(*qty, keep)?;
984 }
985 }
986
987 Ok(())
988 }
989
990 fn accounting_px_before(&self, fill: &OrderFilled) -> CorrectnessResult<Option<Decimal>> {
991 let mut price = None;
992
993 for event in &self.replay_events {
994 let PositionReplayEvent::Filled(existing) = event else {
995 continue;
996 };
997
998 if existing.trade_id != fill.trade_id {
999 continue;
1000 }
1001
1002 if existing.event_id == fill.event_id {
1003 break;
1004 }
1005
1006 price = Some(self::price(existing.last_px)?);
1007 }
1008
1009 Ok(price)
1010 }
1011
1012 fn voidable_replay_fill<'a>(
1013 restatement_indexes: &AHashSet<usize>,
1014 index: usize,
1015 replay_event: &'a PositionReplayEvent,
1016 ) -> Option<&'a OrderFilled> {
1017 let PositionReplayEvent::Filled(fill) = replay_event else {
1018 return None;
1019 };
1020
1021 if restatement_indexes.contains(&index) {
1022 return None;
1023 }
1024
1025 Some(fill)
1026 }
1027
1028 fn price_restatement_indexes(replay_events: &[PositionReplayEvent]) -> AHashSet<usize> {
1029 let mut latest_px = AHashMap::new();
1030 let mut indexes = AHashSet::new();
1031
1032 for (index, event) in replay_events.iter().enumerate() {
1033 let PositionReplayEvent::Filled(fill) = event else {
1034 continue;
1035 };
1036
1037 let key = (fill.trade_id, fill.client_order_id);
1038 if let Some(price) = latest_px.get(&key)
1039 && *price != fill.last_px
1040 {
1041 indexes.insert(index);
1042 }
1043
1044 latest_px.insert(key, fill.last_px);
1045 }
1046
1047 indexes
1048 }
1049
1050 fn handle_order_fill(
1051 &mut self,
1052 fill: &OrderFilled,
1053 last_px: Decimal,
1054 last_qty: Decimal,
1055 ) -> CorrectnessResult<()> {
1056 check_predicate_true(last_qty > Decimal::ZERO, "fill quantity must be positive")?;
1057 let prior_qty = self.signed_qty.abs();
1058 let increases = self.signed_qty.is_zero()
1059 || (self.signed_qty > Decimal::ZERO) == (fill.order_side == OrderSide::Buy);
1060 let mut pnl = Decimal::ZERO;
1061
1062 if increases {
1063 self.cost_open = add_rounded(self.cost_open, mul_rounded(last_px, last_qty)?)?;
1064 self.avg_px_open = div(self.cost_open, add(prior_qty, last_qty)?)?;
1065 } else {
1066 let closing_qty = last_qty.min(prior_qty);
1067
1068 let allocated_cost = if closing_qty == prior_qty {
1069 self.cost_open
1070 } else {
1071 proportion(self.cost_open, closing_qty, prior_qty)?
1072 };
1073
1074 pnl = self.pnl_from_cost(
1075 last_px,
1076 closing_qty,
1077 allocated_cost,
1078 prior_qty,
1079 self.cost_open,
1080 )?;
1081 self.cost_open = sub(self.cost_open, allocated_cost)?;
1082 self.cost_close = add_rounded(self.cost_close, mul_rounded(last_px, closing_qty)?)?;
1083 self.qty_closed = add(self.qty_closed, closing_qty)?;
1084 self.avg_px_close = Some(div(self.cost_close, self.qty_closed)?);
1085 self.realized_return = self.calculate_return(
1086 self.avg_px_open,
1087 self.avg_px_close.expect("closing average is set"),
1088 );
1089
1090 if last_qty > prior_qty {
1091 let opening_qty = sub(last_qty, prior_qty)?;
1092 self.cost_open = mul_rounded(last_px, opening_qty)?;
1093 self.avg_px_open = last_px;
1094 self.avg_px_close = None;
1095 self.realized_return = 0.0;
1096 self.cost_close = Decimal::ZERO;
1097 self.qty_closed = Decimal::ZERO;
1098 self.buy_qty = Quantity::zero(self.size_precision);
1099 self.sell_qty = Quantity::zero(self.size_precision);
1100 }
1101 }
1102
1103 let commission = fill
1104 .commission
1105 .filter(|commission| commission.currency == self.settlement_currency)
1106 .map(amount)
1107 .transpose()?
1108 .unwrap_or(Decimal::ZERO);
1109 pnl = add_pnl(pnl, -commission, self.settlement_currency)?;
1110 self.accumulate_pnl(pnl)?;
1111
1112 let signed = if fill.order_side == OrderSide::Buy {
1113 last_qty
1114 } else {
1115 -last_qty
1116 };
1117
1118 self.signed_qty = add(self.signed_qty, signed)?;
1119
1120 let qty = if !increases && last_qty > prior_qty {
1121 quantity(sub(last_qty, prior_qty)?, self.size_precision)?
1122 } else {
1123 fill.last_qty
1124 };
1125
1126 let cumulative = if fill.order_side == OrderSide::Buy {
1127 &mut self.buy_qty
1128 } else {
1129 &mut self.sell_qty
1130 };
1131
1132 *cumulative = quantity_add(*cumulative, qty, self.size_precision)?;
1133
1134 Ok(())
1135 }
1136
1137 fn accumulate_pnl(&mut self, change: Decimal) -> CorrectnessResult<()> {
1138 let prior = self.realized_decimal()?;
1139 let total = add_pnl(prior, change, self.settlement_currency)?;
1140 let rounded = money(total, self.settlement_currency)?;
1141 self.realized_pnl_remainder = sub(total, amount(rounded)?)?;
1142 self.realized_pnl = Some(rounded);
1143 Ok(())
1144 }
1145
1146 fn realized_decimal(&self) -> CorrectnessResult<Decimal> {
1147 let prior = match self.realized_pnl {
1148 Some(pnl) => {
1149 check_equal(
1150 &pnl.currency,
1151 &self.settlement_currency,
1152 "realized_pnl.currency",
1153 "settlement_currency",
1154 )?;
1155 amount(pnl)?
1156 }
1157 None => Decimal::ZERO,
1158 };
1159
1160 add(prior, self.realized_pnl_remainder)
1161 }
1162
1163 fn pnl_from_cost(
1164 &self,
1165 last_px: Decimal,
1166 qty: Decimal,
1167 cost: Decimal,
1168 prior_qty: Decimal,
1169 basis_cost: Decimal,
1170 ) -> CorrectnessResult<Decimal> {
1171 let difference = if self
1172 .instrument_class
1173 .divides_notional_by_price(self.is_inverse)
1174 {
1175 check_predicate_true(
1176 self.base_currency.is_some(),
1177 "inverse position requires base currency",
1178 )?;
1179 check_predicate_true(
1180 last_px > Decimal::ZERO && basis_cost > Decimal::ZERO,
1181 "inverse prices must be positive",
1182 )?;
1183 let entry = div(prior_qty, basis_cost)?;
1184 let exit = div(Decimal::ONE, last_px)?;
1185 mul_rounded(qty, add_rounded(entry, -exit)?)?
1186 } else {
1187 add_rounded(mul_rounded(last_px, qty)?, -cost)?
1188 };
1189
1190 let difference = if self.signed_qty < Decimal::ZERO {
1191 -difference
1192 } else {
1193 difference
1194 };
1195
1196 if self.multiplier.precision <= FIXED_PRECISION && self.multiplier.raw == FIXED_SCALAR_RAW {
1197 return Ok(difference);
1198 }
1199
1200 mul_rounded(difference, self::qty(self.multiplier)?)
1201 }
1202
1203 pub fn apply_adjustment(&mut self, adjustment: PositionAdjusted) -> CorrectnessResult<()> {
1218 self.apply_adjustment_state(adjustment, true)
1219 }
1220
1221 pub fn revert_last_adjustment(&mut self, prior: &Self) -> CorrectnessResult<()> {
1241 check_equal(&self.id, &prior.id, "self.id", "prior.id")?;
1242 check_equal(
1243 &self.size_precision,
1244 &prior.size_precision,
1245 "self.size_precision",
1246 "prior.size_precision",
1247 )?;
1248 check_equal(
1249 &self.settlement_currency,
1250 &prior.settlement_currency,
1251 "self.settlement_currency",
1252 "prior.settlement_currency",
1253 )?;
1254
1255 let adjustment = match (self.replay_events.last(), self.adjustments.last()) {
1256 (Some(PositionReplayEvent::Adjusted(replayed)), Some(latest)) if replayed == latest => {
1257 *latest
1258 }
1259 _ => {
1260 return Err(CorrectnessError::PredicateViolation {
1261 message: format!(
1262 "cannot revert adjustment for position {}: the last replay event is not its latest adjustment",
1263 self.id
1264 ),
1265 });
1266 }
1267 };
1268
1269 if let Some(earlier) = self.adjustments.iter().rev().nth(1)
1270 && prior.ts_last < earlier.ts_event
1271 {
1272 return Err(CorrectnessError::PredicateViolation {
1273 message: format!(
1274 "cannot revert adjustment {} for position {}: `prior` at {} predates adjustment {} at {}",
1275 adjustment.event_id, self.id, prior.ts_last, earlier.event_id, earlier.ts_event
1276 ),
1277 });
1278 }
1279
1280 if let (Some(realized_pnl), Some(pnl_change)) = (prior.realized_pnl, adjustment.pnl_change)
1282 && (realized_pnl.currency != pnl_change.currency
1283 || realized_pnl.checked_add(pnl_change).is_none())
1284 {
1285 return Err(CorrectnessError::PredicateViolation {
1286 message: format!(
1287 "cannot revert adjustment {} for position {}: `prior` realized PnL {realized_pnl} cannot take the PnL change {pnl_change}",
1288 adjustment.event_id, self.id
1289 ),
1290 });
1291 }
1292
1293 let mut expected = prior.clone_without_events();
1294 expected.apply_adjustment_state(adjustment, false)?;
1295
1296 if expected.signed_qty != self.signed_qty
1297 || expected.quantity != self.quantity
1298 || expected.peak_qty != self.peak_qty
1299 || expected.side != self.side
1300 || expected.cost_open != self.cost_open
1301 || expected.realized_pnl_remainder != self.realized_pnl_remainder
1302 || expected.realized_pnl != self.realized_pnl
1303 || expected.ts_last != self.ts_last
1304 {
1305 return Err(CorrectnessError::PredicateViolation {
1306 message: format!(
1307 "cannot revert adjustment {} for position {}: `prior` is not the state before it",
1308 adjustment.event_id, self.id
1309 ),
1310 });
1311 }
1312
1313 self.adjustments.pop();
1314 self.replay_events.pop();
1315 self.signed_qty = prior.signed_qty;
1316 self.quantity = prior.quantity;
1317 self.peak_qty = prior.peak_qty;
1318 self.side = prior.side;
1319 self.realized_pnl = prior.realized_pnl;
1320 self.cost_open = prior.cost_open;
1321 self.realized_pnl_remainder = prior.realized_pnl_remainder;
1322 self.ts_last = prior.ts_last;
1323
1324 self.debug_assert_invariants();
1325 Ok(())
1326 }
1327
1328 fn apply_adjustment_state(
1329 &mut self,
1330 adjustment: PositionAdjusted,
1331 record_replay: bool,
1332 ) -> CorrectnessResult<()> {
1333 let signed_qty = add(
1334 self.signed_qty,
1335 adjustment.quantity_change.unwrap_or(Decimal::ZERO),
1336 )?;
1337 let quantity = quantity(signed_qty.abs(), self.size_precision)?;
1338
1339 let cost_open = if adjustment.quantity_change.is_some() {
1340 if self.signed_qty.is_zero() {
1341 mul_rounded(self.avg_px_open, signed_qty.abs())?
1342 } else {
1343 proportion(self.cost_open, signed_qty.abs(), self.signed_qty.abs())?
1344 }
1345 } else {
1346 self.cost_open
1347 };
1348
1349 let (realized_pnl, remainder) = if let Some(change) = adjustment.pnl_change {
1350 check_equal(
1351 &change.currency,
1352 &self.settlement_currency,
1353 "adjustment.currency",
1354 "settlement_currency",
1355 )?;
1356 let prior = self.realized_decimal()?;
1357 let total = add_pnl(prior, amount(change)?, self.settlement_currency)?;
1358 let rounded = money(total, self.settlement_currency)?;
1359 (Some(rounded), sub(total, amount(rounded)?)?)
1360 } else {
1361 (self.realized_pnl, self.realized_pnl_remainder)
1362 };
1363
1364 self.signed_qty = signed_qty;
1365 self.quantity = quantity;
1366 self.peak_qty = self.peak_qty.max(quantity);
1367 self.cost_open = cost_open;
1368 self.realized_pnl = realized_pnl;
1369 self.realized_pnl_remainder = remainder;
1370
1371 self.side = if signed_qty.is_zero() {
1372 PositionSide::Flat
1373 } else if signed_qty > Decimal::ZERO {
1374 PositionSide::Long
1375 } else {
1376 PositionSide::Short
1377 };
1378
1379 self.adjustments.push(adjustment);
1380
1381 if record_replay {
1382 self.replay_events
1383 .push(PositionReplayEvent::Adjusted(adjustment));
1384 }
1385
1386 self.ts_last = adjustment.ts_event;
1387 self.debug_assert_invariants();
1388 Ok(())
1389 }
1390
1391 pub fn apply_instrument_close(&mut self, close: InstrumentClose) -> anyhow::Result<()> {
1404 anyhow::ensure!(
1405 close.instrument_id == self.instrument_id,
1406 "instrument close {} does not match position instrument {}",
1407 close.instrument_id,
1408 self.instrument_id,
1409 );
1410 anyhow::ensure!(
1411 close.close_type == InstrumentCloseType::ContractExpired,
1412 "instrument close for {} is not a contract expiration",
1413 close.instrument_id,
1414 );
1415 anyhow::ensure!(
1416 self.is_open(),
1417 "cannot settle position {} which is not open",
1418 self.id,
1419 );
1420
1421 self.apply_instrument_close_state(close, true)
1422 }
1423
1424 fn apply_instrument_close_state(
1425 &mut self,
1426 close: InstrumentClose,
1427 record_replay: bool,
1428 ) -> anyhow::Result<()> {
1429 if self.side == PositionSide::Flat {
1430 return Ok(());
1431 }
1432
1433 let last_px = price(close.close_price)?;
1434 let qty = self.signed_qty.abs();
1435 let cost_close = add_rounded(self.cost_close, mul_rounded(last_px, qty)?)?;
1436 let qty_closed = add(self.qty_closed, qty)?;
1437 let avg_px_close = div(cost_close, qty_closed)?;
1438 let realized_return = self.calculate_return(self.avg_px_open, avg_px_close);
1439 let pnl = self.pnl_from_cost(last_px, qty, self.cost_open, qty, self.cost_open)?;
1440 self.accumulate_pnl(pnl)?;
1441
1442 if record_replay {
1443 self.replay_events
1444 .push(PositionReplayEvent::InstrumentClosed(close));
1445 }
1446
1447 self.avg_px_close = Some(avg_px_close);
1448 self.realized_return = realized_return;
1449 self.cost_open = Decimal::ZERO;
1450 self.cost_close = cost_close;
1451 self.qty_closed = qty_closed;
1452 self.side = PositionSide::Flat;
1453 self.signed_qty = Decimal::ZERO;
1454 self.quantity = Quantity::zero(self.size_precision);
1455 self.closing_order_id = None;
1456 self.ts_last = close.ts_event;
1457 self.ts_closed = Some(close.ts_event);
1458 self.duration_ns = close.ts_event.saturating_duration_since(self.ts_opened);
1459 self.debug_assert_invariants();
1460 Ok(())
1461 }
1462
1463 fn debug_assert_invariants(&self) {
1464 debug_assert!(
1465 match self.side {
1466 PositionSide::Long => self.signed_qty > Decimal::ZERO,
1467 PositionSide::Short => self.signed_qty < Decimal::ZERO,
1468 PositionSide::Flat => self.signed_qty == Decimal::ZERO,
1469 },
1470 "Invariant: position side must match signed_qty sign (side={:?}, signed_qty={})",
1471 self.side,
1472 self.signed_qty,
1473 );
1474 debug_assert!(
1475 self.peak_qty >= self.quantity,
1476 "Invariant: peak_qty must not be less than current quantity (peak={}, quantity={})",
1477 self.peak_qty,
1478 self.quantity,
1479 );
1480 }
1481
1482 pub fn apply_fill_void(
1495 &mut self,
1496 event: OrderFillVoided,
1497 voided_qty: Quantity,
1498 commission_voided: Option<Money>,
1499 ) -> anyhow::Result<Option<Money>> {
1500 let fragments = self.fill_fragments(event.client_order_id, event.trade_id);
1501
1502 qty(voided_qty)?;
1503
1504 let fragment_qty = fragments
1505 .iter()
1506 .try_fold(Quantity::zero(self.size_precision), |total, fill| {
1507 quantity_add(total, fill.last_qty, self.size_precision)
1508 })?;
1509
1510 anyhow::ensure!(
1511 !voided_qty.is_zero() && voided_qty <= fragment_qty,
1512 "position fill void exceeds known fragments for {}",
1513 event.trade_id,
1514 );
1515
1516 if let Some(commission_voided) = commission_voided {
1517 for commission in fragments.iter().filter_map(|fill| fill.commission) {
1518 anyhow::ensure!(
1519 commission.currency == commission_voided.currency,
1520 "position commission currency differs for fill {}",
1521 event.trade_id,
1522 );
1523 }
1524 }
1525
1526 if let Some(previous) = self.fill_voids.iter().rev().find(|record| {
1527 record.event.client_order_id == event.client_order_id
1528 && record.event.trade_id == event.trade_id
1529 }) {
1530 anyhow::ensure!(
1531 voided_qty >= previous.voided_qty,
1532 "stale position fill void for {}",
1533 event.trade_id,
1534 );
1535 anyhow::ensure!(
1536 voided_qty != previous.voided_qty
1537 || commission_voided != previous.commission_voided,
1538 "duplicate position fill void for {}",
1539 event.trade_id,
1540 );
1541 }
1542
1543 let mut next = self.clone();
1544 next.fill_voids.push(PositionFillVoid {
1545 event,
1546 voided_qty,
1547 commission_voided,
1548 });
1549
1550 let closed = next.rebuild_from_replay()?;
1551 *self = next;
1552 Ok(closed)
1553 }
1554
1555 #[must_use]
1557 pub fn fill_fragments(
1558 &self,
1559 client_order_id: ClientOrderId,
1560 trade_id: TradeId,
1561 ) -> Vec<&OrderFilled> {
1562 let restatement_indexes = Self::price_restatement_indexes(&self.replay_events);
1563 self.replay_events
1564 .iter()
1565 .enumerate()
1566 .filter_map(|(index, event)| match event {
1567 PositionReplayEvent::Filled(fill)
1568 if fill.client_order_id == client_order_id
1569 && fill.trade_id == trade_id
1570 && !restatement_indexes.contains(&index) =>
1571 {
1572 Some(fill)
1573 }
1574 _ => None,
1575 })
1576 .collect()
1577 }
1578
1579 fn rebuild_from_replay(&mut self) -> CorrectnessResult<Option<Money>> {
1583 let replay_events = self.replay_events.clone();
1584 let restatement_indexes = Self::price_restatement_indexes(&replay_events);
1585 let mut quantity_removed = AHashMap::<usize, Quantity>::new();
1586 let mut commission_removed = AHashMap::<usize, Money>::new();
1587
1588 for correction in self.latest_fill_voids() {
1589 let mut remaining_qty = correction.voided_qty;
1590 let mut remaining_commission = correction.commission_voided;
1591
1592 for (index, replay_event) in replay_events.iter().enumerate().rev() {
1593 let Some(fill) =
1594 Self::voidable_replay_fill(&restatement_indexes, index, replay_event)
1595 else {
1596 continue;
1597 };
1598
1599 if fill.client_order_id != correction.event.client_order_id
1600 || fill.trade_id != correction.event.trade_id
1601 {
1602 continue;
1603 }
1604
1605 if !remaining_qty.is_zero() {
1606 let removed = remaining_qty.min(fill.last_qty);
1607 quantity_removed.insert(index, removed);
1608 remaining_qty = quantity_sub(remaining_qty, removed, remaining_qty.precision)?;
1609 }
1610
1611 if let (Some(remaining), Some(commission)) = (remaining_commission, fill.commission)
1612 {
1613 let magnitude = remaining.abs().min(commission.abs());
1614
1615 let removed = if remaining.is_negative() {
1616 -magnitude
1617 } else {
1618 magnitude
1619 };
1620
1621 commission_removed.insert(index, removed);
1622 let next = remaining - removed;
1623 remaining_commission = (!next.is_zero()).then_some(next);
1624 }
1625 }
1626 }
1627
1628 self.reset_derived_state();
1629
1630 let mut closed_cycles_pnl: Option<Money> = None;
1631
1632 for (index, replay_event) in replay_events.iter().enumerate() {
1633 match replay_event {
1634 PositionReplayEvent::Filled(fill) => {
1635 if restatement_indexes.contains(&index) {
1636 self.apply_price_restatement(fill, false)?;
1637 continue;
1638 }
1639
1640 let removed = quantity_removed
1641 .get(&index)
1642 .copied()
1643 .unwrap_or_else(|| Quantity::zero(fill.last_qty.precision));
1644 let effective_qty =
1645 quantity_sub(fill.last_qty, removed, fill.last_qty.precision)?;
1646
1647 let effective_commission =
1648 match (fill.commission, commission_removed.get(&index).copied()) {
1649 (Some(commission), Some(removed)) => Some(commission - removed),
1650 (commission, None) => commission,
1651 (None, Some(_)) => None,
1652 };
1653
1654 if effective_qty.is_zero() {
1655 if let Some(commission) =
1656 effective_commission.filter(|commission| !commission.is_zero())
1657 && let Some(realized_pnl) =
1658 self.apply_surviving_fill_commission(fill, commission)?
1659 {
1660 closed_cycles_pnl =
1661 Some(closed_cycles_pnl.map_or(Ok(realized_pnl), |total| {
1662 total
1663 .checked_add(realized_pnl)
1664 .ok_or_else(|| invalid("closed cycle PnL overflow"))
1665 })?);
1666 }
1667 continue;
1668 }
1669
1670 if self.side == PositionSide::Flat
1673 && let Some(realized_pnl) = self.realized_pnl
1674 {
1675 closed_cycles_pnl =
1676 Some(closed_cycles_pnl.map_or(Ok(realized_pnl), |total| {
1677 total
1678 .checked_add(realized_pnl)
1679 .ok_or_else(|| invalid("closed cycle PnL overflow"))
1680 })?);
1681 }
1682
1683 let mut effective = fill.clone();
1684 effective.last_qty = effective_qty;
1685 effective.commission = effective_commission;
1686 self.apply_fill(&effective, false)?;
1687 }
1688 PositionReplayEvent::Adjusted(adjustment) => {
1689 self.apply_adjustment_state(*adjustment, false)?;
1690 }
1691 PositionReplayEvent::InstrumentClosed(close) => {
1692 self.apply_instrument_close_state(*close, false)
1693 .map_err(|e| invalid(&e.to_string()))?;
1694 }
1695 }
1696 }
1697
1698 Ok(closed_cycles_pnl)
1699 }
1700
1701 fn apply_surviving_fill_commission(
1702 &mut self,
1703 fill: &OrderFilled,
1704 commission: Money,
1705 ) -> CorrectnessResult<Option<Money>> {
1706 let is_base_commission =
1707 self.is_currency_pair && self.base_currency == Some(commission.currency);
1708 let reopens =
1709 self.side == PositionSide::Flat && is_base_commission && !amount(commission)?.is_zero();
1710 let closed_cycles_pnl_previous = reopens.then_some(self.realized_pnl).flatten();
1711
1712 if reopens {
1713 self.reset_cycle(fill)?;
1714 }
1715
1716 self.commissions
1717 .entry(commission.currency)
1718 .or_insert(Money::zero(commission.currency));
1719 let total = self.commissions[&commission.currency]
1720 .checked_add(commission)
1721 .ok_or_else(|| invalid("position commission overflow"))?;
1722 self.commissions.insert(commission.currency, total);
1723 if commission.currency == self.settlement_currency {
1724 self.accumulate_pnl(-amount(commission)?)?;
1725 }
1726
1727 if is_base_commission {
1728 let previous_side = self.side;
1729 self.apply_base_commission_adjustment(fill, commission)?;
1730 self.finalize_surviving_base_commission(fill, previous_side)?;
1731 } else {
1732 self.ts_last = fill.ts_event;
1733 }
1734
1735 Ok(closed_cycles_pnl_previous)
1736 }
1737
1738 fn finalize_surviving_base_commission(
1739 &mut self,
1740 fill: &OrderFilled,
1741 previous_side: PositionSide,
1742 ) -> CorrectnessResult<()> {
1743 if self.side == PositionSide::Flat {
1744 if previous_side != PositionSide::Flat {
1745 self.closing_order_id = Some(fill.client_order_id);
1746 self.ts_closed = Some(fill.ts_event);
1747 self.duration_ns = fill.ts_event.saturating_duration_since(self.ts_opened);
1748 }
1749 } else {
1750 self.entry = match self.side {
1751 PositionSide::Long => OrderSide::Buy,
1752 PositionSide::Short => OrderSide::Sell,
1753 PositionSide::Flat => unreachable!(),
1754 };
1755
1756 if previous_side != PositionSide::Flat && previous_side != self.side {
1757 self.avg_px_open = price(fill.last_px)?;
1758 self.cost_open = mul_rounded(self.avg_px_open, self.signed_qty.abs())?;
1759 }
1760 }
1761
1762 Ok(())
1763 }
1764
1765 fn apply_base_commission_adjustment(
1766 &mut self,
1767 fill: &OrderFilled,
1768 commission: Money,
1769 ) -> CorrectnessResult<()> {
1770 let mut adjustment_id = fill.event_id.as_bytes();
1771 adjustment_id[15] ^= 0x01;
1772
1773 let adjustment = PositionAdjusted::new(
1774 self.trader_id,
1775 self.strategy_id,
1776 self.instrument_id,
1777 self.id,
1778 self.account_id,
1779 PositionAdjustmentType::Commission,
1780 Some(-amount(commission)?),
1781 None,
1782 Some(fill.client_order_id.inner()),
1783 UUID4::from_bytes(adjustment_id),
1784 fill.ts_event,
1785 fill.ts_init,
1786 );
1787 self.apply_adjustment_state(adjustment, false)
1788 }
1789
1790 fn latest_fill_voids(&self) -> Vec<&PositionFillVoid> {
1791 let mut latest = IndexMap::<(ClientOrderId, TradeId), &PositionFillVoid>::new();
1792 for correction in &self.fill_voids {
1793 latest.insert(
1794 (correction.event.client_order_id, correction.event.trade_id),
1795 correction,
1796 );
1797 }
1798 latest.into_values().collect()
1799 }
1800
1801 fn reset_derived_state(&mut self) {
1802 self.events.clear();
1803 self.adjustments.clear();
1804 self.trade_ids.clear();
1805 self.buy_qty = Quantity::zero(self.size_precision);
1806 self.sell_qty = Quantity::zero(self.size_precision);
1807 self.commissions.clear();
1808 self.signed_qty = Decimal::ZERO;
1809 self.quantity = Quantity::zero(self.size_precision);
1810 self.peak_qty = Quantity::zero(self.size_precision);
1811 self.side = PositionSide::Flat;
1812 self.closing_order_id = None;
1813 self.ts_opened = UnixNanos::default();
1814 self.ts_last = UnixNanos::default();
1815 self.ts_closed = Some(UnixNanos::default());
1816 self.duration_ns = DurationNanos::default();
1817 self.cost_open = Decimal::ZERO;
1818 self.cost_close = Decimal::ZERO;
1819 self.qty_closed = Decimal::ZERO;
1820 self.realized_pnl_remainder = Decimal::ZERO;
1821 self.avg_px_open = Decimal::ZERO;
1822 self.avg_px_close = None;
1823 self.realized_pnl = None;
1824 self.realized_return = 0.0;
1825 }
1826
1827 fn calculate_return(&self, open: Decimal, close: Decimal) -> f64 {
1828 if open.is_zero() {
1829 return 0.0;
1830 }
1831
1832 let open = open.as_f64();
1833 let close = close.as_f64();
1834 let points = if self.side == PositionSide::Short {
1835 open - close
1836 } else {
1837 close - open
1838 };
1839
1840 points / open
1841 }
1842
1843 pub fn try_realized_pnl(&self, last: Price, quantity: Quantity) -> CorrectnessResult<Money> {
1851 let qty = qty(quantity)?.min(self.signed_qty.abs());
1852 if qty.is_zero() {
1853 return Ok(Money::zero(self.settlement_currency));
1854 }
1855
1856 let cost = if qty == self.signed_qty.abs() {
1857 self.cost_open
1858 } else {
1859 proportion(self.cost_open, qty, self.signed_qty.abs())?
1860 };
1861
1862 let pnl = self.pnl_from_cost(
1863 price(last)?,
1864 qty,
1865 cost,
1866 self.signed_qty.abs(),
1867 self.cost_open,
1868 )?;
1869 let prior = self.realized_decimal()?;
1870 let total = add_pnl(prior, pnl, self.settlement_currency)?;
1871 let rounded = money(total, self.settlement_currency)?;
1872 rounded
1873 .checked_sub(
1874 self.realized_pnl
1875 .unwrap_or(Money::zero(self.settlement_currency)),
1876 )
1877 .ok_or_else(|| invalid("position PnL increment overflow"))
1878 }
1879
1880 pub fn try_calculate_pnl(
1886 &self,
1887 avg_px_open: Decimal,
1888 avg_px_close: Decimal,
1889 quantity: Quantity,
1890 ) -> CorrectnessResult<Money> {
1891 let qty = qty(quantity)?.min(self.signed_qty.abs());
1892 if qty.is_zero() {
1893 return Ok(Money::zero(self.settlement_currency));
1894 }
1895
1896 let cost = mul_rounded(avg_px_open, qty)?;
1897 let basis_cost = mul_rounded(avg_px_open, self.signed_qty.abs())?;
1898 let pnl = self.pnl_from_cost(avg_px_close, qty, cost, self.signed_qty.abs(), basis_cost)?;
1899 money(pnl, self.settlement_currency)
1900 }
1901
1902 pub fn calculate_pnl(
1908 &self,
1909 avg_px_open: Decimal,
1910 avg_px_close: Decimal,
1911 quantity: Quantity,
1912 ) -> CorrectnessResult<Money> {
1913 self.try_calculate_pnl(avg_px_open, avg_px_close, quantity)
1914 }
1915
1916 pub fn try_total_pnl(&self, last: Price) -> CorrectnessResult<Money> {
1922 let unrealized = self.unrealized_decimal(last)?;
1923 let realized = self.realized_decimal()?;
1924 let total = add_pnl(realized, unrealized, self.settlement_currency)?;
1925 money(total, self.settlement_currency)
1926 }
1927
1928 pub fn total_pnl(&self, last: Price) -> CorrectnessResult<Money> {
1934 self.try_total_pnl(last)
1935 }
1936
1937 pub fn try_unrealized_pnl(&self, last: Price) -> CorrectnessResult<Money> {
1943 money(self.unrealized_decimal(last)?, self.settlement_currency)
1944 }
1945
1946 fn unrealized_decimal(&self, last: Price) -> CorrectnessResult<Decimal> {
1947 if self.side == PositionSide::Flat {
1948 return Ok(Decimal::ZERO);
1949 }
1950
1951 self.pnl_from_cost(
1952 price(last)?,
1953 self.signed_qty.abs(),
1954 self.cost_open,
1955 self.signed_qty.abs(),
1956 self.cost_open,
1957 )
1958 }
1959
1960 pub fn unrealized_pnl(&self, last: Price) -> CorrectnessResult<Money> {
1966 self.try_unrealized_pnl(last)
1967 }
1968
1969 #[must_use]
1971 pub fn closing_order_side(&self) -> Option<OrderSide> {
1972 match self.side {
1973 PositionSide::Long => Some(OrderSide::Sell),
1974 PositionSide::Short => Some(OrderSide::Buy),
1975 PositionSide::Flat => None,
1976 }
1977 }
1978
1979 #[must_use]
1981 pub fn is_opposite_side(&self, side: OrderSide) -> bool {
1982 self.entry != side
1983 }
1984
1985 #[must_use]
1987 pub fn symbol(&self) -> Symbol {
1988 self.instrument_id.symbol
1989 }
1990
1991 #[must_use]
1993 pub fn venue(&self) -> Venue {
1994 self.instrument_id.venue
1995 }
1996
1997 #[must_use]
1999 pub fn event_count(&self) -> usize {
2000 self.events.len()
2001 }
2002
2003 #[must_use]
2005 pub fn client_order_ids(&self) -> Vec<ClientOrderId> {
2006 let mut result = self
2008 .events
2009 .iter()
2010 .map(|event| event.client_order_id)
2011 .collect::<AHashSet<ClientOrderId>>()
2012 .into_iter()
2013 .collect::<Vec<ClientOrderId>>();
2014 result.sort_unstable();
2015 result
2016 }
2017
2018 #[must_use]
2020 pub fn venue_order_ids(&self) -> Vec<VenueOrderId> {
2021 let mut result = self
2023 .events
2024 .iter()
2025 .map(|event| event.venue_order_id)
2026 .collect::<AHashSet<VenueOrderId>>()
2027 .into_iter()
2028 .collect::<Vec<VenueOrderId>>();
2029 result.sort_unstable();
2030 result
2031 }
2032
2033 #[must_use]
2035 pub fn trade_ids(&self) -> Vec<TradeId> {
2036 let mut result = self
2037 .events
2038 .iter()
2039 .map(|event| event.trade_id)
2040 .collect::<AHashSet<TradeId>>()
2041 .into_iter()
2042 .collect::<Vec<TradeId>>();
2043 result.sort_unstable();
2044 result
2045 }
2046
2047 pub fn try_notional_value(&self, last: Price) -> anyhow::Result<Money> {
2055 let currency = if self.is_inverse {
2056 self.base_currency.ok_or_else(|| {
2057 anyhow::anyhow!(
2058 "inverse position {} has no base currency",
2059 self.instrument_id
2060 )
2061 })?
2062 } else {
2063 self.settlement_currency
2064 };
2065
2066 notional(
2067 self.quantity,
2068 last,
2069 self.multiplier,
2070 self.instrument_class
2071 .divides_notional_by_price(self.is_inverse),
2072 currency,
2073 )
2074 .map_err(Into::into)
2075 }
2076
2077 #[must_use]
2083 pub fn notional_value(&self, last: Price) -> Money {
2084 self.try_notional_value(last)
2085 .expect("invalid notional value")
2086 }
2087
2088 #[must_use]
2090 pub fn last_event(&self) -> Option<OrderFilled> {
2091 self.events.last().cloned()
2092 }
2093
2094 #[must_use]
2096 pub fn last_trade_id(&self) -> Option<TradeId> {
2097 self.events.last().map(|e| e.trade_id)
2098 }
2099
2100 #[must_use]
2102 pub fn is_long(&self) -> bool {
2103 self.side == PositionSide::Long
2104 }
2105
2106 #[must_use]
2108 pub fn is_short(&self) -> bool {
2109 self.side == PositionSide::Short
2110 }
2111
2112 #[must_use]
2114 pub fn is_open(&self) -> bool {
2115 self.side != PositionSide::Flat && self.ts_closed.is_none()
2116 }
2117
2118 #[must_use]
2120 pub fn is_closed(&self) -> bool {
2121 self.side == PositionSide::Flat && self.ts_closed.is_some()
2122 }
2123
2124 #[must_use]
2129 pub fn is_settled(&self) -> bool {
2130 matches!(
2131 self.replay_events.last(),
2132 Some(PositionReplayEvent::InstrumentClosed(_))
2133 )
2134 }
2135
2136 #[must_use]
2142 pub fn requires_replay_state(&self) -> bool {
2143 !self.fill_voids.is_empty()
2145 || self.is_settled()
2146 || (self.replay_events.len() > self.events.len() && self.has_replay_price_restatement())
2147 || (self.id.is_virtual() && self.replay_events.len() > self.events.len())
2148 }
2149
2150 #[must_use]
2155 pub fn signed_decimal_qty(&self) -> Decimal {
2156 self.signed_qty
2157 }
2158
2159 #[must_use]
2161 pub fn commissions(&self) -> Vec<Money> {
2162 self.commissions.values().copied().collect()
2163 }
2164}
2165
2166impl PartialEq<Self> for Position {
2167 fn eq(&self, other: &Self) -> bool {
2168 self.id == other.id
2169 }
2170}
2171
2172impl Eq for Position {}
2173
2174impl Hash for Position {
2175 fn hash<H: Hasher>(&self, state: &mut H) {
2176 self.id.hash(state);
2177 }
2178}
2179
2180impl Display for Position {
2181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2182 let quantity_str = if self.quantity == Quantity::zero(self.size_precision) {
2183 String::new()
2184 } else {
2185 self.quantity.to_formatted_string() + " "
2186 };
2187
2188 write!(
2189 f,
2190 "Position({} {}{}, id={})",
2191 self.side, quantity_str, self.instrument_id, self.id
2192 )
2193 }
2194}
2195
2196fn invalid(message: &str) -> CorrectnessError {
2197 CorrectnessError::PredicateViolation {
2198 message: message.to_owned(),
2199 }
2200}
2201
2202pub(crate) fn from_raw(mut raw: i128, mut scale: u32) -> CorrectnessResult<Decimal> {
2203 if let Ok(value) = Decimal::try_from_i128_with_scale(raw, scale) {
2204 return Ok(value.normalize());
2205 }
2206
2207 while scale > 0 && raw % 10 == 0 {
2208 raw /= 10;
2209 scale -= 1;
2210 }
2211
2212 Decimal::try_from_i128_with_scale(raw, scale)
2213 .map_err(|_| invalid("position input exceeds exact Decimal representation"))
2214}
2215
2216#[allow(
2217 clippy::useless_conversion,
2218 reason = "raw integer widths vary between standard and high precision"
2219)]
2220pub(crate) fn price(value: Price) -> CorrectnessResult<Decimal> {
2221 from_raw(
2222 i128::from(value.raw),
2223 u32::from(FIXED_PRECISION.max(value.precision)),
2224 )
2225}
2226
2227#[allow(
2228 clippy::unnecessary_fallible_conversions,
2229 reason = "high precision quantities use u128 and require a checked signed conversion"
2230)]
2231pub(crate) fn qty(value: Quantity) -> CorrectnessResult<Decimal> {
2232 let raw = i128::try_from(value.raw)
2233 .map_err(|_| invalid("position quantity exceeds Decimal bounds"))?;
2234 from_raw(raw, u32::from(FIXED_PRECISION.max(value.precision)))
2235}
2236
2237#[allow(
2238 clippy::useless_conversion,
2239 reason = "raw integer widths vary between standard and high precision"
2240)]
2241pub(crate) fn amount(value: Money) -> CorrectnessResult<Decimal> {
2242 from_raw(
2243 i128::from(value.raw),
2244 u32::from(FIXED_PRECISION.max(value.currency.precision)),
2245 )
2246}
2247
2248pub(crate) fn quantity(value: Decimal, precision: u8) -> CorrectnessResult<Quantity> {
2249 let precision = precision.max(value.normalize().scale() as u8);
2250 let quantity = Quantity::from_decimal_dp(value, precision)?;
2251 if qty(quantity)? != value {
2252 return Err(invalid("position quantity loses instrument precision"));
2253 }
2254
2255 Ok(quantity)
2256}
2257
2258pub(crate) fn quantity_add(
2259 lhs: Quantity,
2260 rhs: Quantity,
2261 precision: u8,
2262) -> CorrectnessResult<Quantity> {
2263 if let Some(sum) = lhs.checked_add(rhs)
2264 && let Some(quantity) = quantity_native(sum, precision)
2265 {
2266 return Ok(quantity);
2267 }
2268
2269 quantity(add(qty(lhs)?, qty(rhs)?)?, precision)
2270}
2271
2272#[allow(clippy::useless_conversion, reason = "QuantityRaw is u64 or u128")]
2273pub(crate) fn quantity_sub(
2274 lhs: Quantity,
2275 rhs: Quantity,
2276 precision: u8,
2277) -> CorrectnessResult<Quantity> {
2278 if u128::from(lhs.raw) <= Decimal::MAX.mantissa() as u128
2279 && let Some(difference) = lhs.checked_sub(rhs)
2280 && let Some(quantity) = quantity_native(difference, precision)
2281 {
2282 return Ok(quantity);
2283 }
2284
2285 quantity(sub(qty(lhs)?, qty(rhs)?)?, precision)
2286}
2287
2288#[allow(clippy::useless_conversion, reason = "QuantityRaw is u64 or u128")]
2289fn quantity_native(value: Quantity, precision: u8) -> Option<Quantity> {
2290 if !raw_scales_match(value.precision, precision)
2291 || u128::from(value.raw) > Decimal::MAX.mantissa() as u128
2292 {
2293 return None;
2294 }
2295
2296 let step = QuantityRaw::pow(
2297 10,
2298 u32::from(FIXED_PRECISION.max(value.precision) - precision),
2299 );
2300
2301 if !value.raw.is_multiple_of(step) {
2302 return None;
2303 }
2304
2305 Quantity::from_raw_checked(value.raw, precision).ok()
2306}
2307
2308pub(crate) fn proportion(
2309 value: Decimal,
2310 numerator: Decimal,
2311 denominator: Decimal,
2312) -> CorrectnessResult<Decimal> {
2313 if denominator.is_zero() {
2314 return Err(invalid("position proportion has zero denominator"));
2315 }
2316
2317 if value.is_zero() || numerator.is_zero() {
2318 return Ok(Decimal::ZERO);
2319 }
2320
2321 if let Ok(product) = mul(value, numerator)
2322 && let Ok(result) = div(product, denominator)
2323 && sub(value, result).is_ok()
2324 {
2325 return Ok(result);
2326 }
2327
2328 let raw = U512::from(value.mantissa().unsigned_abs())
2329 * U512::from(numerator.mantissa().unsigned_abs());
2330 let divisor = U512::from(denominator.mantissa().unsigned_abs());
2331 let negative =
2332 value.is_sign_negative() ^ numerator.is_sign_negative() ^ denominator.is_sign_negative();
2333 for scale in (0..=Decimal::MAX_SCALE).rev() {
2334 let exponent = scale as i32 + denominator.scale() as i32
2335 - value.scale() as i32
2336 - numerator.scale() as i32;
2337 let ten = U512::from(10_u8);
2338 let (dividend, divisor) = if exponent >= 0 {
2339 (raw * ten.pow(U512::from(exponent as u32)), divisor)
2340 } else {
2341 (raw, divisor * ten.pow(U512::from(exponent.unsigned_abs())))
2342 };
2343
2344 let Some(result) = decimal_quotient(dividend, divisor, scale, negative) else {
2345 continue;
2346 };
2347
2348 if result.is_zero() {
2349 return Err(invalid("position proportional allocation underflow"));
2350 }
2351
2352 if sub(value, result).is_ok() {
2353 return Ok(result);
2354 }
2355 }
2356
2357 Err(invalid("position proportional allocation overflow"))
2358}
2359
2360fn decimal_quotient(dividend: U512, divisor: U512, scale: u32, negative: bool) -> Option<Decimal> {
2361 let mut quotient = dividend / divisor;
2362 let doubled = (dividend % divisor) * U512::from(2_u8);
2363 if doubled > divisor || (doubled == divisor && quotient % U512::from(2_u8) != U512::ZERO) {
2364 quotient += U512::ONE;
2365 }
2366
2367 let mantissa = u128::try_from(quotient).ok()?;
2368 let mantissa = i128::try_from(mantissa).ok()?;
2369 from_raw(if negative { -mantissa } else { mantissa }, scale).ok()
2370}
2371
2372pub(crate) fn mul_rounded(lhs: Decimal, rhs: Decimal) -> CorrectnessResult<Decimal> {
2373 let value = lhs
2374 .checked_mul(rhs)
2375 .ok_or_else(|| invalid("position proportional arithmetic overflow"))?;
2376
2377 if !lhs.is_zero() && !rhs.is_zero() && value.is_zero() {
2378 return Err(invalid("position proportional arithmetic underflow"));
2379 }
2380
2381 Ok(value.normalize())
2382}
2383
2384pub(crate) fn add(lhs: Decimal, rhs: Decimal) -> CorrectnessResult<Decimal> {
2385 let scale = lhs.scale().max(rhs.scale());
2386 let left_scale = 10_i128.pow(scale - lhs.scale());
2387 let right_scale = 10_i128.pow(scale - rhs.scale());
2388 if let Some(raw) = lhs.mantissa().checked_mul(left_scale).and_then(|left| {
2389 rhs.mantissa()
2390 .checked_mul(right_scale)
2391 .and_then(|right| left.checked_add(right))
2392 }) {
2393 return from_raw(raw, scale)
2394 .map_err(|e| invalid(&format!("position exact sum {lhs} + {rhs}: {e}")));
2395 }
2396
2397 let left = U256::from(lhs.mantissa().unsigned_abs()) * U256::from(left_scale as u128);
2398 let right = U256::from(rhs.mantissa().unsigned_abs()) * U256::from(right_scale as u128);
2399 let (raw, negative) = if lhs.is_sign_negative() == rhs.is_sign_negative() {
2400 (left + right, lhs.is_sign_negative())
2401 } else if left >= right {
2402 (left - right, lhs.is_sign_negative())
2403 } else {
2404 (right - left, rhs.is_sign_negative())
2405 };
2406
2407 from_wide(raw, scale, negative)
2408}
2409
2410pub(crate) fn add_rounded(lhs: Decimal, rhs: Decimal) -> CorrectnessResult<Decimal> {
2411 let result = lhs
2412 .checked_add(rhs)
2413 .ok_or_else(|| invalid("position Decimal addition overflow"))?;
2414
2415 if (!rhs.is_zero() && result == lhs) || (!lhs.is_zero() && result == rhs) {
2416 return Err(invalid(
2417 "position Decimal addition loses the entire increment",
2418 ));
2419 }
2420
2421 Ok(result)
2422}
2423
2424fn add_pnl(lhs: Decimal, rhs: Decimal, currency: Currency) -> CorrectnessResult<Decimal> {
2425 let result = lhs
2426 .checked_add(rhs)
2427 .ok_or_else(|| invalid("position Decimal addition overflow"))?;
2428
2429 let lost = if result == lhs {
2430 rhs
2431 } else if result == rhs {
2432 lhs
2433 } else {
2434 Decimal::ZERO
2435 };
2436
2437 if !lost.is_zero() && lost.abs() >= amount(Money::from_raw_checked(1, currency)?)? {
2438 return Err(invalid(
2439 "position PnL addition loses at least one native Money unit",
2440 ));
2441 }
2442
2443 Ok(result)
2444}
2445
2446pub(crate) fn sub(lhs: Decimal, rhs: Decimal) -> CorrectnessResult<Decimal> {
2447 add(lhs, -rhs)
2448}
2449
2450pub(crate) fn mul(lhs: Decimal, rhs: Decimal) -> CorrectnessResult<Decimal> {
2451 let scale = lhs.scale() + rhs.scale();
2452 if let Some(raw) = lhs.mantissa().checked_mul(rhs.mantissa()) {
2453 return from_raw(raw, scale);
2454 }
2455
2456 let raw = U256::from(lhs.mantissa().unsigned_abs()) * U256::from(rhs.mantissa().unsigned_abs());
2457 from_wide(raw, scale, lhs.is_sign_negative() != rhs.is_sign_negative())
2458}
2459
2460fn from_wide(mut raw: U256, mut scale: u32, negative: bool) -> CorrectnessResult<Decimal> {
2461 let ten = U256::from(10_u8);
2462 while scale > 0 && raw % ten == U256::ZERO {
2463 raw /= ten;
2464 scale -= 1;
2465 }
2466
2467 let raw: u128 = raw
2468 .try_into()
2469 .map_err(|_| invalid("position Decimal arithmetic overflow"))?;
2470 let signed =
2471 i128::try_from(raw).map_err(|_| invalid("position Decimal arithmetic overflow"))?;
2472 from_raw(if negative { -signed } else { signed }, scale)
2473}
2474
2475pub(crate) fn div(lhs: Decimal, rhs: Decimal) -> CorrectnessResult<Decimal> {
2476 let value = lhs
2477 .checked_div(rhs)
2478 .ok_or_else(|| invalid("position Decimal division overflow or zero divisor"))?;
2479
2480 if !lhs.is_zero() && value.is_zero() {
2481 return Err(invalid("position Decimal division underflow"));
2482 }
2483
2484 Ok(value)
2485}
2486
2487pub(crate) fn money(amount: Decimal, currency: Currency) -> CorrectnessResult<Money> {
2488 Money::from_decimal_native(amount, currency)
2489}
2490
2491#[allow(
2492 clippy::useless_conversion,
2493 reason = "the raw width differs when high-precision is disabled"
2494)]
2495pub(crate) fn money_from_qty(value: Quantity, currency: Currency) -> CorrectnessResult<Money> {
2496 if raw_scales_match(value.precision, currency.precision)
2497 && u128::from(value.raw) <= Decimal::MAX.mantissa() as u128
2498 && let Ok(raw) = value.raw.try_into()
2499 {
2500 return Money::from_raw_checked(raw, currency);
2501 }
2502
2503 money(qty(value)?, currency)
2504}
2505
2506pub(crate) fn notional(
2507 quantity: Quantity,
2508 last: Price,
2509 multiplier: Quantity,
2510 divides_by_price: bool,
2511 currency: Currency,
2512) -> CorrectnessResult<Money> {
2513 let product = mul_rounded(qty(quantity)?, qty(multiplier)?)?;
2514 let price = price(last)?;
2515 let amount = if divides_by_price {
2516 if price <= Decimal::ZERO {
2517 return Err(invalid(
2518 "price must be positive for inverse notional valuation",
2519 ));
2520 }
2521
2522 div(product, price)?
2523 } else {
2524 mul_rounded(product, price)?
2525 };
2526
2527 money(amount, currency)
2528}
2529
2530#[allow(
2531 clippy::useless_conversion,
2532 clippy::unnecessary_fallible_conversions,
2533 reason = "raw integer widths vary between standard and high precision"
2534)]
2535pub(crate) fn split_commission_qty(
2536 commission: Money,
2537 closing_qty: Quantity,
2538 total_qty: Quantity,
2539) -> CorrectnessResult<(Money, Money)> {
2540 if closing_qty.non_zero()
2541 && closing_qty < total_qty
2542 && raw_scales_match(closing_qty.precision, total_qty.precision)
2543 && u128::from(total_qty.raw) <= Decimal::MAX.mantissa() as u128
2544 && let Some(magnitude) = checked_mul_div_raw(
2545 commission.raw.unsigned_abs(),
2546 closing_qty.raw,
2547 total_qty.raw,
2548 )
2549 {
2550 let raw = i128::try_from(magnitude)
2551 .map_err(|_| invalid("commission split exceeds raw bounds"))?;
2552 return commission_parts(
2553 commission,
2554 if commission.is_negative() { -raw } else { raw },
2555 );
2556 }
2557
2558 split_commission(commission, qty(closing_qty)?, qty(total_qty)?)
2559}
2560
2561#[allow(
2562 clippy::useless_conversion,
2563 reason = "raw integer widths vary between standard and high precision"
2564)]
2565pub(crate) fn split_commission(
2566 commission: Money,
2567 closing_qty: Decimal,
2568 total_qty: Decimal,
2569) -> CorrectnessResult<(Money, Money)> {
2570 if closing_qty <= Decimal::ZERO || closing_qty >= total_qty {
2571 return Err(invalid(
2572 "commission split requires a positive closing quantity below the total",
2573 ));
2574 }
2575
2576 let raw = i128::from(commission.raw);
2577 let mut numerator =
2578 U512::from(raw.unsigned_abs()) * U512::from(closing_qty.mantissa().unsigned_abs());
2579 let mut denominator = U512::from(total_qty.mantissa().unsigned_abs());
2580 let ten = U512::from(10_u8);
2581 if total_qty.scale() >= closing_qty.scale() {
2582 numerator *= ten.pow(U512::from(total_qty.scale() - closing_qty.scale()));
2583 } else {
2584 denominator *= ten.pow(U512::from(closing_qty.scale() - total_qty.scale()));
2585 }
2586
2587 let magnitude: u128 = (numerator / denominator)
2588 .try_into()
2589 .map_err(|_| invalid("commission split exceeds raw bounds"))?;
2590 let magnitude =
2591 i128::try_from(magnitude).map_err(|_| invalid("commission split exceeds raw bounds"))?;
2592 commission_parts(commission, if raw < 0 { -magnitude } else { magnitude })
2593}
2594
2595#[allow(clippy::useless_conversion, reason = "MoneyRaw is i64 or i128")]
2596fn commission_parts(commission: Money, closing_raw: i128) -> CorrectnessResult<(Money, Money)> {
2597 let closing_raw = closing_raw
2598 .try_into()
2599 .map_err(|_| invalid("commission split exceeds native Money bounds"))?;
2600 let closing = Money::from_raw(closing_raw, commission.currency);
2601 let opening = commission
2602 .checked_sub(closing)
2603 .ok_or_else(|| invalid("position commission allocation overflow"))?;
2604 Ok((closing, opening))
2605}
2606
2607#[must_use]
2619pub fn fold_net_position(legs: &[(Decimal, Decimal, u64)]) -> (Decimal, Decimal) {
2620 let mut sorted: Vec<&(Decimal, Decimal, u64)> =
2621 legs.iter().filter(|(qty, _, _)| !qty.is_zero()).collect();
2622 sorted.sort_by_key(|(_, _, ts_opened)| *ts_opened);
2623
2624 let mut net_signed_qty = Decimal::ZERO;
2625 let mut net_avg_px = Decimal::ZERO;
2626
2627 for &(p_qty, p_px, _) in sorted {
2628 if net_signed_qty.is_zero() {
2629 net_signed_qty = p_qty;
2630 net_avg_px = p_px;
2631 continue;
2632 }
2633
2634 let same_side = net_signed_qty.is_sign_negative() == p_qty.is_sign_negative();
2635 let new_net = net_signed_qty + p_qty;
2636 if same_side {
2637 let total_abs = net_signed_qty.abs() + p_qty.abs();
2638 net_avg_px = (net_signed_qty.abs() * net_avg_px + p_qty.abs() * p_px) / total_abs;
2639 net_signed_qty = new_net;
2640 } else if new_net.is_zero()
2641 || new_net.is_sign_negative() == net_signed_qty.is_sign_negative()
2642 {
2643 net_signed_qty = new_net;
2644 if new_net.is_zero() {
2645 net_avg_px = Decimal::ZERO;
2646 }
2647 } else {
2648 net_signed_qty = new_net;
2649 net_avg_px = p_px;
2650 }
2651 }
2652
2653 (net_signed_qty, net_avg_px)
2654}
2655
2656#[cfg(test)]
2657mod tests {
2658 use std::str::FromStr;
2659
2660 use ahash::AHashSet;
2661 use nautilus_core::{DurationNanos, UUID4, UnixNanos, correctness::CorrectnessError};
2662 use proptest::prelude::*;
2663 use rstest::rstest;
2664 use rust_decimal::{Decimal, prelude::ToPrimitive};
2665 use rust_decimal_macros::dec;
2666
2667 use super::*;
2668 #[cfg(feature = "defi")]
2669 use crate::enums::CurrencyType;
2670 use crate::{
2671 data::InstrumentClose,
2672 enums::{InstrumentCloseType, OrderSide, OrderType, PositionAdjustmentType, PositionSide},
2673 events::{
2674 OrderEventAny, OrderFillVoided, OrderFilled, PositionAdjusted, PositionSnapshot,
2675 order::spec::{OrderFillVoidedSpec, OrderFilledSpec},
2676 },
2677 identifiers::{
2678 AccountId, ClientOrderId, InstrumentId, PositionId, StrategyId, TradeId, VenueOrderId,
2679 stubs::uuid4,
2680 },
2681 instruments::{
2682 BinaryOption, CryptoFuture, CryptoOption, CryptoPerpetual, CurrencyPair, Instrument,
2683 InstrumentAny, stubs::*,
2684 },
2685 orders::{Order, builder::OrderTestBuilder, stubs::TestOrderEventStubs},
2686 stubs::*,
2687 types::{
2688 Currency, Money, Price, Quantity,
2689 money::{MONEY_RAW_MAX, MoneyRaw},
2690 },
2691 };
2692
2693 fn native_money(value: Decimal, currency: Currency) -> Money {
2694 let value = value.round_dp_with_strategy(
2695 u32::from(crate::types::fixed::FIXED_PRECISION),
2696 rust_decimal::RoundingStrategy::MidpointNearestEven,
2697 );
2698 Money::from_decimal_exact(value, currency).unwrap()
2699 }
2700
2701 #[rstest]
2702 fn test_decimal_intermediate_price_restatement_after_third_reduction(audusd_sim: CurrencyPair) {
2703 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2704 let opening = OrderFilledSpec::builder()
2705 .instrument_id(instrument.id())
2706 .position_id(PositionId::from("P-THIRD-RESTATE"))
2707 .last_qty(Quantity::from(100_000))
2708 .last_px(Price::from("1.00001"))
2709 .commission(Money::zero(Currency::USD()))
2710 .build();
2711 let mut position = Position::new(&instrument, opening.clone());
2712 let mut addition = opening.clone();
2713 addition.trade_id = TradeId::from("T-ADD");
2714 addition.event_id = UUID4::new();
2715 addition.last_qty = Quantity::from(200_000);
2716 addition.last_px = Price::from("1.00002");
2717 position.try_apply(&addition).unwrap();
2718 let mut reduction = opening.clone();
2719 reduction.trade_id = TradeId::from("T-REDUCE");
2720 reduction.event_id = UUID4::new();
2721 reduction.order_side = OrderSide::Sell;
2722 reduction.last_px = Price::from("1.00004");
2723 position.try_apply(&reduction).unwrap();
2724 let realized_before = position.realized_pnl;
2725 let mut restatement = opening;
2726 restatement.event_id = UUID4::new();
2727 restatement.last_px = Price::from("1.00003");
2728
2729 position.try_apply(&restatement).unwrap();
2730
2731 assert_eq!(position.quantity, Quantity::from(200_000));
2732 assert_eq!(position.signed_qty, dec!(200000));
2733 assert_eq!(position.cost_open, dec!(200004.66666666666666666666666));
2734 assert_eq!(position.avg_px_open, dec!(1.0000233333333333333333333333));
2735 assert_eq!(position.realized_pnl, realized_before);
2736 assert_eq!(position.events.len(), 3);
2737 assert_eq!(position.replay_events.len(), 4);
2738 }
2739
2740 #[rstest]
2741 #[case(OrderSide::Buy, dec!(1))]
2742 #[case(OrderSide::Sell, dec!(-1))]
2743 fn test_decimal_intermediate_inverse_large_move(
2744 btcusd_bybit: CryptoPerpetual,
2745 #[case] side: OrderSide,
2746 #[case] sign: Decimal,
2747 ) {
2748 let instrument = InstrumentAny::CryptoPerpetual(btcusd_bybit);
2749 let opening = OrderFilledSpec::builder()
2750 .instrument_id(instrument.id())
2751 .position_id(PositionId::from("P-INVERSE-MOVE"))
2752 .order_side(side)
2753 .last_qty(Quantity::from(1))
2754 .last_px(Price::from("0.0625"))
2755 .commission(Money::zero(Currency::BTC()))
2756 .build();
2757 let mut position = Position::new(&instrument, opening.clone());
2758 let mut settled = position.clone();
2759 let mark = Price::from("0.15");
2760 let expected = native_money(sign * dec!(28) / dec!(3), Currency::BTC());
2761 let unrealized = position.try_unrealized_pnl(mark).unwrap();
2762 let realized = position.try_realized_pnl(mark, position.quantity).unwrap();
2763 let mut closing = opening;
2764 closing.trade_id = TradeId::from("T-CLOSE");
2765 closing.event_id = UUID4::new();
2766
2767 closing.order_side = if side == OrderSide::Buy {
2768 OrderSide::Sell
2769 } else {
2770 OrderSide::Buy
2771 };
2772
2773 closing.last_px = mark;
2774 position.try_apply(&closing).unwrap();
2775 let close = contract_expired(&instrument, "0.15");
2776 settled.apply_instrument_close(close).unwrap();
2777
2778 assert_eq!(unrealized, expected);
2779 assert_eq!(realized, expected);
2780 assert_eq!(position.realized_pnl, Some(expected));
2781 assert_eq!(settled.realized_pnl, Some(expected));
2782 assert_eq!(position.quantity, Quantity::from(0));
2783 assert_eq!(settled.quantity, Quantity::from(0));
2784 assert_eq!(position.side, PositionSide::Flat);
2785 assert!(settled.is_settled());
2786 }
2787
2788 #[rstest]
2789 fn test_position_accepts_pnl_after_break_even_residual(audusd_sim: CurrencyPair) {
2790 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2791 let opening = OrderFilledSpec::builder()
2792 .instrument_id(instrument.id())
2793 .position_id(PositionId::from("P-BREAK-EVEN"))
2794 .last_qty(Quantity::from(5))
2795 .last_px(Price::from("1.00000"))
2796 .commission(Money::zero(Currency::USD()))
2797 .build();
2798 let mut position = Position::new(&instrument, opening.clone());
2799
2800 for (trade_id, side, qty, px) in [
2801 ("T-ADD", OrderSide::Buy, 10, "0.60000"),
2802 ("T-REDUCE-1", OrderSide::Sell, 1, "0.70000"),
2803 ("T-REDUCE-2", OrderSide::Sell, 5, "0.74000"),
2804 ] {
2805 let fill = OrderFilled {
2806 trade_id: TradeId::from(trade_id),
2807 order_side: side,
2808 last_qty: Quantity::from(qty),
2809 last_px: Price::from(px),
2810 event_id: UUID4::new(),
2811 ..opening.clone()
2812 };
2813
2814 position.try_apply(&fill).unwrap();
2815 }
2816
2817 assert_eq!(position.realized_pnl, Some(Money::zero(Currency::USD())));
2818 assert_eq!(
2819 position.realized_pnl_remainder,
2820 dec!(0.0000000000000000000000000002)
2821 );
2822
2823 let closing = OrderFilled {
2824 trade_id: TradeId::from("T-CLOSE"),
2825 order_side: OrderSide::Sell,
2826 last_qty: Quantity::from(9),
2827 last_px: Price::from("2.00000"),
2828 event_id: UUID4::new(),
2829 ..opening
2830 };
2831
2832 let realized = position.try_realized_pnl(closing.last_px, closing.last_qty);
2833 let total = position.try_total_pnl(closing.last_px);
2834 let expected = Money::from("11.40 USD");
2835 let mut adjusted = position.clone();
2836
2837 let adjustment = PositionAdjusted::new(
2838 position.trader_id,
2839 position.strategy_id,
2840 position.instrument_id,
2841 position.id,
2842 position.account_id,
2843 PositionAdjustmentType::Funding,
2844 None,
2845 Some(expected),
2846 None,
2847 UUID4::new(),
2848 position.ts_last,
2849 position.ts_last,
2850 );
2851 let adjustment = adjusted
2852 .apply_adjustment(adjustment)
2853 .map(|()| adjusted.realized_pnl.unwrap());
2854 let closed = position
2855 .try_apply(&closing)
2856 .map(|()| position.realized_pnl.unwrap());
2857 let results: Vec<_> = [realized, total, adjustment, closed]
2858 .into_iter()
2859 .map(|result| result.map_err(|e| e.to_string()))
2860 .collect();
2861
2862 assert_eq!(results, vec![Ok(expected); 4]);
2863 assert_eq!(position.side, PositionSide::Flat);
2864 assert_eq!(position.quantity, Quantity::from(0));
2865 assert_eq!(position.cost_open, Decimal::ZERO);
2866 assert_eq!(position.realized_pnl_remainder, Decimal::ZERO);
2867 assert_eq!(adjusted.quantity, Quantity::from(9));
2868 assert_eq!(adjusted.realized_pnl_remainder, Decimal::ZERO);
2869 }
2870
2871 #[rstest]
2872 #[case::without_fee("0 USD", "10.60 USD")]
2873 #[case::with_fee("0.01 USD", "10.59 USD")]
2874 fn test_position_accepts_residual_pnl_on_close(
2875 audusd_sim: CurrencyPair,
2876 #[case] commission: &str,
2877 #[case] expected: &str,
2878 ) {
2879 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2880 let opening = OrderFilledSpec::builder()
2881 .instrument_id(instrument.id())
2882 .position_id(PositionId::from("P-RESIDUAL-CLOSE"))
2883 .last_qty(Quantity::from(5))
2884 .last_px(Price::from("1.00000"))
2885 .commission(Money::zero(Currency::USD()))
2886 .build();
2887 let mut position = Position::new(&instrument, opening.clone());
2888
2889 for (trade_id, side, qty, px) in [
2890 ("T-ADD", OrderSide::Buy, 10, "0.60000"),
2891 ("T-REDUCE-1", OrderSide::Sell, 1, "10.00000"),
2892 ("T-REDUCE-2", OrderSide::Sell, 5, "1.00000"),
2893 ("T-ADD-2", OrderSide::Buy, 1, "0.60000"),
2894 ] {
2895 let fill = OrderFilled {
2896 trade_id: TradeId::from(trade_id),
2897 order_side: side,
2898 last_qty: Quantity::from(qty),
2899 last_px: Price::from(px),
2900 event_id: UUID4::new(),
2901 ..opening.clone()
2902 };
2903
2904 position.try_apply(&fill).unwrap();
2905 }
2906
2907 let closing = OrderFilled {
2908 trade_id: TradeId::from("T-CLOSE"),
2909 order_side: OrderSide::Sell,
2910 last_qty: Quantity::from(10),
2911 last_px: Price::from("0.72000"),
2912 commission: Some(Money::from(commission)),
2913 event_id: UUID4::new(),
2914 ..opening
2915 };
2916
2917 let realized = position.try_realized_pnl(closing.last_px, closing.last_qty);
2918 let total = position.try_total_pnl(closing.last_px);
2919 let closed = position.try_apply(&closing);
2920
2921 assert_eq!(realized.unwrap(), Money::zero(Currency::USD()));
2922 assert_eq!(total.unwrap(), Money::from("10.60 USD"));
2923 closed.unwrap();
2924 assert_eq!(position.realized_pnl, Some(Money::from(expected)));
2925 assert_eq!(position.side, PositionSide::Flat);
2926 assert_eq!(position.quantity, Quantity::from(0));
2927 assert_eq!(position.cost_open, Decimal::ZERO);
2928 assert_eq!(position.realized_pnl_remainder, Decimal::ZERO);
2929 }
2930
2931 #[rstest]
2932 fn test_decimal_intermediate_restatement_after_two_reductions(audusd_sim: CurrencyPair) {
2933 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2934 let opening = OrderFilledSpec::builder()
2935 .instrument_id(instrument.id())
2936 .position_id(PositionId::from("P-REDUCED-LOTS"))
2937 .last_qty(Quantity::from("2.00"))
2938 .last_px(Price::from("12.00"))
2939 .commission(Money::zero(Currency::USD()))
2940 .build();
2941 let mut position = Position::new(&instrument, opening.clone());
2942 for (id, side, qty, price) in [
2943 ("T-ADD", OrderSide::Buy, "100.00", "10.00"),
2944 ("T-REDUCE-1", OrderSide::Sell, "3.00", "15.00"),
2945 ("T-REDUCE-2", OrderSide::Sell, "1.00", "15.00"),
2946 ] {
2947 let mut fill = opening.clone();
2948 fill.trade_id = TradeId::from(id);
2949 fill.event_id = UUID4::new();
2950 fill.order_side = side;
2951 fill.last_qty = Quantity::from(qty);
2952 fill.last_px = Price::from(price);
2953 position.try_apply(&fill).unwrap();
2954 }
2955
2956 let realized = position.realized_pnl;
2957 let mut restatement = opening;
2958 restatement.event_id = UUID4::new();
2959 restatement.last_px = Price::from("14.00");
2960
2961 position.try_apply(&restatement).unwrap();
2962
2963 assert_eq!(position.quantity, Quantity::from(98));
2964 assert_eq!(position.cost_open, dec!(987.686274509803921568627451));
2965 assert_eq!(position.avg_px_open, dec!(10.078431372549019607843137255));
2966 assert_eq!(position.realized_pnl, realized);
2967 assert_eq!(position.events.len(), 4);
2968 assert_eq!(position.replay_events.len(), 5);
2969 }
2970
2971 #[rstest]
2972 fn test_decimal_intermediate_adjustment_on_flat_position(audusd_sim: CurrencyPair) {
2973 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2974 let opening = OrderFilledSpec::builder()
2975 .instrument_id(instrument.id())
2976 .position_id(PositionId::from("P-ADJUST-REOPEN"))
2977 .last_qty(Quantity::from(100_000))
2978 .last_px(Price::from("1.00001"))
2979 .commission(Money::zero(Currency::USD()))
2980 .build();
2981 let mut position = Position::new(&instrument, opening);
2982 let mut addition = position.events[0].clone();
2983 addition.trade_id = TradeId::from("T-ADD-REOPEN");
2984 addition.event_id = UUID4::new();
2985 addition.last_qty = Quantity::from(23_457);
2986 addition.last_px = Price::from("1.00004");
2987 position.try_apply(&addition).unwrap();
2988 let qty = position.quantity;
2989 let average = position.avg_px_open;
2990 let mut closing = addition;
2991 closing.trade_id = TradeId::from("T-CLOSE-REOPEN");
2992 closing.event_id = UUID4::new();
2993 closing.order_side = OrderSide::Sell;
2994 closing.last_qty = qty;
2995 position.try_apply(&closing).unwrap();
2996
2997 let adjustment = PositionAdjusted::new(
2998 position.trader_id,
2999 position.strategy_id,
3000 position.instrument_id,
3001 position.id,
3002 position.account_id,
3003 PositionAdjustmentType::Commission,
3004 Some(qty.as_decimal()),
3005 None,
3006 None,
3007 UUID4::new(),
3008 position.ts_last,
3009 position.ts_last,
3010 );
3011
3012 position.apply_adjustment(adjustment).unwrap();
3013
3014 assert_eq!(position.quantity, qty);
3015 assert_eq!(position.signed_qty, dec!(123457));
3016 assert_eq!(position.side, PositionSide::Long);
3017 assert_eq!(position.avg_px_open, average);
3018 assert_eq!(position.cost_open, dec!(123458.93828));
3019 }
3020
3021 #[cfg(feature = "high-precision")]
3022 #[rstest]
3023 #[case(OrderSide::Buy)]
3024 #[case(OrderSide::Sell)]
3025 fn test_position_accepts_rounded_dex_fill_cost(
3026 audusd_sim: CurrencyPair,
3027 #[case] side: OrderSide,
3028 ) {
3029 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3030 let opening = OrderFilledSpec::builder()
3031 .instrument_id(instrument.id())
3032 .position_id(PositionId::from("P-DEX-COST"))
3033 .order_side(side)
3034 .last_qty(Quantity::from("0.0010000004030790"))
3035 .last_px(Price::from("1891.3472376376468003"))
3036 .commission(Money::zero(Currency::USD()))
3037 .build();
3038 let mut position = Position::new_checked(&instrument, opening.clone()).unwrap();
3039 let expected_cost = dec!(1.8913480000000000000450346181);
3040 let notional = position.try_notional_value(opening.last_px).unwrap();
3041 let opening_cost = position.cost_open;
3042 let mut addition = opening.clone();
3043 addition.trade_id = TradeId::from("T-DEX-ADD");
3044 addition.event_id = UUID4::new();
3045 position.try_apply(&addition).unwrap();
3046 let added_cost = position.cost_open;
3047 let mut settled = position.clone();
3048 let mut closing = opening;
3049 closing.trade_id = TradeId::from("T-DEX-REDUCE");
3050 closing.event_id = UUID4::new();
3051
3052 closing.order_side = if side == OrderSide::Buy {
3053 OrderSide::Sell
3054 } else {
3055 OrderSide::Buy
3056 };
3057
3058 position.try_apply(&closing).unwrap();
3059 let remaining_cost = position.cost_open;
3060 closing.trade_id = TradeId::from("T-DEX-CLOSE");
3061 closing.event_id = UUID4::new();
3062 position.try_apply(&closing).unwrap();
3063 let close = contract_expired(&instrument, "1891.3472376376468003");
3064 settled.apply_instrument_close(close).unwrap();
3065
3066 assert_eq!(opening_cost, expected_cost);
3067 assert_eq!(notional.as_decimal(), dec!(1.891348));
3068 assert_eq!(added_cost, dec!(3.7826960000000000000900692362));
3069 assert_eq!(remaining_cost, expected_cost);
3070 assert_eq!(position.cost_open, Decimal::ZERO);
3071 assert_eq!(position.cost_close, added_cost);
3072 assert_eq!(position.signed_qty, Decimal::ZERO);
3073 assert_eq!(position.realized_pnl, Some(Money::zero(Currency::USD())));
3074 assert_eq!(position.realized_pnl_remainder, Decimal::ZERO);
3075 assert_eq!(settled.cost_open, Decimal::ZERO);
3076 assert_eq!(settled.cost_close, added_cost);
3077 assert_eq!(settled.signed_qty, Decimal::ZERO);
3078 assert_eq!(settled.realized_pnl, position.realized_pnl);
3079 assert_eq!(settled.realized_pnl_remainder, Decimal::ZERO);
3080 }
3081
3082 #[rstest]
3083 fn test_decimal_intermediate_pnl_accepts_repeating_average(audusd_sim: CurrencyPair) {
3084 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3085 let opening = OrderFilledSpec::builder()
3086 .instrument_id(instrument.id())
3087 .position_id(PositionId::from("P-REPEATING-PNL"))
3088 .last_qty(Quantity::from(100_000))
3089 .last_px(Price::from("1.00001"))
3090 .commission(Money::zero(Currency::USD()))
3091 .build();
3092 let mut position = Position::new(&instrument, opening.clone());
3093 let mut addition = opening;
3094 addition.trade_id = TradeId::from("T-ADD");
3095 addition.event_id = UUID4::new();
3096 addition.last_qty = Quantity::from(23_457);
3097 addition.last_px = Price::from("1.00004");
3098 position.try_apply(&addition).unwrap();
3099
3100 let pnl = position.calculate_pnl(position.avg_px_open, dec!(1.00005), position.quantity);
3101 let unchanged = position.calculate_pnl(
3102 position.avg_px_open,
3103 position.avg_px_open,
3104 position.quantity,
3105 );
3106
3107 assert_eq!(position.avg_px_open, dec!(1.0000157000413099297731193857));
3108 assert_eq!(
3109 pnl.unwrap(),
3110 Money::from_decimal_exact(dec!(4.23457), Currency::USD()).unwrap()
3111 );
3112 assert_eq!(unchanged.unwrap(), Money::zero(Currency::USD()));
3113 }
3114
3115 #[rstest]
3116 #[case(OrderSide::Buy, "0.1234", "0.1235")]
3117 #[case(OrderSide::Sell, "0.1235", "0.1234")]
3118 fn test_position_preserves_micro_fees_and_profit(
3119 audusd_sim: CurrencyPair,
3120 #[case] side: OrderSide,
3121 #[case] opening_px: &str,
3122 #[case] closing_px: &str,
3123 ) {
3124 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3125 let opening = OrderFilledSpec::builder()
3126 .instrument_id(instrument.id())
3127 .position_id(PositionId::from("P-MICRO"))
3128 .order_side(side)
3129 .last_qty(Quantity::from("0.01"))
3130 .last_px(Price::from(opening_px))
3131 .commission(Money::from_decimal_exact(dec!(0.000001), Currency::USD()).unwrap())
3132 .build();
3133 let mut position = Position::new_checked(&instrument, opening.clone()).unwrap();
3134 assert_eq!(position.realized_pnl.unwrap().as_decimal(), dec!(-0.000001));
3135
3136 let closing = OrderFilledSpec::builder()
3137 .instrument_id(instrument.id())
3138 .position_id(position.id)
3139 .client_order_id(ClientOrderId::from("O-CLOSE-MICRO"))
3140 .trade_id(TradeId::from("T-CLOSE-MICRO"))
3141 .order_side(if side == OrderSide::Buy {
3142 OrderSide::Sell
3143 } else {
3144 OrderSide::Buy
3145 })
3146 .last_qty(Quantity::from("0.01"))
3147 .last_px(Price::from(closing_px))
3148 .commission(Money::zero(Currency::USD()))
3149 .build();
3150
3151 let mut zero_fee = opening;
3152 zero_fee.commission = Some(Money::zero(Currency::USD()));
3153 let mut without_fee = Position::new_checked(&instrument, zero_fee).unwrap();
3154 without_fee.try_apply(&closing).unwrap();
3155 position.try_apply(&closing).unwrap();
3156 assert_eq!(
3157 without_fee.realized_pnl.unwrap().as_decimal(),
3158 dec!(0.000001)
3159 );
3160 assert_eq!(position.realized_pnl.unwrap().as_decimal(), Decimal::ZERO);
3161 assert_eq!(position.signed_qty, Decimal::ZERO);
3162 assert_eq!(
3163 position.commissions(),
3164 vec![Money::from_decimal_exact(dec!(0.000001), Currency::USD()).unwrap()]
3165 );
3166 }
3167
3168 #[rstest]
3169 fn test_position_commission_overflow_preserves_all_state(audusd_sim: CurrencyPair) {
3170 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3171 let opening = OrderFilledSpec::builder()
3172 .instrument_id(instrument.id())
3173 .position_id(PositionId::from("P-OVERFLOW"))
3174 .order_side(OrderSide::Buy)
3175 .last_qty(Quantity::from(1))
3176 .last_px(Price::from("1"))
3177 .commission(Money::from_raw(
3178 crate::types::money::MONEY_RAW_MAX,
3179 Currency::USD(),
3180 ))
3181 .build();
3182 let mut position = Position::new_checked(&instrument, opening.clone()).unwrap();
3183 let before = serde_json::to_value(&position).unwrap();
3184 let mut next = opening;
3185 next.event_id = UUID4::new();
3186 next.trade_id = TradeId::from("T-OVERFLOW");
3187 next.last_qty = Quantity::from(2);
3188 next.last_px = Price::from("3");
3189 next.commission = Some(Money::from_decimal_exact(dec!(0.000001), Currency::USD()).unwrap());
3190 let result = position.try_apply(&next);
3191 assert_eq!(
3192 result.unwrap_err().to_string(),
3193 "position commission overflow"
3194 );
3195 assert_eq!(serde_json::to_value(&position).unwrap(), before);
3196 }
3197
3198 #[rstest]
3199 fn test_position_restoration_preserves_basis_and_fractional_pnl(audusd_sim: CurrencyPair) {
3200 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3201 let mut fill = OrderFilledSpec::builder()
3202 .instrument_id(instrument.id())
3203 .position_id(PositionId::from("P-RESTORE"))
3204 .order_side(OrderSide::Buy)
3205 .last_qty(Quantity::from(3))
3206 .last_px(Price::from("0.1"))
3207 .commission(Money::zero(Currency::USD()))
3208 .build();
3209 let mut position = Position::new_checked(&instrument, fill.clone()).unwrap();
3210 fill.event_id = UUID4::new();
3211 fill.trade_id = TradeId::from("T-ADD");
3212 fill.last_qty = Quantity::from(4);
3213 fill.last_px = Price::from("0.2");
3214 position.try_apply(&fill).unwrap();
3215 fill.event_id = UUID4::new();
3216 fill.trade_id = TradeId::from("T-REDUCE");
3217 fill.order_side = OrderSide::Sell;
3218 fill.last_qty = Quantity::from(1);
3219 fill.last_px = Price::from("0.3");
3220 position.try_apply(&fill).unwrap();
3221 let encoded = serde_json::to_string(&position).unwrap();
3222 let mut restored: Position = serde_json::from_str(&encoded).unwrap();
3223 fill.event_id = UUID4::new();
3224 fill.trade_id = TradeId::from("T-CLOSE");
3225 fill.last_qty = Quantity::from(6);
3226 position.try_apply(&fill).unwrap();
3227 restored.try_apply(&fill).unwrap();
3228 assert_eq!(position.realized_pnl.unwrap().as_decimal(), dec!(1));
3229 assert_eq!(position.cost_open, Decimal::ZERO);
3230 assert_eq!(position.realized_pnl_remainder, Decimal::ZERO);
3231 assert_eq!(
3232 serde_json::to_value(restored).unwrap(),
3233 serde_json::to_value(position).unwrap()
3234 );
3235 }
3236
3237 #[rstest]
3238 fn test_same_trade_id_new_price_restates_position_average(audusd_sim: CurrencyPair) {
3239 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3240 let position_id = PositionId::from("P-RESTATE");
3241 let trade_id = TradeId::from("T-1");
3242 let opening = OrderFilledSpec::builder()
3243 .instrument_id(instrument.id())
3244 .trade_id(trade_id)
3245 .order_side(OrderSide::Buy)
3246 .last_qty(Quantity::from(2))
3247 .last_px(Price::from("12"))
3248 .position_id(position_id)
3249 .build();
3250 let restatement = OrderFilledSpec::builder()
3251 .instrument_id(instrument.id())
3252 .trade_id(trade_id)
3253 .order_side(OrderSide::Buy)
3254 .last_qty(Quantity::from(2))
3255 .last_px(Price::from("9.47"))
3256 .position_id(position_id)
3257 .event_id(UUID4::new())
3258 .build();
3259 let mut position = Position::new(&instrument, opening);
3260 position.apply(&restatement);
3261
3262 assert_eq!(position.quantity.as_decimal(), Decimal::from(2));
3263 assert_eq!(position.side, PositionSide::Long);
3264 assert_eq!(position.avg_px_open, Price::from("9.47").as_decimal());
3265 assert_eq!(position.events[0].last_px, Price::from("12"));
3266
3267 let voided = OrderFillVoidedSpec::builder()
3268 .instrument_id(instrument.id())
3269 .trade_id(trade_id)
3270 .voided_qty(Quantity::from(1))
3271 .last_px(Price::from("12"))
3272 .order_side(OrderSide::Buy)
3273 .position_id(position_id)
3274 .build();
3275 position
3276 .apply_fill_void(voided, Quantity::from(1), None)
3277 .unwrap();
3278
3279 assert_eq!(position.quantity.as_decimal(), Decimal::from(1));
3280 assert_eq!(position.avg_px_open, Price::from("9.47").as_decimal());
3281 assert_eq!(position.events[0].last_px, Price::from("12"));
3282 assert!(position.requires_replay_state());
3283 }
3284
3285 #[rstest]
3286 fn test_price_restatement_of_closing_fill_keeps_open_average(audusd_sim: CurrencyPair) {
3287 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3288 let position_id = PositionId::from("P-CLOSE-RESTATE");
3289 let opening = OrderFilledSpec::builder()
3290 .instrument_id(instrument.id())
3291 .trade_id(TradeId::from("T-OPEN"))
3292 .order_side(OrderSide::Buy)
3293 .last_qty(Quantity::from(2))
3294 .last_px(Price::from("12"))
3295 .position_id(position_id)
3296 .build();
3297 let closing = OrderFilledSpec::builder()
3298 .instrument_id(instrument.id())
3299 .trade_id(TradeId::from("T-CLOSE"))
3300 .order_side(OrderSide::Sell)
3301 .last_qty(Quantity::from(1))
3302 .last_px(Price::from("15"))
3303 .position_id(position_id)
3304 .event_id(UUID4::new())
3305 .build();
3306 let restatement = OrderFilledSpec::builder()
3307 .instrument_id(instrument.id())
3308 .trade_id(TradeId::from("T-CLOSE"))
3309 .order_side(OrderSide::Sell)
3310 .last_qty(Quantity::from(1))
3311 .last_px(Price::from("12.75"))
3312 .position_id(position_id)
3313 .event_id(UUID4::new())
3314 .build();
3315 let mut position = Position::new(&instrument, opening);
3316 position.apply(&closing);
3317 position.apply(&restatement);
3318
3319 assert_eq!(position.quantity.as_decimal(), Decimal::from(1));
3320 assert_eq!(position.side, PositionSide::Long);
3321 assert_eq!(position.avg_px_open, Price::from("12").as_decimal());
3322 assert_eq!(position.avg_px_close, Some(Price::from("15").as_decimal()));
3323 assert_eq!(
3324 position.realized_pnl.map(|pnl| pnl.as_decimal()),
3325 Some(dec!(3))
3326 );
3327 assert_eq!(position.events[1].last_px, Price::from("15"));
3328 }
3329
3330 #[rstest]
3331 fn test_restatement_of_reversal_fill_keeps_flipped_average(audusd_sim: CurrencyPair) {
3332 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3333 let position_id = PositionId::from("P-REVERSAL-RESTATE");
3334 let opening = OrderFilledSpec::builder()
3335 .instrument_id(instrument.id())
3336 .trade_id(TradeId::from("T-OPEN"))
3337 .order_side(OrderSide::Buy)
3338 .last_qty(Quantity::from(2))
3339 .last_px(Price::from("12"))
3340 .position_id(position_id)
3341 .build();
3342 let reversal = OrderFilledSpec::builder()
3343 .instrument_id(instrument.id())
3344 .trade_id(TradeId::from("T-REVERSE"))
3345 .order_side(OrderSide::Sell)
3346 .last_qty(Quantity::from(3))
3347 .last_px(Price::from("10"))
3348 .position_id(position_id)
3349 .event_id(UUID4::new())
3350 .build();
3351 let restatement = OrderFilledSpec::builder()
3352 .instrument_id(instrument.id())
3353 .trade_id(TradeId::from("T-REVERSE"))
3354 .order_side(OrderSide::Sell)
3355 .last_qty(Quantity::from(3))
3356 .last_px(Price::from("9"))
3357 .position_id(position_id)
3358 .event_id(UUID4::new())
3359 .build();
3360 let mut position = Position::new(&instrument, opening);
3361 position.apply(&reversal);
3362 position.apply(&restatement);
3363
3364 assert_eq!(position.side, PositionSide::Short);
3365 assert_eq!(position.quantity.as_decimal(), Decimal::from(1));
3366 assert_eq!(position.avg_px_open, Price::from("10").as_decimal());
3367 }
3368
3369 #[rstest]
3370 fn test_restatement_share_excludes_reversal_closing_quantity(audusd_sim: CurrencyPair) {
3371 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3372 let position_id = PositionId::from("P-REVERSAL-SHARE");
3373 let trade_id = TradeId::from("T-RESTATE");
3374
3375 let mut position = Position::new(
3376 &instrument,
3377 OrderFilledSpec::builder()
3378 .instrument_id(instrument.id())
3379 .trade_id(TradeId::from("T-OPEN"))
3380 .order_side(OrderSide::Buy)
3381 .last_qty(Quantity::from(2))
3382 .last_px(Price::from("12"))
3383 .position_id(position_id)
3384 .build(),
3385 );
3386
3387 for (trade, side, qty, px) in [
3388 ("T-REVERSE", OrderSide::Sell, 3, "10"),
3389 (trade_id.as_str(), OrderSide::Sell, 1, "8"),
3390 ("T-CLOSE", OrderSide::Buy, 1, "9"),
3391 ] {
3392 position.apply(
3393 &OrderFilledSpec::builder()
3394 .instrument_id(instrument.id())
3395 .trade_id(TradeId::from(trade))
3396 .order_side(side)
3397 .last_qty(Quantity::from(qty))
3398 .last_px(Price::from(px))
3399 .position_id(position_id)
3400 .event_id(UUID4::new())
3401 .build(),
3402 );
3403 }
3404
3405 assert_eq!(position.side, PositionSide::Short);
3406 assert_eq!(position.quantity.as_decimal(), Decimal::from(1));
3407 assert_eq!(position.avg_px_open, Price::from("9").as_decimal());
3408
3409 position.apply(
3410 &OrderFilledSpec::builder()
3411 .instrument_id(instrument.id())
3412 .trade_id(trade_id)
3413 .order_side(OrderSide::Sell)
3414 .last_qty(Quantity::from(1))
3415 .last_px(Price::from("6"))
3416 .position_id(position_id)
3417 .event_id(UUID4::new())
3418 .build(),
3419 );
3420
3421 assert_eq!(position.avg_px_open, Price::from("8").as_decimal());
3422 }
3423
3424 #[rstest]
3425 fn test_price_restatement_uses_surviving_quantity(audusd_sim: CurrencyPair) {
3426 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3427 let position_id = PositionId::from("P-SURVIVING");
3428 let opening = OrderFilledSpec::builder()
3429 .instrument_id(instrument.id())
3430 .trade_id(TradeId::from("T-1"))
3431 .order_side(OrderSide::Buy)
3432 .last_qty(Quantity::from(2))
3433 .last_px(Price::from("12"))
3434 .position_id(position_id)
3435 .build();
3436 let later = OrderFilledSpec::builder()
3437 .instrument_id(instrument.id())
3438 .trade_id(TradeId::from("T-2"))
3439 .order_side(OrderSide::Buy)
3440 .last_qty(Quantity::from(1))
3441 .last_px(Price::from("10"))
3442 .position_id(position_id)
3443 .event_id(UUID4::new())
3444 .build();
3445 let restatement = OrderFilledSpec::builder()
3446 .instrument_id(instrument.id())
3447 .trade_id(TradeId::from("T-1"))
3448 .order_side(OrderSide::Buy)
3449 .last_qty(Quantity::from(1))
3450 .last_px(Price::from("9.47"))
3451 .position_id(position_id)
3452 .event_id(UUID4::new())
3453 .build();
3454 let mut position = Position::new(&instrument, opening);
3455 let voided = OrderFillVoidedSpec::builder()
3456 .instrument_id(instrument.id())
3457 .trade_id(TradeId::from("T-1"))
3458 .voided_qty(Quantity::from(1))
3459 .last_px(Price::from("12"))
3460 .order_side(OrderSide::Buy)
3461 .position_id(position_id)
3462 .build();
3463 position
3464 .apply_fill_void(voided, Quantity::from(1), None)
3465 .unwrap();
3466 position.apply(&later);
3467 position.apply(&restatement);
3468
3469 let expected = (dec!(11) * dec!(2) - dec!(12) + dec!(9.47)) / dec!(2);
3470 assert_eq!(position.quantity.as_decimal(), Decimal::from(2));
3471 assert_eq!(position.avg_px_open, expected);
3472 }
3473
3474 #[rstest]
3475 fn test_reapplying_same_price_restatement_is_idempotent(audusd_sim: CurrencyPair) {
3476 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3477 let position_id = PositionId::from("P-IDEMPOTENT");
3478 let trade_id = TradeId::from("T-1");
3479 let opening = OrderFilledSpec::builder()
3480 .instrument_id(instrument.id())
3481 .trade_id(trade_id)
3482 .order_side(OrderSide::Buy)
3483 .last_qty(Quantity::from(2))
3484 .last_px(Price::from("12"))
3485 .position_id(position_id)
3486 .build();
3487 let restatement = OrderFilledSpec::builder()
3488 .instrument_id(instrument.id())
3489 .trade_id(trade_id)
3490 .order_side(OrderSide::Buy)
3491 .last_qty(Quantity::from(2))
3492 .last_px(Price::from("9.47"))
3493 .position_id(position_id)
3494 .event_id(UUID4::new())
3495 .build();
3496 let mut position = Position::new(&instrument, opening);
3497 position.apply(&restatement);
3498 let replay_len = position.replay_events.len();
3499 position.apply(&restatement);
3500
3501 assert_eq!(position.avg_px_open, Price::from("9.47").as_decimal());
3502 assert_eq!(position.replay_events.len(), replay_len);
3503 }
3504
3505 #[rstest]
3506 fn test_purge_keeps_price_restatement_on_surviving_order(audusd_sim: CurrencyPair) {
3507 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3508 let position_id = PositionId::from("P-PURGE-RESTATE");
3509 let opening = OrderFilledSpec::builder()
3510 .client_order_id(ClientOrderId::from("O-KEEP"))
3511 .instrument_id(instrument.id())
3512 .trade_id(TradeId::from("T-KEEP"))
3513 .order_side(OrderSide::Buy)
3514 .last_qty(Quantity::from(2))
3515 .last_px(Price::from("12"))
3516 .position_id(position_id)
3517 .build();
3518 let other = OrderFilledSpec::builder()
3519 .client_order_id(ClientOrderId::from("O-DROP"))
3520 .instrument_id(instrument.id())
3521 .trade_id(TradeId::from("T-DROP"))
3522 .order_side(OrderSide::Buy)
3523 .last_qty(Quantity::from(1))
3524 .last_px(Price::from("10"))
3525 .position_id(position_id)
3526 .event_id(UUID4::new())
3527 .build();
3528 let restatement = OrderFilledSpec::builder()
3529 .client_order_id(ClientOrderId::from("O-KEEP"))
3530 .instrument_id(instrument.id())
3531 .trade_id(TradeId::from("T-KEEP"))
3532 .order_side(OrderSide::Buy)
3533 .last_qty(Quantity::from(2))
3534 .last_px(Price::from("9.47"))
3535 .position_id(position_id)
3536 .event_id(UUID4::new())
3537 .build();
3538 let mut position = Position::new(&instrument, opening);
3539 position.apply(&other);
3540 position.apply(&restatement);
3541 position
3542 .purge_events_for_order(ClientOrderId::from("O-DROP"))
3543 .unwrap();
3544
3545 assert_eq!(position.quantity.as_decimal(), Decimal::from(2));
3546 assert_eq!(position.avg_px_open, Price::from("9.47").as_decimal());
3547 }
3548
3549 #[rstest]
3550 fn test_price_restatement_excludes_closed_quantity(audusd_sim: CurrencyPair) {
3551 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3552 let position_id = PositionId::from("P-CLOSED-QTY");
3553 let opening = OrderFilledSpec::builder()
3554 .instrument_id(instrument.id())
3555 .trade_id(TradeId::from("T-1"))
3556 .order_side(OrderSide::Buy)
3557 .last_qty(Quantity::from(2))
3558 .last_px(Price::from("12"))
3559 .position_id(position_id)
3560 .build();
3561 let closing = OrderFilledSpec::builder()
3562 .instrument_id(instrument.id())
3563 .trade_id(TradeId::from("T-CLOSE"))
3564 .order_side(OrderSide::Sell)
3565 .last_qty(Quantity::from(1))
3566 .last_px(Price::from("15"))
3567 .position_id(position_id)
3568 .event_id(UUID4::new())
3569 .build();
3570 let later = OrderFilledSpec::builder()
3571 .instrument_id(instrument.id())
3572 .trade_id(TradeId::from("T-2"))
3573 .order_side(OrderSide::Buy)
3574 .last_qty(Quantity::from(1))
3575 .last_px(Price::from("10"))
3576 .position_id(position_id)
3577 .event_id(UUID4::new())
3578 .build();
3579 let restatement = OrderFilledSpec::builder()
3580 .instrument_id(instrument.id())
3581 .trade_id(TradeId::from("T-1"))
3582 .order_side(OrderSide::Buy)
3583 .last_qty(Quantity::from(2))
3584 .last_px(Price::from("9.47"))
3585 .position_id(position_id)
3586 .event_id(UUID4::new())
3587 .build();
3588 let mut position = Position::new(&instrument, opening);
3589 position.apply(&closing);
3590 position.apply(&later);
3591 position.apply(&restatement);
3592
3593 let expected = (dec!(11) * dec!(2) - dec!(12) + dec!(9.47)) / dec!(2);
3594 assert_eq!(position.quantity.as_decimal(), Decimal::from(2));
3595 assert_eq!(position.avg_px_open, expected);
3596 }
3597
3598 #[rstest]
3599 fn test_price_restatement_after_close_across_two_lots_uses_average_cost(
3600 audusd_sim: CurrencyPair,
3601 ) {
3602 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3603 let position_id = PositionId::from("P-AVG-COST");
3604 let first = OrderFilledSpec::builder()
3605 .instrument_id(instrument.id())
3606 .trade_id(TradeId::from("T-1"))
3607 .order_side(OrderSide::Buy)
3608 .last_qty(Quantity::from(2))
3609 .last_px(Price::from("12"))
3610 .position_id(position_id)
3611 .build();
3612 let second = OrderFilledSpec::builder()
3613 .instrument_id(instrument.id())
3614 .trade_id(TradeId::from("T-2"))
3615 .order_side(OrderSide::Buy)
3616 .last_qty(Quantity::from(2))
3617 .last_px(Price::from("10"))
3618 .position_id(position_id)
3619 .event_id(UUID4::new())
3620 .build();
3621 let closing = OrderFilledSpec::builder()
3622 .instrument_id(instrument.id())
3623 .trade_id(TradeId::from("T-CLOSE"))
3624 .order_side(OrderSide::Sell)
3625 .last_qty(Quantity::from(2))
3626 .last_px(Price::from("15"))
3627 .position_id(position_id)
3628 .event_id(UUID4::new())
3629 .build();
3630 let restatement = OrderFilledSpec::builder()
3631 .instrument_id(instrument.id())
3632 .trade_id(TradeId::from("T-1"))
3633 .order_side(OrderSide::Buy)
3634 .last_qty(Quantity::from(2))
3635 .last_px(Price::from("9.47"))
3636 .position_id(position_id)
3637 .event_id(UUID4::new())
3638 .build();
3639 let mut position = Position::new(&instrument, first);
3640 position.apply(&second);
3641 position.apply(&closing);
3642 position.apply(&restatement);
3643
3644 let expected = (dec!(11) * dec!(2) - dec!(12) + dec!(9.47)) / dec!(2);
3645 assert_eq!(position.quantity.as_decimal(), Decimal::from(2));
3646 assert_eq!(position.avg_px_open, expected);
3647 }
3648
3649 #[rstest]
3650 fn test_purge_applies_restatement_before_later_close(audusd_sim: CurrencyPair) {
3651 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3652 let position_id = PositionId::from("P-PURGE-ORDER");
3653 let opening = OrderFilledSpec::builder()
3654 .client_order_id(ClientOrderId::from("O-KEEP"))
3655 .instrument_id(instrument.id())
3656 .trade_id(TradeId::from("T-KEEP"))
3657 .order_side(OrderSide::Buy)
3658 .last_qty(Quantity::from(2))
3659 .last_px(Price::from("12"))
3660 .position_id(position_id)
3661 .build();
3662 let restatement = OrderFilledSpec::builder()
3663 .client_order_id(ClientOrderId::from("O-KEEP"))
3664 .instrument_id(instrument.id())
3665 .trade_id(TradeId::from("T-KEEP"))
3666 .order_side(OrderSide::Buy)
3667 .last_qty(Quantity::from(2))
3668 .last_px(Price::from("9.47"))
3669 .position_id(position_id)
3670 .event_id(UUID4::new())
3671 .build();
3672 let closing = OrderFilledSpec::builder()
3673 .client_order_id(ClientOrderId::from("O-CLOSE"))
3674 .instrument_id(instrument.id())
3675 .trade_id(TradeId::from("T-CLOSE"))
3676 .order_side(OrderSide::Sell)
3677 .last_qty(Quantity::from(1))
3678 .last_px(Price::from("15"))
3679 .position_id(position_id)
3680 .event_id(UUID4::new())
3681 .build();
3682 let other = OrderFilledSpec::builder()
3683 .client_order_id(ClientOrderId::from("O-DROP"))
3684 .instrument_id(instrument.id())
3685 .trade_id(TradeId::from("T-DROP"))
3686 .order_side(OrderSide::Buy)
3687 .last_qty(Quantity::from(1))
3688 .last_px(Price::from("10"))
3689 .position_id(position_id)
3690 .event_id(UUID4::new())
3691 .build();
3692 let mut position = Position::new(&instrument, opening);
3693 position.apply(&restatement);
3694 position.apply(&closing);
3695 let pnl_before = position.realized_pnl;
3696 let avg_before = position.avg_px_open;
3697 position.apply(&other);
3698 position
3699 .purge_events_for_order(ClientOrderId::from("O-DROP"))
3700 .unwrap();
3701
3702 assert_eq!(position.realized_pnl, pnl_before);
3703 assert_eq!(position.avg_px_open, avg_before);
3704 assert_eq!(position.quantity.as_decimal(), Decimal::from(1));
3705 }
3706
3707 #[rstest]
3708 fn test_full_void_after_restatement_does_not_reopen(audusd_sim: CurrencyPair) {
3709 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3710 let position_id = PositionId::from("P-VOID-RESTATE");
3711 let trade_id = TradeId::from("T-1");
3712 let opening = OrderFilledSpec::builder()
3713 .instrument_id(instrument.id())
3714 .trade_id(trade_id)
3715 .order_side(OrderSide::Buy)
3716 .last_qty(Quantity::from(2))
3717 .last_px(Price::from("12"))
3718 .position_id(position_id)
3719 .build();
3720 let restatement = OrderFilledSpec::builder()
3721 .instrument_id(instrument.id())
3722 .trade_id(trade_id)
3723 .order_side(OrderSide::Buy)
3724 .last_qty(Quantity::from(2))
3725 .last_px(Price::from("9.47"))
3726 .position_id(position_id)
3727 .event_id(UUID4::new())
3728 .build();
3729 let mut position = Position::new(&instrument, opening);
3730 position.apply(&restatement);
3731 let voided = OrderFillVoidedSpec::builder()
3732 .instrument_id(instrument.id())
3733 .trade_id(trade_id)
3734 .voided_qty(Quantity::from(2))
3735 .last_px(Price::from("12"))
3736 .order_side(OrderSide::Buy)
3737 .position_id(position_id)
3738 .build();
3739 position
3740 .apply_fill_void(voided, Quantity::from(2), None)
3741 .unwrap();
3742
3743 assert_eq!(position.side, PositionSide::Flat);
3744 assert!(position.quantity.is_zero());
3745 }
3746
3747 #[rstest]
3748 fn test_return_to_original_price_then_full_void_stays_flat(audusd_sim: CurrencyPair) {
3749 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3750 let position_id = PositionId::from("P-PRICE-ROUNDTRIP");
3751 let trade_id = TradeId::from("T-1");
3752 let opening = OrderFilledSpec::builder()
3753 .instrument_id(instrument.id())
3754 .trade_id(trade_id)
3755 .order_side(OrderSide::Buy)
3756 .last_qty(Quantity::from(2))
3757 .last_px(Price::from("12"))
3758 .position_id(position_id)
3759 .build();
3760 let restated = OrderFilledSpec::builder()
3761 .instrument_id(instrument.id())
3762 .trade_id(trade_id)
3763 .order_side(OrderSide::Buy)
3764 .last_qty(Quantity::from(2))
3765 .last_px(Price::from("9.47"))
3766 .position_id(position_id)
3767 .event_id(UUID4::new())
3768 .build();
3769 let restored = OrderFilledSpec::builder()
3770 .instrument_id(instrument.id())
3771 .trade_id(trade_id)
3772 .order_side(OrderSide::Buy)
3773 .last_qty(Quantity::from(2))
3774 .last_px(Price::from("12"))
3775 .position_id(position_id)
3776 .event_id(UUID4::new())
3777 .build();
3778 let mut position = Position::new(&instrument, opening);
3779 position.apply(&restated);
3780 position.apply(&restored);
3781 let voided = OrderFillVoidedSpec::builder()
3782 .instrument_id(instrument.id())
3783 .trade_id(trade_id)
3784 .voided_qty(Quantity::from(2))
3785 .last_px(Price::from("12"))
3786 .order_side(OrderSide::Buy)
3787 .position_id(position_id)
3788 .build();
3789 position
3790 .apply_fill_void(voided, Quantity::from(2), None)
3791 .unwrap();
3792
3793 assert_eq!(position.side, PositionSide::Flat);
3794 assert!(position.quantity.is_zero());
3795 }
3796
3797 #[rstest]
3798 fn test_purge_keeps_voided_quantity_on_surviving_order(audusd_sim: CurrencyPair) {
3799 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3800 let position_id = PositionId::from("P-PURGE-VOID");
3801 let opening = OrderFilledSpec::builder()
3802 .client_order_id(ClientOrderId::from("O-KEEP"))
3803 .instrument_id(instrument.id())
3804 .trade_id(TradeId::from("T-KEEP"))
3805 .order_side(OrderSide::Buy)
3806 .last_qty(Quantity::from(2))
3807 .last_px(Price::from("12"))
3808 .position_id(position_id)
3809 .build();
3810 let other = OrderFilledSpec::builder()
3811 .client_order_id(ClientOrderId::from("O-DROP"))
3812 .instrument_id(instrument.id())
3813 .trade_id(TradeId::from("T-DROP"))
3814 .order_side(OrderSide::Buy)
3815 .last_qty(Quantity::from(1))
3816 .last_px(Price::from("10"))
3817 .position_id(position_id)
3818 .event_id(UUID4::new())
3819 .build();
3820 let mut position = Position::new(&instrument, opening);
3821 let voided = OrderFillVoidedSpec::builder()
3822 .client_order_id(ClientOrderId::from("O-KEEP"))
3823 .instrument_id(instrument.id())
3824 .trade_id(TradeId::from("T-KEEP"))
3825 .voided_qty(Quantity::from(1))
3826 .last_px(Price::from("12"))
3827 .order_side(OrderSide::Buy)
3828 .position_id(position_id)
3829 .build();
3830 position
3831 .apply_fill_void(voided, Quantity::from(1), None)
3832 .unwrap();
3833 position.apply(&other);
3834 position
3835 .purge_events_for_order(ClientOrderId::from("O-DROP"))
3836 .unwrap();
3837
3838 assert_eq!(position.quantity.as_decimal(), Decimal::from(1));
3839 }
3840
3841 #[rstest]
3842 fn test_position_long_display(stub_position_long: Position) {
3843 let display = format!("{stub_position_long}");
3844 assert_eq!(display, "Position(LONG 1 AUD/USD.SIM, id=1)");
3845 }
3846
3847 #[rstest]
3848 fn test_position_short_display(stub_position_short: Position) {
3849 let display = format!("{stub_position_short}");
3850 assert_eq!(display, "Position(SHORT 1 AUD/USD.SIM, id=1)");
3851 }
3852
3853 #[rstest]
3854 #[case::open(false)]
3855 #[case::closed(true)]
3856 fn test_clone_without_events_preserves_current_state(
3857 mut stub_position_long: Position,
3858 #[case] close: bool,
3859 ) {
3860 let adjustment = PositionAdjusted::new(
3861 stub_position_long.trader_id,
3862 stub_position_long.strategy_id,
3863 stub_position_long.instrument_id,
3864 stub_position_long.id,
3865 stub_position_long.account_id,
3866 PositionAdjustmentType::Funding,
3867 None,
3868 Some(Money::from_decimal(dec!(1.25), stub_position_long.settlement_currency).unwrap()),
3869 Some("clone-test".into()),
3870 uuid4(),
3871 UnixNanos::from(2),
3872 UnixNanos::from(2),
3873 );
3874 stub_position_long.apply_adjustment(adjustment).unwrap();
3875
3876 if close {
3877 let closing_fill = OrderFilledSpec::builder()
3878 .trader_id(stub_position_long.trader_id)
3879 .strategy_id(stub_position_long.strategy_id)
3880 .instrument_id(stub_position_long.instrument_id)
3881 .client_order_id(ClientOrderId::from("CLONE-CLOSE"))
3882 .venue_order_id(VenueOrderId::from("CLONE-CLOSE"))
3883 .account_id(stub_position_long.account_id)
3884 .trade_id(TradeId::from("CLONE-CLOSE"))
3885 .order_side(OrderSide::Sell)
3886 .order_type(OrderType::Market)
3887 .last_qty(stub_position_long.quantity)
3888 .last_px(Price::from("1.0012"))
3889 .currency(stub_position_long.settlement_currency)
3890 .position_id(stub_position_long.id)
3891 .ts_event(UnixNanos::from(3))
3892 .ts_init(UnixNanos::from(3))
3893 .build();
3894 stub_position_long.apply(&closing_fill);
3895 }
3896
3897 let source_fill = stub_position_long.events[0].clone();
3898 let fill_voided = matching_fill_void(&source_fill, source_fill.last_qty, None);
3899 stub_position_long.fill_voids.push(PositionFillVoid {
3900 event: fill_voided,
3901 voided_qty: source_fill.last_qty,
3902 commission_voided: source_fill.commission,
3903 });
3904
3905 let cloned = stub_position_long.clone_without_events();
3906 let mut expected = stub_position_long.clone();
3907 expected.events.clear();
3908 expected.adjustments.clear();
3909 expected.replay_events.clear();
3910 expected.fill_voids.clear();
3911 expected.trade_ids.clear();
3912
3913 assert!(!stub_position_long.events.is_empty());
3914 assert!(!stub_position_long.adjustments.is_empty());
3915 assert!(!stub_position_long.replay_events.is_empty());
3916 assert!(!stub_position_long.fill_voids.is_empty());
3917 assert!(!stub_position_long.trade_ids.is_empty());
3918 assert!(cloned.events.is_empty());
3919 assert!(cloned.adjustments.is_empty());
3920 assert!(cloned.replay_events.is_empty());
3921 assert!(cloned.fill_voids.is_empty());
3922 assert!(cloned.trade_ids.is_empty());
3923 assert_eq!(
3924 serde_json::to_value(cloned).unwrap(),
3925 serde_json::to_value(expected).unwrap()
3926 );
3927 }
3928
3929 #[rstest]
3930 fn test_clone_for_snapshot_matches_clone_then_clear(mut stub_position_long: Position) {
3931 let source_fill = stub_position_long.events[0].clone();
3932 stub_position_long.fill_voids.push(PositionFillVoid {
3933 event: matching_fill_void(&source_fill, source_fill.last_qty, None),
3934 voided_qty: source_fill.last_qty,
3935 commission_voided: source_fill.commission,
3936 });
3937 let mut expected = stub_position_long.clone();
3938 expected.replay_events.clear();
3939 expected.fill_voids.clear();
3940
3941 let snapshot = stub_position_long.clone_for_snapshot();
3942
3943 assert!(snapshot.replay_events.is_empty());
3944 assert!(snapshot.fill_voids.is_empty());
3945 assert_eq!(
3946 serde_json::to_vec(&snapshot).unwrap(),
3947 serde_json::to_vec(&expected).unwrap(),
3948 );
3949 }
3950
3951 #[rstest]
3952 fn test_new_checked_rejects_missing_position_id(audusd_sim: CurrencyPair) {
3953 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3954 let fill = OrderFilledSpec::builder()
3955 .instrument_id(instrument.id())
3956 .build();
3957
3958 let error = Position::new_checked(&instrument, fill).unwrap_err();
3959
3960 assert_eq!(
3961 error,
3962 CorrectnessError::PredicateViolation {
3963 message: "`fill.position_id` was None".to_string(),
3964 }
3965 );
3966 }
3967
3968 #[rstest]
3969 fn test_new_checked_rejects_instrument_mismatch(audusd_sim: CurrencyPair) {
3970 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3971 let fill = OrderFilledSpec::builder()
3972 .instrument_id(InstrumentId::from("GBP/USD.SIM"))
3973 .position_id(PositionId::from("P-1"))
3974 .build();
3975
3976 let error = Position::new_checked(&instrument, fill).unwrap_err();
3977
3978 assert_eq!(
3979 error,
3980 CorrectnessError::EqualityMismatch {
3981 lhs_param: "instrument.id()".to_string(),
3982 rhs_param: "fill.instrument_id".to_string(),
3983 lhs: "AUD/USD.SIM".to_string(),
3984 rhs: "GBP/USD.SIM".to_string(),
3985 type_name: "value",
3986 }
3987 );
3988 }
3989
3990 #[rstest]
3991 #[case::instrument_mismatch(
3992 "GBP/USD.SIM",
3993 Some("P-1"),
3994 "'self.instrument_id' value of AUD/USD.SIM was not equal to 'fill.instrument_id' value of GBP/USD.SIM"
3995 )]
3996 #[case::missing_position_id("AUD/USD.SIM", None, "`fill.position_id` was None")]
3997 #[case::position_mismatch(
3998 "AUD/USD.SIM",
3999 Some("P-2"),
4000 "'self.id' value of P-1 was not equal to 'fill.position_id' value of P-2"
4001 )]
4002 fn test_try_apply_rejects_invalid_fill_identity_without_mutation(
4003 #[case] fill_instrument_id: &str,
4004 #[case] fill_position_id: Option<&str>,
4005 #[case] expected_error: &str,
4006 audusd_sim: CurrencyPair,
4007 ) {
4008 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4009 let position_id = PositionId::from("P-1");
4010 let fill_open = OrderFilledSpec::builder()
4011 .instrument_id(instrument.id())
4012 .trade_id(TradeId::from("T-1"))
4013 .position_id(position_id)
4014 .build();
4015 let mut fill_invalid = OrderFilledSpec::builder()
4016 .instrument_id(InstrumentId::from(fill_instrument_id))
4017 .trade_id(TradeId::from("T-2"))
4018 .build();
4019 fill_invalid.position_id = fill_position_id.map(PositionId::from);
4020 let mut position = Position::new(&instrument, fill_open);
4021 let state_before = serde_json::to_value(&position).unwrap();
4022
4023 let error = position.try_apply(&fill_invalid).unwrap_err();
4024
4025 assert_eq!(error.to_string(), expected_error);
4026 assert_eq!(serde_json::to_value(&position).unwrap(), state_before);
4027 }
4028
4029 #[rstest]
4030 fn test_try_apply_rejects_duplicate_trade_without_mutation(audusd_sim: CurrencyPair) {
4031 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4032 let position_id = PositionId::from("P-1");
4033 let fill_open = OrderFilledSpec::builder()
4034 .instrument_id(instrument.id())
4035 .trade_id(TradeId::from("T-1"))
4036 .position_id(position_id)
4037 .build();
4038 let fill_duplicate = OrderFilledSpec::builder()
4039 .instrument_id(instrument.id())
4040 .client_order_id(ClientOrderId::from("O-2"))
4041 .trade_id(TradeId::from("T-1"))
4042 .position_id(position_id)
4043 .build();
4044 let mut position = Position::new(&instrument, fill_open);
4045 let state_before = serde_json::to_value(&position).unwrap();
4046
4047 let error = position.try_apply(&fill_duplicate).unwrap_err();
4048
4049 assert_eq!(
4050 error,
4051 CorrectnessError::PredicateViolation {
4052 message: "`fill.trade_id` already contained in `trade_ids`".to_string(),
4053 }
4054 );
4055 assert_eq!(serde_json::to_value(&position).unwrap(), state_before);
4056 }
4057
4058 #[cfg(feature = "defi")]
4059 #[rstest]
4060 #[case::price_17("1.00000000000000001", "100000", None)]
4061 #[case::price_18("1.000000000000000001", "100000", None)]
4062 #[case::quantity_18("1.00000", "1.000000000000000001", None)]
4063 #[case::settlement_commission_18("1.00000", "100000", Some(("USD", 18)))]
4064 #[case::base_commission_18("1.00000", "100000", Some(("AUD", 18)))]
4065 fn test_new_checked_preserves_high_precision_fill_values(
4066 #[case] last_px: &str,
4067 #[case] last_qty: &str,
4068 #[case] commission: Option<(&str, u8)>,
4069 audusd_sim: CurrencyPair,
4070 ) {
4071 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4072 let mut fill = OrderFilledSpec::builder()
4073 .instrument_id(instrument.id())
4074 .last_px(Price::from(last_px))
4075 .last_qty(Quantity::from(last_qty))
4076 .position_id(PositionId::from("P-1"))
4077 .build();
4078 fill.commission = commission.map(|(code, precision)| {
4079 let currency = Currency::new(code, precision, 0, code, CurrencyType::Fiat);
4080 Money::from_decimal(dec!(1), currency).unwrap()
4081 });
4082
4083 let position = Position::new_checked(&instrument, fill.clone()).unwrap();
4084
4085 let expected_qty = if commission.is_some_and(|(code, _)| code == "AUD") {
4086 dec!(99999)
4087 } else {
4088 last_qty.parse().unwrap()
4089 };
4090
4091 assert_eq!(position.avg_px_open, last_px.parse::<Decimal>().unwrap());
4092 assert_eq!(position.signed_qty, expected_qty);
4093 assert_eq!(position.quantity.as_decimal(), expected_qty);
4094 assert_eq!(
4095 position.commissions(),
4096 fill.commission.into_iter().collect::<Vec<_>>()
4097 );
4098 }
4099
4100 #[cfg(feature = "defi")]
4101 #[rstest]
4102 #[case::size_precision(18, 0, 2)]
4103 #[case::multiplier(0, 18, 2)]
4104 #[case::settlement_currency(0, 0, 18)]
4105 fn test_new_checked_accepts_high_precision_instrument(
4106 #[case] size_precision: u8,
4107 #[case] multiplier_precision: u8,
4108 #[case] quote_precision: u8,
4109 mut audusd_sim: CurrencyPair,
4110 ) {
4111 audusd_sim.size_precision = size_precision;
4112 audusd_sim.multiplier = Quantity::from_decimal_dp(dec!(1), multiplier_precision).unwrap();
4113 audusd_sim.quote_currency =
4114 Currency::new("USD", quote_precision, 840, "US dollar", CurrencyType::Fiat);
4115 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4116 let fill = OrderFilledSpec::builder()
4117 .instrument_id(instrument.id())
4118 .position_id(PositionId::from("P-1"))
4119 .build();
4120
4121 let position = Position::new_checked(&instrument, fill).unwrap();
4122
4123 assert_eq!(position.size_precision, size_precision);
4124 assert_eq!(position.multiplier.precision, multiplier_precision);
4125 assert_eq!(position.settlement_currency.precision, quote_precision);
4126 }
4127
4128 #[cfg(feature = "defi")]
4129 #[rstest]
4130 fn test_new_checked_preserves_sixteen_digit_inputs(audusd_sim: CurrencyPair) {
4131 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4132 let usd = Currency::new("USD", 16, 840, "US dollar", CurrencyType::Fiat);
4133 let mut fill = OrderFilledSpec::builder()
4134 .instrument_id(instrument.id())
4135 .last_px(Price::from("1.5000000000000000"))
4136 .last_qty(Quantity::from("2.0000000000000000"))
4137 .position_id(PositionId::from("P-1"))
4138 .build();
4139 fill.commission = Some(Money::from_decimal(dec!(0.25), usd).unwrap());
4140
4141 let position = Position::new_checked(&instrument, fill).unwrap();
4142
4143 assert_eq!(position.avg_px_open, dec!(1.5));
4144 assert_eq!(position.signed_qty, dec!(2.0));
4145 assert_eq!(position.quantity, Quantity::from("2.0000000000000000"));
4146 assert_eq!(position.realized_pnl, Some(Money::from("-0.25 USD")));
4147 }
4148
4149 #[cfg(feature = "defi")]
4150 #[rstest]
4151 fn test_new_checked_preserves_eighteen_digit_foreign_commission(audusd_sim: CurrencyPair) {
4152 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4153 let wei = Currency::new("WEI", 18, 0, "Wei", CurrencyType::Crypto);
4154 let commission = Money::from_decimal(dec!(0.000000000000000001), wei).unwrap();
4155 let mut fill = OrderFilledSpec::builder()
4156 .instrument_id(instrument.id())
4157 .position_id(PositionId::from("P-1"))
4158 .build();
4159 fill.commission = Some(commission);
4160
4161 let position = Position::new_checked(&instrument, fill).unwrap();
4162
4163 assert_eq!(position.commissions(), vec![commission]);
4164 assert_eq!(position.realized_pnl, Some(Money::from("0 USD")));
4165 }
4166
4167 #[cfg(feature = "defi")]
4168 #[rstest]
4169 #[case::price("price")]
4170 #[case::foreign_commission("foreign_commission")]
4171 fn test_try_apply_rejects_unrepresentable_input_without_mutation(
4172 audusd_sim: CurrencyPair,
4173 #[case] input: &str,
4174 ) {
4175 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4176 let position_id = PositionId::from("P-1");
4177 let fill_open = OrderFilledSpec::builder()
4178 .instrument_id(instrument.id())
4179 .trade_id(TradeId::from("T-1"))
4180 .position_id(position_id)
4181 .build();
4182 let mut fill_invalid = OrderFilledSpec::builder()
4183 .instrument_id(instrument.id())
4184 .trade_id(TradeId::from("T-2"))
4185 .position_id(position_id)
4186 .build();
4187
4188 match input {
4189 "price" => fill_invalid.last_px = Price::from("100000000000.000000000000000001"),
4190 "foreign_commission" => {
4191 fill_invalid.commission = Some(Money::from_raw(
4192 100_000_000_000_000_000_000_000_000_001,
4193 Currency::EUR(),
4194 ));
4195 }
4196 _ => unreachable!(),
4197 }
4198
4199 assert_eq!(
4200 Position::new_checked(&instrument, fill_invalid.clone())
4201 .unwrap_err()
4202 .to_string(),
4203 "position input exceeds exact Decimal representation",
4204 );
4205 let mut position = Position::new(&instrument, fill_open);
4206 let state_before = serde_json::to_value(&position).unwrap();
4207
4208 let error = position.try_apply(&fill_invalid).unwrap_err();
4209
4210 assert_eq!(
4211 error.to_string(),
4212 "position input exceeds exact Decimal representation"
4213 );
4214 assert_eq!(serde_json::to_value(&position).unwrap(), state_before);
4215 }
4216
4217 #[cfg(feature = "defi")]
4218 #[rstest]
4219 #[case::unchanged(|_: &mut Position| {})]
4220 #[case::buy_qty(
4221 |position: &mut Position| position.buy_qty = Quantity::from("1.000000000000000001")
4222 )]
4223 #[case::sell_qty(
4224 |position: &mut Position| position.sell_qty = Quantity::from("1.000000000000000001")
4225 )]
4226 #[case::event_fill(
4227 |position: &mut Position| position.events[0].last_px = Price::from("1.000000000000000001")
4228 )]
4229 #[case::replay_fill(
4230 |position: &mut Position| {
4231 let mut fill = position.events[0].clone();
4232 fill.last_qty = Quantity::from("1.00000000000000001");
4233 position.replay_events.push(PositionReplayEvent::Filled(fill));
4234 }
4235 )]
4236 #[case::replay_close(
4237 |position: &mut Position| {
4238 let close = InstrumentClose::new(
4239 position.instrument_id,
4240 Price::from("1.000000000000000001"),
4241 InstrumentCloseType::ContractExpired,
4242 UnixNanos::default(),
4243 UnixNanos::default(),
4244 );
4245 position.replay_events.push(PositionReplayEvent::InstrumentClosed(close));
4246 }
4247 )]
4248 #[case::realized_pnl(
4249 |position: &mut Position| {
4250 let usd = Currency::new("USD", 18, 840, "US dollar", CurrencyType::Fiat);
4251 position.realized_pnl = Some(Money::from_decimal(dec!(1), usd).unwrap());
4252 }
4253 )]
4254 fn test_check_accounting_accepts_high_precision_stored_values(
4255 #[case] mutate: fn(&mut Position),
4256 mut stub_position_long: Position,
4257 ) {
4258 mutate(&mut stub_position_long);
4259
4260 let result = stub_position_long.check_accounting();
4261
4262 assert_eq!(result, Ok(()));
4263 }
4264
4265 #[cfg(feature = "defi")]
4266 #[rstest]
4267 fn test_try_pnl_preserves_eighteen_digit_inputs(stub_position_long: Position) {
4268 let last = Price::from("1.000000000000000001");
4269 let quantity = Quantity::from("1.000000000000000001");
4270
4271 let unrealized = stub_position_long.try_unrealized_pnl(last).unwrap();
4272 let total = stub_position_long.try_total_pnl(last).unwrap();
4273 let calculated = stub_position_long
4274 .try_calculate_pnl(dec!(1.0), dec!(1.0), quantity)
4275 .unwrap();
4276
4277 assert_eq!(unrealized, Money::from_str_exact("-0.0002 USD").unwrap());
4278 assert_eq!(total, Money::from_str_exact("-2.0002 USD").unwrap());
4279 assert_eq!(calculated, Money::zero(Currency::USD()));
4280 }
4281
4282 #[rstest]
4283 #[should_panic(expected = "`fill.trade_id` already contained in `trade_ids`")]
4284 fn test_two_trades_with_same_trade_id_error(audusd_sim: CurrencyPair) {
4285 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
4286 let order1 = OrderTestBuilder::new(OrderType::Market)
4287 .client_order_id(ClientOrderId::from("O-1"))
4288 .instrument_id(audusd_sim.id())
4289 .side(OrderSide::Buy)
4290 .quantity(Quantity::from(100_000))
4291 .build();
4292 let order2 = OrderTestBuilder::new(OrderType::Market)
4293 .client_order_id(ClientOrderId::from("O-2"))
4294 .instrument_id(audusd_sim.id())
4295 .side(OrderSide::Buy)
4296 .quantity(Quantity::from(100_000))
4297 .build();
4298 let fill1 = TestOrderEventStubs::filled(
4299 &order1,
4300 &audusd_sim,
4301 Some(TradeId::new("1")),
4302 None,
4303 Some(Price::from("1.00001")),
4304 None,
4305 None,
4306 None,
4307 None,
4308 None,
4309 );
4310 let fill2 = TestOrderEventStubs::filled(
4311 &order2,
4312 &audusd_sim,
4313 Some(TradeId::new("1")),
4314 None,
4315 Some(Price::from("1.00002")),
4316 None,
4317 None,
4318 None,
4319 None,
4320 None,
4321 );
4322 let mut position = Position::new(&audusd_sim, fill1.into());
4323 position.apply(&fill2.into());
4324 }
4325
4326 #[rstest]
4327 #[case(false)]
4328 #[case(true)]
4329 fn test_historical_duplicate_trade_id_does_not_poison_fill_void_replay(
4330 #[case] causal_duplicate: bool,
4331 audusd_sim: CurrencyPair,
4332 ) {
4333 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4334 let position_id = PositionId::from("P-DUP");
4335 let fill_open = OrderFilledSpec::builder()
4336 .instrument_id(instrument.id())
4337 .client_order_id(ClientOrderId::from("O-1"))
4338 .trade_id(TradeId::from("T-1"))
4339 .order_side(OrderSide::Buy)
4340 .last_qty(Quantity::from(10))
4341 .last_px(Price::from("1.00000"))
4342 .currency(Currency::USD())
4343 .position_id(position_id)
4344 .ts_event(UnixNanos::from(1))
4345 .build();
4346 let fill_close = OrderFilledSpec::builder()
4347 .instrument_id(instrument.id())
4348 .client_order_id(ClientOrderId::from("O-2"))
4349 .trade_id(TradeId::from("T-2"))
4350 .order_side(OrderSide::Sell)
4351 .last_qty(Quantity::from(10))
4352 .last_px(Price::from("1.00010"))
4353 .currency(Currency::USD())
4354 .position_id(position_id)
4355 .ts_event(UnixNanos::from(2))
4356 .build();
4357 let mut fill_duplicate = OrderFilledSpec::builder()
4358 .instrument_id(instrument.id())
4359 .client_order_id(ClientOrderId::from("O-1"))
4360 .trade_id(TradeId::from("T-1"))
4361 .order_side(OrderSide::Buy)
4362 .last_qty(Quantity::from(10))
4363 .last_px(Price::from("1.00020"))
4364 .currency(Currency::USD())
4365 .position_id(position_id)
4366 .ts_event(UnixNanos::from(3))
4367 .build();
4368
4369 if causal_duplicate {
4370 fill_duplicate.causation_id = Some(fill_open.event_id);
4371 }
4372 let fill_reopen = OrderFilledSpec::builder()
4373 .instrument_id(instrument.id())
4374 .client_order_id(ClientOrderId::from("O-3"))
4375 .trade_id(TradeId::from("T-3"))
4376 .order_side(OrderSide::Buy)
4377 .last_qty(Quantity::from(5))
4378 .last_px(Price::from("1.00000"))
4379 .currency(Currency::USD())
4380 .position_id(position_id)
4381 .ts_event(UnixNanos::from(4))
4382 .build();
4383 let mut fill_duplicate_open = fill_duplicate.clone();
4384 fill_duplicate_open.event_id = uuid4();
4385 fill_duplicate_open.client_order_id = ClientOrderId::from("O-4");
4386 fill_duplicate_open.ts_event = UnixNanos::from(5);
4387 let fill_voided = matching_fill_void(&fill_close, Quantity::from(10), None);
4388 let mut position = Position::new(&instrument, fill_open.clone());
4389 position.try_apply(&fill_close).unwrap();
4390
4391 position.try_apply(&fill_duplicate).unwrap();
4392
4393 assert_eq!(position.side, PositionSide::Flat);
4394 assert_eq!(position.quantity, Quantity::from(0));
4395 assert_eq!(position.events, vec![fill_open.clone(), fill_close.clone()]);
4396 assert_eq!(position.replay_events.len(), 2);
4397 assert_eq!(position.trade_ids.len(), 2);
4398 assert!(position.trade_ids.contains(&TradeId::from("T-1")));
4399 assert!(position.trade_ids.contains(&TradeId::from("T-2")));
4400
4401 position.try_apply(&fill_reopen).unwrap();
4402 position.try_apply(&fill_duplicate_open).unwrap();
4403
4404 assert_eq!(position.side, PositionSide::Long);
4405 assert_eq!(position.quantity, Quantity::from(5));
4406 assert_eq!(position.opening_order_id, ClientOrderId::from("O-3"));
4407 assert_eq!(position.events, vec![fill_reopen.clone()]);
4408 assert_eq!(position.replay_events.len(), 3);
4409 assert_eq!(position.trade_ids.len(), 1);
4410 assert!(position.trade_ids.contains(&TradeId::from("T-3")));
4411
4412 position
4413 .apply_fill_void(fill_voided, Quantity::from(10), None)
4414 .unwrap();
4415
4416 assert_eq!(position.side, PositionSide::Long);
4417 assert_eq!(position.quantity, Quantity::from(15));
4418 assert_eq!(position.opening_order_id, ClientOrderId::from("O-1"));
4419 assert_eq!(position.closing_order_id, None);
4420 assert_eq!(position.avg_px_open, dec!(1.0));
4421 assert_eq!(position.buy_qty, Quantity::from(15));
4422 assert_eq!(position.sell_qty, Quantity::from(0));
4423 assert_eq!(
4424 position.events,
4425 vec![fill_open.clone(), fill_reopen.clone()]
4426 );
4427 assert_eq!(position.replay_events.len(), 3);
4428 assert_eq!(position.fill_voids.len(), 1);
4429 assert_eq!(position.trade_ids.len(), 2);
4430 assert!(position.trade_ids.contains(&TradeId::from("T-1")));
4431 assert!(position.trade_ids.contains(&TradeId::from("T-3")));
4432
4433 let mut fill_close_duplicate = fill_close;
4434 fill_close_duplicate.event_id = uuid4();
4435 fill_close_duplicate.ts_event = UnixNanos::from(6);
4436 position.try_apply(&fill_close_duplicate).unwrap();
4437
4438 assert_eq!(position.side, PositionSide::Long);
4439 assert_eq!(position.quantity, Quantity::from(15));
4440 assert_eq!(position.events, vec![fill_open, fill_reopen]);
4441 assert_eq!(position.replay_events.len(), 3);
4442 }
4443
4444 #[rstest]
4445 fn test_replay_fill_with_foreign_causation_stays_duplicate(audusd_sim: CurrencyPair) {
4446 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4447 let position_id = PositionId::from("P-CAUSATION");
4448 let fill_open = OrderFilledSpec::builder()
4449 .instrument_id(instrument.id())
4450 .client_order_id(ClientOrderId::from("O-1"))
4451 .trade_id(TradeId::from("T-1"))
4452 .order_side(OrderSide::Buy)
4453 .last_qty(Quantity::from(10))
4454 .last_px(Price::from("1.00000"))
4455 .currency(Currency::USD())
4456 .position_id(position_id)
4457 .ts_event(UnixNanos::from(1))
4458 .build();
4459 let fill_close = OrderFilledSpec::builder()
4460 .instrument_id(instrument.id())
4461 .client_order_id(ClientOrderId::from("O-2"))
4462 .trade_id(TradeId::from("T-2"))
4463 .order_side(OrderSide::Sell)
4464 .last_qty(Quantity::from(10))
4465 .last_px(Price::from("1.00000"))
4466 .currency(Currency::USD())
4467 .position_id(position_id)
4468 .ts_event(UnixNanos::from(2))
4469 .build();
4470 let mut position = Position::new(&instrument, fill_open.clone());
4471 position.apply(&fill_close);
4472
4473 let mut replayed = fill_open.clone();
4474 replayed.event_id = uuid4();
4475 replayed.ts_event = UnixNanos::from(3);
4476 replayed.causation_id = Some(fill_close.event_id);
4477
4478 position.apply(&replayed);
4479
4480 assert_eq!(position.side, PositionSide::Flat);
4481 assert_eq!(position.quantity, Quantity::from(0));
4482 assert_eq!(position.events, vec![fill_open, fill_close.clone()]);
4483 }
4484
4485 #[rstest]
4486 fn test_position_applies_fills_with_negative_prices(audusd_sim: CurrencyPair) {
4487 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
4491 let order = OrderTestBuilder::new(OrderType::Market)
4492 .instrument_id(audusd_sim.id())
4493 .side(OrderSide::Buy)
4494 .quantity(Quantity::from(100_000))
4495 .build();
4496 let fill1 = TestOrderEventStubs::filled(
4497 &order,
4498 &audusd_sim,
4499 Some(TradeId::new("1")),
4500 None,
4501 Some(Price::from("-5.00000")),
4502 Some(Quantity::from(50_000)),
4503 None,
4504 None,
4505 None,
4506 None,
4507 );
4508 let fill2 = TestOrderEventStubs::filled(
4509 &order,
4510 &audusd_sim,
4511 Some(TradeId::new("2")),
4512 None,
4513 Some(Price::from("-7.00000")),
4514 Some(Quantity::from(50_000)),
4515 None,
4516 None,
4517 None,
4518 None,
4519 );
4520 let mut position = Position::new(&audusd_sim, fill1.into());
4521 position.apply(&fill2.into());
4522
4523 assert_eq!(position.quantity, Quantity::from(100_000));
4524 assert_eq!(position.signed_qty, dec!(100_000.0));
4525 assert_eq!(position.side, PositionSide::Long);
4526 assert_eq!(position.avg_px_open, dec!(-6.0));
4528 }
4529
4530 #[rstest]
4531 fn test_position_filled_with_buy_order(audusd_sim: CurrencyPair) {
4532 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
4533 let order = OrderTestBuilder::new(OrderType::Market)
4534 .instrument_id(audusd_sim.id())
4535 .side(OrderSide::Buy)
4536 .quantity(Quantity::from(100_000))
4537 .build();
4538 let fill = TestOrderEventStubs::filled(
4539 &order,
4540 &audusd_sim,
4541 None,
4542 None,
4543 Some(Price::from("1.00001")),
4544 None,
4545 None,
4546 None,
4547 None,
4548 None,
4549 );
4550 let last_price = Price::from_str("1.0005").unwrap();
4551 let position = Position::new(&audusd_sim, fill.into());
4552 assert_eq!(position.symbol(), audusd_sim.id().symbol);
4553 assert_eq!(position.venue(), audusd_sim.id().venue);
4554 assert_eq!(position.closing_order_side(), Some(OrderSide::Sell));
4555 assert!(!position.is_opposite_side(OrderSide::Buy));
4556 assert_eq!(position, position); assert!(position.closing_order_id.is_none());
4558 assert_eq!(position.quantity, Quantity::from(100_000));
4559 assert_eq!(position.peak_qty, Quantity::from(100_000));
4560 assert_eq!(position.size_precision, 0);
4561 assert_eq!(position.signed_qty, dec!(100_000.0));
4562 assert_eq!(position.entry, OrderSide::Buy);
4563 assert_eq!(position.side, PositionSide::Long);
4564 assert_eq!(position.ts_opened.as_u64(), 0);
4565 assert_eq!(position.duration_ns, DurationNanos::default());
4566 assert_eq!(position.avg_px_open, dec!(1.00001));
4567 assert_eq!(position.event_count(), 1);
4568 assert_eq!(position.id, PositionId::new("1"));
4569 assert_eq!(position.events.len(), 1);
4570 assert!(position.is_long());
4571 assert!(!position.is_short());
4572 assert!(position.is_open());
4573 assert!(!position.is_closed());
4574 assert_eq!(position.realized_return, 0.0);
4575 assert_eq!(position.realized_pnl, Some(Money::from("-2.0 USD")));
4576 assert_eq!(
4577 position.unrealized_pnl(last_price).unwrap(),
4578 Money::from("49.0 USD")
4579 );
4580 assert_eq!(
4581 position.total_pnl(last_price).unwrap(),
4582 Money::from("47.0 USD")
4583 );
4584 assert_eq!(position.commissions(), vec![Money::from("2.0 USD")]);
4585 assert_eq!(
4586 format!("{position}"),
4587 "Position(LONG 100_000 AUD/USD.SIM, id=1)"
4588 );
4589 }
4590
4591 #[rstest]
4592 fn test_position_filled_with_sell_order(audusd_sim: CurrencyPair) {
4593 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
4594 let order = OrderTestBuilder::new(OrderType::Market)
4595 .instrument_id(audusd_sim.id())
4596 .side(OrderSide::Sell)
4597 .quantity(Quantity::from(100_000))
4598 .build();
4599 let fill = TestOrderEventStubs::filled(
4600 &order,
4601 &audusd_sim,
4602 None,
4603 None,
4604 Some(Price::from("1.00001")),
4605 None,
4606 None,
4607 None,
4608 None,
4609 None,
4610 );
4611 let last_price = Price::from_str("1.00050").unwrap();
4612 let position = Position::new(&audusd_sim, fill.into());
4613 assert_eq!(position.symbol(), audusd_sim.id().symbol);
4614 assert_eq!(position.venue(), audusd_sim.id().venue);
4615 assert_eq!(position.closing_order_side(), Some(OrderSide::Buy));
4616 assert!(!position.is_opposite_side(OrderSide::Sell));
4617 assert_eq!(position, position); assert!(position.closing_order_id.is_none());
4619 assert_eq!(position.quantity, Quantity::from(100_000));
4620 assert_eq!(position.peak_qty, Quantity::from(100_000));
4621 assert_eq!(position.signed_qty, dec!(-100_000.0));
4622 assert_eq!(position.entry, OrderSide::Sell);
4623 assert_eq!(position.side, PositionSide::Short);
4624 assert_eq!(position.ts_opened.as_u64(), 0);
4625 assert_eq!(position.avg_px_open, dec!(1.00001));
4626 assert_eq!(position.event_count(), 1);
4627 assert_eq!(position.id, PositionId::new("1"));
4628 assert_eq!(position.events.len(), 1);
4629 assert!(!position.is_long());
4630 assert!(position.is_short());
4631 assert!(position.is_open());
4632 assert!(!position.is_closed());
4633 assert_eq!(position.realized_return, 0.0);
4634 assert_eq!(position.realized_pnl, Some(Money::from("-2.0 USD")));
4635 assert_eq!(
4636 position.unrealized_pnl(last_price).unwrap(),
4637 Money::from("-49.0 USD")
4638 );
4639 assert_eq!(
4640 position.total_pnl(last_price).unwrap(),
4641 Money::from("-51.0 USD")
4642 );
4643 assert_eq!(position.commissions(), vec![Money::from("2.0 USD")]);
4644 assert_eq!(
4645 format!("{position}"),
4646 "Position(SHORT 100_000 AUD/USD.SIM, id=1)"
4647 );
4648 }
4649
4650 #[rstest]
4651 fn test_position_partial_fills_with_buy_order(audusd_sim: CurrencyPair) {
4652 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
4653 let order = OrderTestBuilder::new(OrderType::Market)
4654 .instrument_id(audusd_sim.id())
4655 .side(OrderSide::Buy)
4656 .quantity(Quantity::from(100_000))
4657 .build();
4658 let fill = TestOrderEventStubs::filled(
4659 &order,
4660 &audusd_sim,
4661 None,
4662 None,
4663 Some(Price::from("1.00001")),
4664 Some(Quantity::from(50_000)),
4665 None,
4666 None,
4667 None,
4668 None,
4669 );
4670 let last_price = Price::from_str("1.00048").unwrap();
4671 let position = Position::new(&audusd_sim, fill.into());
4672 assert_eq!(position.quantity, Quantity::from(50_000));
4673 assert_eq!(position.peak_qty, Quantity::from(50_000));
4674 assert_eq!(position.side, PositionSide::Long);
4675 assert_eq!(position.signed_qty, dec!(50000.0));
4676 assert_eq!(position.avg_px_open, dec!(1.00001));
4677 assert_eq!(position.event_count(), 1);
4678 assert_eq!(position.ts_opened.as_u64(), 0);
4679 assert!(position.is_long());
4680 assert!(!position.is_short());
4681 assert!(position.is_open());
4682 assert!(!position.is_closed());
4683 assert_eq!(position.realized_return, 0.0);
4684 assert_eq!(position.realized_pnl, Some(Money::from("-2.0 USD")));
4685 assert_eq!(
4686 position.unrealized_pnl(last_price).unwrap(),
4687 Money::from("23.5 USD")
4688 );
4689 assert_eq!(
4690 position.total_pnl(last_price).unwrap(),
4691 Money::from("21.5 USD")
4692 );
4693 assert_eq!(position.commissions(), vec![Money::from("2.0 USD")]);
4694 assert_eq!(
4695 format!("{position}"),
4696 "Position(LONG 50_000 AUD/USD.SIM, id=1)"
4697 );
4698 }
4699
4700 #[rstest]
4701 fn test_position_partial_fills_with_two_sell_orders(audusd_sim: CurrencyPair) {
4702 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
4703 let order = OrderTestBuilder::new(OrderType::Market)
4704 .instrument_id(audusd_sim.id())
4705 .side(OrderSide::Sell)
4706 .quantity(Quantity::from(100_000))
4707 .build();
4708 let fill1 = TestOrderEventStubs::filled(
4709 &order,
4710 &audusd_sim,
4711 Some(TradeId::new("1")),
4712 None,
4713 Some(Price::from("1.00001")),
4714 Some(Quantity::from(50_000)),
4715 None,
4716 None,
4717 None,
4718 None,
4719 );
4720 let fill2 = TestOrderEventStubs::filled(
4721 &order,
4722 &audusd_sim,
4723 Some(TradeId::new("2")),
4724 None,
4725 Some(Price::from("1.00002")),
4726 Some(Quantity::from(50_000)),
4727 None,
4728 None,
4729 None,
4730 None,
4731 );
4732 let last_price = Price::from_str("1.0005").unwrap();
4733 let mut position = Position::new(&audusd_sim, fill1.into());
4734 position.apply(&fill2.into());
4735
4736 assert_eq!(position.quantity, Quantity::from(100_000));
4737 assert_eq!(position.peak_qty, Quantity::from(100_000));
4738 assert_eq!(position.side, PositionSide::Short);
4739 assert_eq!(position.signed_qty, dec!(-100_000.0));
4740 assert_eq!(position.avg_px_open, dec!(1.000_015));
4741 assert_eq!(position.event_count(), 2);
4742 assert_eq!(position.ts_opened, 0);
4743 assert!(position.is_short());
4744 assert!(!position.is_long());
4745 assert!(position.is_open());
4746 assert!(!position.is_closed());
4747 assert_eq!(position.realized_return, 0.0);
4748 assert_eq!(position.realized_pnl, Some(Money::from("-4.0 USD")));
4749 assert_eq!(
4750 position.unrealized_pnl(last_price).unwrap(),
4751 Money::from("-48.5 USD")
4752 );
4753 assert_eq!(
4754 position.total_pnl(last_price).unwrap(),
4755 Money::from("-52.5 USD")
4756 );
4757 assert_eq!(position.commissions(), vec![Money::from("4.0 USD")]);
4758 }
4759
4760 #[rstest]
4761 pub fn test_position_filled_with_buy_order_then_sell_order(audusd_sim: CurrencyPair) {
4762 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
4763 let order = OrderTestBuilder::new(OrderType::Market)
4764 .instrument_id(audusd_sim.id())
4765 .side(OrderSide::Buy)
4766 .quantity(Quantity::from(150_000))
4767 .build();
4768 let fill = TestOrderEventStubs::filled(
4769 &order,
4770 &audusd_sim,
4771 Some(TradeId::new("1")),
4772 Some(PositionId::new("P-1")),
4773 Some(Price::from("1.00001")),
4774 None,
4775 None,
4776 None,
4777 Some(UnixNanos::from(1_000_000_000)),
4778 None,
4779 );
4780 let mut position = Position::new(&audusd_sim, fill.into());
4781
4782 let fill2 = OrderFilledSpec::builder()
4783 .trader_id(order.trader_id())
4784 .strategy_id(StrategyId::new("S-001"))
4785 .instrument_id(order.instrument_id())
4786 .client_order_id(order.client_order_id())
4787 .venue_order_id(VenueOrderId::from("2"))
4788 .account_id(order.account_id().unwrap_or(AccountId::new("SIM-001")))
4789 .trade_id(TradeId::new("2"))
4790 .order_side(OrderSide::Sell)
4791 .last_qty(order.quantity())
4792 .last_px(Price::from("1.00011"))
4793 .currency(audusd_sim.quote_currency())
4794 .ts_event(2_000_000_000.into())
4795 .position_id(PositionId::new("T1"))
4796 .commission(Money::from("0.0 USD"))
4797 .build();
4798 position.apply(&fill2);
4799 let last = Price::from_str("1.0005").unwrap();
4800
4801 assert!(position.is_opposite_side(fill2.order_side));
4802 assert_eq!(
4803 position.quantity,
4804 Quantity::zero(audusd_sim.price_precision())
4805 );
4806 assert_eq!(position.size_precision, 0);
4807 assert_eq!(position.signed_qty, dec!(0.0));
4808 assert_eq!(position.side, PositionSide::Flat);
4809 assert_eq!(position.ts_opened, 1_000_000_000);
4810 assert_eq!(position.ts_closed, Some(UnixNanos::from(2_000_000_000)));
4811 assert_eq!(position.duration_ns, DurationNanos::from_secs(1));
4812 assert_eq!(position.avg_px_open, dec!(1.00001));
4813 assert_eq!(position.avg_px_close, Some(dec!(1.00011)));
4814 assert!(!position.is_long());
4815 assert!(!position.is_short());
4816 assert!(!position.is_open());
4817 assert!(position.is_closed());
4818 assert_eq!(position.realized_return, (1.00011_f64 - 1.00001) / 1.00001);
4819 assert_eq!(position.realized_pnl, Some(Money::from("13.0 USD")));
4820 assert_eq!(position.unrealized_pnl(last).unwrap(), Money::from("0 USD"));
4821 assert_eq!(position.commissions(), vec![Money::from("2 USD")]);
4822 assert_eq!(position.total_pnl(last).unwrap(), Money::from("13 USD"));
4823 assert_eq!(format!("{position}"), "Position(FLAT AUD/USD.SIM, id=P-1)");
4824 }
4825
4826 #[rstest]
4827 pub fn test_position_filled_with_sell_order_then_buy_order(audusd_sim: CurrencyPair) {
4828 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
4829 let order1 = OrderTestBuilder::new(OrderType::Market)
4830 .instrument_id(audusd_sim.id())
4831 .side(OrderSide::Sell)
4832 .quantity(Quantity::from(100_000))
4833 .build();
4834 let order2 = OrderTestBuilder::new(OrderType::Market)
4835 .instrument_id(audusd_sim.id())
4836 .side(OrderSide::Buy)
4837 .quantity(Quantity::from(100_000))
4838 .build();
4839 let fill1 = TestOrderEventStubs::filled(
4840 &order1,
4841 &audusd_sim,
4842 None,
4843 Some(PositionId::new("P-19700101-000000-001-001-1")),
4844 Some(Price::from("1.0")),
4845 None,
4846 None,
4847 None,
4848 None,
4849 None,
4850 );
4851 let mut position = Position::new(&audusd_sim, fill1.into());
4852 let fill2 = TestOrderEventStubs::filled(
4854 &order2,
4855 &audusd_sim,
4856 Some(TradeId::new("1")),
4857 Some(PositionId::new("P-19700101-000000-001-001-1")),
4858 Some(Price::from("1.00001")),
4859 Some(Quantity::from(50_000)),
4860 None,
4861 None,
4862 None,
4863 None,
4864 );
4865 let fill3 = TestOrderEventStubs::filled(
4866 &order2,
4867 &audusd_sim,
4868 Some(TradeId::new("2")),
4869 Some(PositionId::new("P-19700101-000000-001-001-1")),
4870 Some(Price::from("1.00003")),
4871 Some(Quantity::from(50_000)),
4872 None,
4873 None,
4874 None,
4875 None,
4876 );
4877 let last = Price::from("1.0005");
4878 position.apply(&fill2.into());
4879 position.apply(&fill3.into());
4880
4881 assert_eq!(
4882 position.quantity,
4883 Quantity::zero(audusd_sim.price_precision())
4884 );
4885 assert_eq!(position.side, PositionSide::Flat);
4886 assert_eq!(position.ts_opened, 0);
4887 assert_eq!(position.avg_px_open, dec!(1.0));
4888 assert_eq!(position.events.len(), 3);
4889 assert_eq!(position.ts_closed, Some(UnixNanos::default()));
4890 assert_eq!(position.avg_px_close, Some(dec!(1.00002)));
4891 assert!(!position.is_long());
4892 assert!(!position.is_short());
4893 assert!(!position.is_open());
4894 assert!(position.is_closed());
4895 assert_eq!(position.commissions(), vec![Money::from("6.0 USD")]);
4896 assert_eq!(position.unrealized_pnl(last).unwrap(), Money::from("0 USD"));
4897 assert_eq!(position.realized_pnl, Some(Money::from("-8.0 USD")));
4898 assert_eq!(position.total_pnl(last).unwrap(), Money::from("-8.0 USD"));
4899 assert_eq!(
4900 format!("{position}"),
4901 "Position(FLAT AUD/USD.SIM, id=P-19700101-000000-001-001-1)"
4902 );
4903 }
4904
4905 #[rstest]
4906 fn test_position_filled_with_no_change(audusd_sim: CurrencyPair) {
4907 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
4908 let order1 = OrderTestBuilder::new(OrderType::Market)
4909 .instrument_id(audusd_sim.id())
4910 .side(OrderSide::Buy)
4911 .quantity(Quantity::from(100_000))
4912 .build();
4913 let order2 = OrderTestBuilder::new(OrderType::Market)
4914 .instrument_id(audusd_sim.id())
4915 .side(OrderSide::Sell)
4916 .quantity(Quantity::from(100_000))
4917 .build();
4918 let fill1 = TestOrderEventStubs::filled(
4919 &order1,
4920 &audusd_sim,
4921 Some(TradeId::new("1")),
4922 Some(PositionId::new("P-19700101-000000-001-001-1")),
4923 Some(Price::from("1.0")),
4924 None,
4925 None,
4926 None,
4927 None,
4928 None,
4929 );
4930 let mut position = Position::new(&audusd_sim, fill1.into());
4931 let fill2 = TestOrderEventStubs::filled(
4932 &order2,
4933 &audusd_sim,
4934 Some(TradeId::new("2")),
4935 Some(PositionId::new("P-19700101-000000-001-001-1")),
4936 Some(Price::from("1.0")),
4937 None,
4938 None,
4939 None,
4940 None,
4941 None,
4942 );
4943 let last = Price::from("1.0005");
4944 position.apply(&fill2.into());
4945
4946 assert_eq!(
4947 position.quantity,
4948 Quantity::zero(audusd_sim.price_precision())
4949 );
4950 assert_eq!(position.closing_order_side(), None);
4951 assert_eq!(position.side, PositionSide::Flat);
4952 assert_eq!(position.ts_opened, 0);
4953 assert_eq!(position.avg_px_open, dec!(1.0));
4954 assert_eq!(position.events.len(), 2);
4955 assert_eq!(position.ts_closed, Some(UnixNanos::default()));
4957 assert_eq!(position.avg_px_close, Some(dec!(1.0)));
4958 assert!(!position.is_long());
4959 assert!(!position.is_short());
4960 assert!(!position.is_open());
4961 assert!(position.is_closed());
4962 assert_eq!(position.commissions(), vec![Money::from("4.0 USD")]);
4963 assert_eq!(position.unrealized_pnl(last).unwrap(), Money::from("0 USD"));
4964 assert_eq!(position.realized_pnl, Some(Money::from("-4.0 USD")));
4965 assert_eq!(position.total_pnl(last).unwrap(), Money::from("-4.0 USD"));
4966 assert_eq!(
4967 format!("{position}"),
4968 "Position(FLAT AUD/USD.SIM, id=P-19700101-000000-001-001-1)"
4969 );
4970 }
4971
4972 #[rstest]
4973 fn test_position_long_with_multiple_filled_orders(audusd_sim: CurrencyPair) {
4974 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
4975 let order1 = OrderTestBuilder::new(OrderType::Market)
4976 .instrument_id(audusd_sim.id())
4977 .side(OrderSide::Buy)
4978 .quantity(Quantity::from(100_000))
4979 .build();
4980 let order2 = OrderTestBuilder::new(OrderType::Market)
4981 .instrument_id(audusd_sim.id())
4982 .side(OrderSide::Buy)
4983 .quantity(Quantity::from(100_000))
4984 .build();
4985 let order3 = OrderTestBuilder::new(OrderType::Market)
4986 .instrument_id(audusd_sim.id())
4987 .side(OrderSide::Sell)
4988 .quantity(Quantity::from(200_000))
4989 .build();
4990 let fill1 = TestOrderEventStubs::filled(
4991 &order1,
4992 &audusd_sim,
4993 Some(TradeId::new("1")),
4994 Some(PositionId::new("P-123456")),
4995 Some(Price::from("1.0")),
4996 None,
4997 None,
4998 None,
4999 None,
5000 None,
5001 );
5002 let fill2 = TestOrderEventStubs::filled(
5003 &order2,
5004 &audusd_sim,
5005 Some(TradeId::new("2")),
5006 Some(PositionId::new("P-123456")),
5007 Some(Price::from("1.00001")),
5008 None,
5009 None,
5010 None,
5011 None,
5012 None,
5013 );
5014 let fill3 = TestOrderEventStubs::filled(
5015 &order3,
5016 &audusd_sim,
5017 Some(TradeId::new("3")),
5018 Some(PositionId::new("P-123456")),
5019 Some(Price::from("1.0001")),
5020 None,
5021 None,
5022 None,
5023 None,
5024 None,
5025 );
5026 let mut position = Position::new(&audusd_sim, fill1.into());
5027 let last = Price::from("1.0005");
5028 position.apply(&fill2.into());
5029 position.apply(&fill3.into());
5030
5031 assert_eq!(
5032 position.quantity,
5033 Quantity::zero(audusd_sim.price_precision())
5034 );
5035 assert_eq!(position.side, PositionSide::Flat);
5036 assert_eq!(position.ts_opened, 0);
5037 assert_eq!(position.avg_px_open, dec!(1.000_005));
5038 assert_eq!(position.events.len(), 3);
5039 assert_eq!(position.ts_closed, Some(UnixNanos::default()));
5044 assert_eq!(position.avg_px_close, Some(dec!(1.0001)));
5045 assert!(position.is_closed());
5046 assert!(!position.is_open());
5047 assert!(!position.is_long());
5048 assert!(!position.is_short());
5049 assert_eq!(position.commissions(), vec![Money::from("6.0 USD")]);
5050 assert_eq!(position.realized_pnl, Some(Money::from("13.0 USD")));
5051 assert_eq!(position.unrealized_pnl(last).unwrap(), Money::from("0 USD"));
5052 assert_eq!(position.total_pnl(last).unwrap(), Money::from("13 USD"));
5053 assert_eq!(
5054 format!("{position}"),
5055 "Position(FLAT AUD/USD.SIM, id=P-123456)"
5056 );
5057 }
5058
5059 #[rstest]
5060 fn test_pnl_calculation_from_trading_technologies_example(currency_pair_ethusdt: CurrencyPair) {
5061 let ethusdt = InstrumentAny::CurrencyPair(currency_pair_ethusdt);
5062 let quantity1 = Quantity::from(12);
5063 let price1 = Price::from("100.0");
5064 let order1 = OrderTestBuilder::new(OrderType::Market)
5065 .instrument_id(ethusdt.id())
5066 .side(OrderSide::Buy)
5067 .quantity(quantity1)
5068 .build();
5069 let commission1 =
5070 calculate_commission(ðusdt, order1.quantity(), price1, None, dec!(0.0001));
5071 let fill1 = TestOrderEventStubs::filled(
5072 &order1,
5073 ðusdt,
5074 Some(TradeId::new("1")),
5075 Some(PositionId::new("P-123456")),
5076 Some(price1),
5077 None,
5078 None,
5079 Some(commission1),
5080 None,
5081 None,
5082 );
5083 let mut position = Position::new(ðusdt, fill1.into());
5084 let quantity2 = Quantity::from(17);
5085 let order2 = OrderTestBuilder::new(OrderType::Market)
5086 .instrument_id(ethusdt.id())
5087 .side(OrderSide::Buy)
5088 .quantity(quantity2)
5089 .build();
5090 let price2 = Price::from("99.0");
5091 let commission2 =
5092 calculate_commission(ðusdt, order2.quantity(), price2, None, dec!(0.0001));
5093 let fill2 = TestOrderEventStubs::filled(
5094 &order2,
5095 ðusdt,
5096 Some(TradeId::new("2")),
5097 Some(PositionId::new("P-123456")),
5098 Some(price2),
5099 None,
5100 None,
5101 Some(commission2),
5102 None,
5103 None,
5104 );
5105 position.apply(&fill2.into());
5106 assert_eq!(position.quantity, Quantity::from(29));
5107 assert_eq!(position.realized_pnl, Some(Money::from("-0.28830000 USDT")));
5108 assert_eq!(position.avg_px_open, (dec!(2883) / dec!(29)));
5109 let quantity3 = Quantity::from(9);
5110 let order3 = OrderTestBuilder::new(OrderType::Market)
5111 .instrument_id(ethusdt.id())
5112 .side(OrderSide::Sell)
5113 .quantity(quantity3)
5114 .build();
5115 let price3 = Price::from("101.0");
5116 let commission3 =
5117 calculate_commission(ðusdt, order3.quantity(), price3, None, dec!(0.0001));
5118 let fill3 = TestOrderEventStubs::filled(
5119 &order3,
5120 ðusdt,
5121 Some(TradeId::new("3")),
5122 Some(PositionId::new("P-123456")),
5123 Some(price3),
5124 None,
5125 None,
5126 Some(commission3),
5127 None,
5128 None,
5129 );
5130 position.apply(&fill3.into());
5131 assert_eq!(position.quantity, Quantity::from(20));
5132 assert_eq!(
5133 position.realized_pnl,
5134 Some(native_money(dec!(251877) / dec!(18125), Currency::USDT()))
5135 );
5136 assert_eq!(position.avg_px_open, (dec!(2883) / dec!(29)));
5137 let quantity4 = Quantity::from("4");
5138 let price4 = Price::from("105.0");
5139 let order4 = OrderTestBuilder::new(OrderType::Market)
5140 .instrument_id(ethusdt.id())
5141 .side(OrderSide::Sell)
5142 .quantity(quantity4)
5143 .build();
5144 let commission4 =
5145 calculate_commission(ðusdt, order4.quantity(), price4, None, dec!(0.0001));
5146 let fill4 = TestOrderEventStubs::filled(
5147 &order4,
5148 ðusdt,
5149 Some(TradeId::new("4")),
5150 Some(PositionId::new("P-123456")),
5151 Some(price4),
5152 None,
5153 None,
5154 Some(commission4),
5155 None,
5156 None,
5157 );
5158 position.apply(&fill4.into());
5159 assert_eq!(position.quantity, Quantity::from("16"));
5160 assert_eq!(
5161 position.realized_pnl,
5162 Some(native_money(dec!(2624463) / dec!(72500), Currency::USDT()))
5163 );
5164 assert_eq!(position.avg_px_open, (dec!(2883) / dec!(29)));
5165 let quantity5 = Quantity::from("3");
5166 let price5 = Price::from("103.0");
5167 let order5 = OrderTestBuilder::new(OrderType::Market)
5168 .instrument_id(ethusdt.id())
5169 .side(OrderSide::Buy)
5170 .quantity(quantity5)
5171 .build();
5172 let commission5 =
5173 calculate_commission(ðusdt, order5.quantity(), price5, None, dec!(0.0001));
5174 let fill5 = TestOrderEventStubs::filled(
5175 &order5,
5176 ðusdt,
5177 Some(TradeId::new("5")),
5178 Some(PositionId::new("P-123456")),
5179 Some(price5),
5180 None,
5181 None,
5182 Some(commission5),
5183 None,
5184 None,
5185 );
5186 position.apply(&fill5.into());
5187 assert_eq!(position.quantity, Quantity::from("19"));
5188 assert_eq!(
5189 position.realized_pnl,
5190 Some(native_money(
5191 dec!(10488891) / dec!(290000),
5192 Currency::USDT()
5193 ))
5194 );
5195 assert_eq!(position.avg_px_open, dec!(99.98003629764065335753176043));
5196 assert_eq!(
5197 format!("{position}"),
5198 "Position(LONG 19.00000 ETHUSDT.BINANCE, id=P-123456)"
5199 );
5200 }
5201
5202 #[rstest]
5203 fn test_position_closed_and_reopened(audusd_sim: CurrencyPair) {
5204 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
5205 let quantity1 = Quantity::from(150_000);
5206 let price1 = Price::from("1.00001");
5207 let order = OrderTestBuilder::new(OrderType::Market)
5208 .instrument_id(audusd_sim.id())
5209 .side(OrderSide::Buy)
5210 .quantity(quantity1)
5211 .build();
5212 let commission1 = calculate_commission(&audusd_sim, quantity1, price1, None, dec!(0.00002));
5213 let fill1 = TestOrderEventStubs::filled(
5214 &order,
5215 &audusd_sim,
5216 Some(TradeId::new("5")),
5217 Some(PositionId::new("P-123456")),
5218 Some(Price::from("1.00001")),
5219 None,
5220 None,
5221 Some(commission1),
5222 Some(UnixNanos::from(1_000_000_000)),
5223 None,
5224 );
5225 let mut position = Position::new(&audusd_sim, fill1.into());
5226
5227 let fill2 = OrderFilledSpec::builder()
5228 .trader_id(order.trader_id())
5229 .strategy_id(order.strategy_id())
5230 .instrument_id(order.instrument_id())
5231 .client_order_id(order.client_order_id())
5232 .venue_order_id(VenueOrderId::from("2"))
5233 .account_id(order.account_id().unwrap_or(AccountId::new("SIM-001")))
5234 .trade_id(TradeId::from("2"))
5235 .order_side(OrderSide::Sell)
5236 .last_qty(order.quantity())
5237 .last_px(Price::from("1.00011"))
5238 .currency(audusd_sim.quote_currency())
5239 .ts_event(UnixNanos::from(2_000_000_000))
5240 .position_id(PositionId::from("P-123456"))
5241 .commission(Money::from("0 USD"))
5242 .build();
5243
5244 position.apply(&fill2);
5245
5246 let fill3 = OrderFilledSpec::builder()
5247 .trader_id(order.trader_id())
5248 .strategy_id(order.strategy_id())
5249 .instrument_id(order.instrument_id())
5250 .client_order_id(order.client_order_id())
5251 .venue_order_id(VenueOrderId::from("2"))
5252 .account_id(order.account_id().unwrap_or(AccountId::new("SIM-001")))
5253 .trade_id(TradeId::from("3"))
5254 .last_qty(order.quantity())
5255 .last_px(Price::from("1.00012"))
5256 .currency(audusd_sim.quote_currency())
5257 .ts_event(UnixNanos::from(3_000_000_000))
5258 .position_id(PositionId::from("P-123456"))
5259 .commission(Money::from("0 USD"))
5260 .build();
5261
5262 position.apply(&fill3);
5263
5264 let last = Price::from("1.0003");
5265 assert!(position.is_opposite_side(fill2.order_side));
5266 assert_eq!(position.quantity, Quantity::from(150_000));
5267 assert_eq!(position.peak_qty, Quantity::from(150_000));
5268 assert_eq!(position.side, PositionSide::Long);
5269 assert_eq!(position.opening_order_id, fill3.client_order_id);
5270 assert_eq!(position.closing_order_id, None);
5271 assert_eq!(position.ts_opened, 3_000_000_000);
5272 assert_eq!(position.duration_ns, DurationNanos::default());
5273 assert_eq!(position.avg_px_open, dec!(1.00012));
5274 assert_eq!(position.event_count(), 1);
5275 assert_eq!(position.ts_closed, None);
5276 assert_eq!(position.avg_px_close, None);
5277 assert!(position.is_long());
5278 assert!(!position.is_short());
5279 assert!(position.is_open());
5280 assert!(!position.is_closed());
5281 assert_eq!(position.realized_return, 0.0);
5282 assert_eq!(position.realized_pnl, Some(Money::from("0 USD")));
5283 assert_eq!(
5284 position.unrealized_pnl(last).unwrap(),
5285 Money::from("27 USD")
5286 );
5287 assert_eq!(position.total_pnl(last).unwrap(), Money::from("27 USD"));
5288 assert_eq!(position.commissions(), vec![Money::from("0 USD")]);
5289 assert_eq!(
5290 format!("{position}"),
5291 "Position(LONG 150_000 AUD/USD.SIM, id=P-123456)"
5292 );
5293 }
5294
5295 #[rstest]
5296 #[case::zero(Quantity::from(0))]
5297 #[case::exceeds_fragments(Quantity::from(11))]
5298 fn test_apply_fill_void_rejects_invalid_allocation_without_mutation(
5299 #[case] voided_qty: Quantity,
5300 audusd_sim: CurrencyPair,
5301 ) {
5302 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5303 let position_id = PositionId::from("P-VOID-INVALID");
5304 let fill = OrderFilledSpec::builder()
5305 .instrument_id(instrument.id())
5306 .client_order_id(ClientOrderId::from("O-VOID-INVALID"))
5307 .trade_id(TradeId::from("T-VOID-INVALID"))
5308 .order_side(OrderSide::Buy)
5309 .last_qty(Quantity::from(10))
5310 .last_px(Price::from("1.00000"))
5311 .currency(Currency::USD())
5312 .position_id(position_id)
5313 .build();
5314 let fill_voided = matching_fill_void(&fill, voided_qty, None);
5315 let mut position = Position::new(&instrument, fill);
5316 let state_before = serde_json::to_value(&position).unwrap();
5317
5318 let error = position
5319 .apply_fill_void(fill_voided, voided_qty, None)
5320 .unwrap_err();
5321
5322 assert_eq!(
5323 error.to_string(),
5324 "position fill void exceeds known fragments for T-VOID-INVALID"
5325 );
5326 assert_eq!(serde_json::to_value(&position).unwrap(), state_before);
5327 }
5328
5329 #[rstest]
5330 #[case::stale(
5331 Quantity::from(4),
5332 Money::from("0.40 USD"),
5333 "stale position fill void for T-VOID-CUMULATIVE"
5334 )]
5335 #[case::duplicate(
5336 Quantity::from(5),
5337 Money::from("0.50 USD"),
5338 "duplicate position fill void for T-VOID-CUMULATIVE"
5339 )]
5340 fn test_apply_fill_void_rejects_invalid_cumulative_update_without_mutation(
5341 #[case] voided_qty: Quantity,
5342 #[case] commission_voided: Money,
5343 #[case] expected_error: &str,
5344 audusd_sim: CurrencyPair,
5345 ) {
5346 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5347 let position_id = PositionId::from("P-VOID-CUMULATIVE");
5348 let fill = OrderFilledSpec::builder()
5349 .instrument_id(instrument.id())
5350 .client_order_id(ClientOrderId::from("O-VOID-CUMULATIVE"))
5351 .trade_id(TradeId::from("T-VOID-CUMULATIVE"))
5352 .order_side(OrderSide::Buy)
5353 .last_qty(Quantity::from(10))
5354 .last_px(Price::from("1.00000"))
5355 .currency(Currency::USD())
5356 .position_id(position_id)
5357 .commission(Money::from("1.00 USD"))
5358 .build();
5359 let fill_voided =
5360 matching_fill_void(&fill, Quantity::from(5), Some(Money::from("0.50 USD")));
5361 let mut position = Position::new(&instrument, fill);
5362 position
5363 .apply_fill_void(
5364 fill_voided.clone(),
5365 Quantity::from(5),
5366 Some(Money::from("0.50 USD")),
5367 )
5368 .unwrap();
5369 let state_before = serde_json::to_value(&position).unwrap();
5370
5371 let error = position
5372 .apply_fill_void(fill_voided, voided_qty, Some(commission_voided))
5373 .unwrap_err();
5374
5375 assert_eq!(error.to_string(), expected_error);
5376 assert_eq!(serde_json::to_value(&position).unwrap(), state_before);
5377 }
5378
5379 #[rstest]
5380 fn test_apply_fill_void_rejects_currency_mismatch_without_mutation(audusd_sim: CurrencyPair) {
5381 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5382 let fill = OrderFilledSpec::builder()
5383 .instrument_id(instrument.id())
5384 .client_order_id(ClientOrderId::from("O-VOID-CURRENCY"))
5385 .trade_id(TradeId::from("T-VOID-CURRENCY"))
5386 .order_side(OrderSide::Buy)
5387 .last_qty(Quantity::from(10))
5388 .last_px(Price::from("1.00000"))
5389 .currency(Currency::USD())
5390 .position_id(PositionId::from("P-VOID-CURRENCY"))
5391 .commission(Money::from("1.00 USD"))
5392 .build();
5393 let commission_voided = Some(Money::from("0.50 EUR"));
5394 let voided_qty = Quantity::from(5);
5395 let fill_voided = matching_fill_void(&fill, voided_qty, commission_voided);
5396
5397 let mut position = Position::new(&instrument, fill);
5398 let before = serde_json::to_value(&position).unwrap();
5399 let error = position
5400 .apply_fill_void(fill_voided, voided_qty, commission_voided)
5401 .unwrap_err();
5402 assert_eq!(
5403 error.to_string(),
5404 "position commission currency differs for fill T-VOID-CURRENCY"
5405 );
5406 assert_eq!(serde_json::to_value(&position).unwrap(), before);
5407 }
5408
5409 #[rstest]
5410 fn test_apply_fill_void_uses_latest_cumulative_commission(audusd_sim: CurrencyPair) {
5411 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5412 let position_id = PositionId::from("P-VOID-LATEST");
5413 let fill = OrderFilledSpec::builder()
5414 .instrument_id(instrument.id())
5415 .client_order_id(ClientOrderId::from("O-VOID-LATEST"))
5416 .trade_id(TradeId::from("T-VOID-LATEST"))
5417 .order_side(OrderSide::Buy)
5418 .last_qty(Quantity::from(10))
5419 .last_px(Price::from("1.00000"))
5420 .currency(Currency::USD())
5421 .position_id(position_id)
5422 .commission(Money::from("1.00 USD"))
5423 .build();
5424 let fill_voided =
5425 matching_fill_void(&fill, Quantity::from(4), Some(Money::from("0.40 USD")));
5426 let mut position = Position::new(&instrument, fill);
5427 position
5428 .apply_fill_void(
5429 fill_voided.clone(),
5430 Quantity::from(4),
5431 Some(Money::from("0.40 USD")),
5432 )
5433 .unwrap();
5434
5435 position
5436 .apply_fill_void(fill_voided, Quantity::from(7), None)
5437 .unwrap();
5438
5439 assert_eq!(position.side, PositionSide::Long);
5440 assert_eq!(position.quantity, Quantity::from(3));
5441 assert_eq!(position.commissions(), vec![Money::from("1.00 USD")]);
5442 assert_eq!(position.realized_pnl, Some(Money::from("-1.00 USD")));
5443 assert_eq!(position.fill_voids.len(), 2);
5444 }
5445
5446 #[cfg(feature = "defi")]
5447 #[rstest]
5448 #[case::native_instrument(0, "1.000000000000000001", "0.5", "0.500000000000000001")]
5449 #[case::wei_instrument(18, "1", "0.500000000000000001", "0.499999999999999999")]
5450 fn test_fill_void_accepts_mixed_qty_scales(
5451 mut audusd_sim: CurrencyPair,
5452 #[case] precision: u8,
5453 #[case] qty: &str,
5454 #[case] voided: &str,
5455 #[case] remaining: &str,
5456 ) {
5457 audusd_sim.size_precision = precision;
5458 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5459 let fill = OrderFilledSpec::builder()
5460 .instrument_id(instrument.id())
5461 .last_qty(Quantity::from(qty))
5462 .last_px(Price::from("1"))
5463 .position_id(PositionId::from("P-MIXED-VOID"))
5464 .commission(Money::zero(Currency::USD()))
5465 .build();
5466 let mut position = Position::new(&instrument, fill.clone());
5467 let voided_qty = Quantity::from(voided);
5468 let first = matching_fill_void(&fill, voided_qty, None);
5469 position.apply_fill_void(first, voided_qty, None).unwrap();
5470
5471 assert_eq!(position.quantity, Quantity::from(remaining));
5472 assert_eq!(position.signed_qty, remaining.parse::<Decimal>().unwrap());
5473 assert_eq!(position.cost_open, remaining.parse::<Decimal>().unwrap());
5474 assert_eq!(position.realized_pnl, Some(Money::zero(Currency::USD())));
5475
5476 let final_void = matching_fill_void(&fill, fill.last_qty, None);
5477 position
5478 .apply_fill_void(final_void, fill.last_qty, None)
5479 .unwrap();
5480
5481 assert_eq!(position.quantity.as_decimal(), Decimal::ZERO);
5482 assert_eq!(position.signed_qty, Decimal::ZERO);
5483 assert_eq!(position.cost_open, Decimal::ZERO);
5484 assert_eq!(position.realized_pnl_remainder, Decimal::ZERO);
5485 assert_eq!(position.fill_voids.len(), 2);
5486 }
5487
5488 #[rstest]
5489 fn test_fill_void_replays_across_position_close_and_reopen(audusd_sim: CurrencyPair) {
5490 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5491 let position_id = PositionId::from("P-VOID-REPLAY");
5492 let fill1 = OrderFilledSpec::builder()
5493 .instrument_id(instrument.id())
5494 .client_order_id(ClientOrderId::from("O-OPEN"))
5495 .trade_id(TradeId::from("T-OPEN"))
5496 .order_side(OrderSide::Buy)
5497 .last_qty(Quantity::from(10))
5498 .last_px(Price::from("1.00000"))
5499 .currency(Currency::USD())
5500 .position_id(position_id)
5501 .commission(Money::from("1.00 USD"))
5502 .ts_event(UnixNanos::from(1))
5503 .build();
5504 let fill2 = OrderFilledSpec::builder()
5505 .instrument_id(instrument.id())
5506 .client_order_id(ClientOrderId::from("O-CLOSE"))
5507 .trade_id(TradeId::from("T-CLOSE"))
5508 .order_side(OrderSide::Sell)
5509 .last_qty(Quantity::from(10))
5510 .last_px(Price::from("1.10000"))
5511 .currency(Currency::USD())
5512 .position_id(position_id)
5513 .commission(Money::from("1.00 USD"))
5514 .ts_event(UnixNanos::from(2))
5515 .build();
5516 let fill3 = OrderFilledSpec::builder()
5517 .instrument_id(instrument.id())
5518 .client_order_id(ClientOrderId::from("O-REOPEN"))
5519 .trade_id(TradeId::from("T-REOPEN"))
5520 .order_side(OrderSide::Buy)
5521 .last_qty(Quantity::from(5))
5522 .last_px(Price::from("1.20000"))
5523 .currency(Currency::USD())
5524 .position_id(position_id)
5525 .commission(Money::from("1.00 USD"))
5526 .ts_event(UnixNanos::from(3))
5527 .build();
5528 let fill_voided =
5529 matching_fill_void(&fill2, Quantity::from(5), Some(Money::from("0.50 USD")));
5530 let mut position = Position::new(&instrument, fill1);
5531 position.apply(&fill2);
5532 position.apply(&fill3);
5533
5534 position
5535 .apply_fill_void(
5536 fill_voided,
5537 Quantity::from(5),
5538 Some(Money::from("0.50 USD")),
5539 )
5540 .unwrap();
5541 let encoded = serde_json::to_string(&position).unwrap();
5542 let restored: Position = serde_json::from_str(&encoded).unwrap();
5543
5544 assert_eq!(position.side, PositionSide::Long);
5545 assert_eq!(position.quantity, Quantity::from(10));
5546 assert_eq!(position.opening_order_id, ClientOrderId::from("O-OPEN"));
5547 assert_eq!(position.buy_qty, Quantity::from(15));
5548 assert_eq!(position.sell_qty, Quantity::from(5));
5549 assert_eq!(position.commissions(), vec![Money::from("2.50 USD")]);
5550 assert_eq!(position.replay_events.len(), 3);
5551 assert_eq!(position.fill_voids.len(), 1);
5552 assert_eq!(restored.quantity, position.quantity);
5553 assert_eq!(restored.opening_order_id, position.opening_order_id);
5554 assert_eq!(restored.commissions(), position.commissions());
5555 assert_eq!(restored.replay_events.len(), position.replay_events.len());
5556 assert_eq!(restored.fill_voids.len(), position.fill_voids.len());
5557 }
5558
5559 #[rstest]
5560 fn test_fill_void_replays_manual_adjustment(audusd_sim: CurrencyPair) {
5561 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5562 let position_id = PositionId::from("P-VOID-ADJUSTMENT");
5563 let fill = OrderFilledSpec::builder()
5564 .instrument_id(instrument.id())
5565 .client_order_id(ClientOrderId::from("O-VOID-ADJUSTMENT"))
5566 .trade_id(TradeId::from("T-VOID-ADJUSTMENT"))
5567 .order_side(OrderSide::Buy)
5568 .last_qty(Quantity::from(10))
5569 .last_px(Price::from("1.00000"))
5570 .currency(Currency::USD())
5571 .position_id(position_id)
5572 .ts_event(UnixNanos::from(1))
5573 .build();
5574 let fill_voided = matching_fill_void(&fill, Quantity::from(2), None);
5575 let adjustment = PositionAdjusted::new(
5576 fill.trader_id,
5577 fill.strategy_id,
5578 fill.instrument_id,
5579 position_id,
5580 fill.account_id,
5581 PositionAdjustmentType::Funding,
5582 None,
5583 Some(Money::from("5.00 USD")),
5584 Some("funding".into()),
5585 uuid4(),
5586 UnixNanos::from(2),
5587 UnixNanos::from(2),
5588 );
5589 let mut position = Position::new(&instrument, fill);
5590 position.apply_adjustment(adjustment).unwrap();
5591
5592 position
5593 .apply_fill_void(fill_voided, Quantity::from(2), None)
5594 .unwrap();
5595
5596 assert_eq!(position.side, PositionSide::Long);
5597 assert_eq!(position.quantity, Quantity::from(8));
5598 assert_eq!(position.realized_pnl, Some(Money::from("5.00 USD")));
5599 assert_eq!(position.adjustments, vec![adjustment]);
5600 assert_eq!(position.replay_events.len(), 2);
5601 assert_eq!(position.fill_voids.len(), 1);
5602 assert_eq!(position.ts_last, UnixNanos::from(2));
5603 }
5604
5605 fn binary_fill(
5606 instrument: &InstrumentAny,
5607 trade_id: &str,
5608 last_qty: &str,
5609 last_px: &str,
5610 ts_event: u64,
5611 ) -> OrderFilled {
5612 OrderFilledSpec::builder()
5613 .instrument_id(instrument.id())
5614 .client_order_id(ClientOrderId::from(format!("O-{trade_id}").as_str()))
5615 .venue_order_id(VenueOrderId::from(format!("V-{trade_id}").as_str()))
5616 .account_id(AccountId::from("POLYMARKET-001"))
5617 .trade_id(TradeId::from(trade_id))
5618 .order_side(OrderSide::Buy)
5619 .last_qty(Quantity::from(last_qty))
5620 .last_px(Price::from(last_px))
5621 .currency(Currency::USDC())
5622 .commission(Money::from("0.10 USDC"))
5623 .position_id(PositionId::from("P-BINARY"))
5624 .ts_event(UnixNanos::from(ts_event))
5625 .ts_init(UnixNanos::from(ts_event))
5626 .build()
5627 }
5628
5629 fn contract_expired(instrument: &InstrumentAny, close_price: &str) -> InstrumentClose {
5630 InstrumentClose::new(
5631 instrument.id(),
5632 Price::from(close_price),
5633 InstrumentCloseType::ContractExpired,
5634 UnixNanos::from(300),
5635 UnixNanos::from(301),
5636 )
5637 }
5638
5639 #[rstest]
5640 #[case::winner("1.000", dec!(5.90), 1.5)]
5641 #[case::loser("0.000", dec!(-4.10), -1.0)]
5642 fn test_apply_instrument_close_settles_without_fill(
5643 binary_option: BinaryOption,
5644 #[case] close_price: &str,
5645 #[case] expected_realized_pnl: Decimal,
5646 #[case] expected_return: f64,
5647 ) {
5648 let instrument = InstrumentAny::BinaryOption(binary_option);
5649 let fill = binary_fill(&instrument, "T-OPEN", "10.00", "0.400", 100);
5650 let mut position = Position::new(&instrument, fill.clone());
5651 let close = contract_expired(&instrument, close_price);
5652 let requires_replay_state_before = position.requires_replay_state();
5653
5654 position.apply_instrument_close(close).unwrap();
5655 let snapshot = PositionSnapshot::from_replay_state(&position, None);
5656 let restored: Position = serde_json::from_value(snapshot.replay_state.unwrap()).unwrap();
5657
5658 assert!(!requires_replay_state_before);
5659 assert!(position.requires_replay_state());
5660 assert!(position.is_closed());
5661 assert!(position.is_settled());
5662 assert_eq!(position.side, PositionSide::Flat);
5663 assert_eq!(position.signed_qty, dec!(0.0));
5664 assert_eq!(position.quantity, Quantity::from("0.00"));
5665 assert_eq!(position.peak_qty, Quantity::from("10.00"));
5666 assert_eq!(position.buy_qty, Quantity::from("10.00"));
5667 assert_eq!(position.sell_qty, Quantity::from("0.00"));
5668 assert_eq!(position.opening_order_id, fill.client_order_id);
5669 assert_eq!(position.closing_order_id, None);
5670 assert_eq!(position.events, vec![fill]);
5671 assert_eq!(position.commissions(), vec![Money::from("0.10 USDC")]);
5672 assert_eq!(position.avg_px_open, dec!(0.4));
5673 assert_eq!(
5674 position.avg_px_close,
5675 Some(Price::from(close_price).as_decimal())
5676 );
5677 assert!((position.realized_return - expected_return).abs() < 1e-12);
5678 assert_eq!(
5679 position.realized_pnl.unwrap().as_decimal(),
5680 expected_realized_pnl
5681 );
5682 assert_eq!(position.ts_last, UnixNanos::from(300));
5683 assert_eq!(position.ts_closed, Some(UnixNanos::from(300)));
5684 assert_eq!(position.duration_ns, DurationNanos::new(200));
5685 assert!(matches!(
5686 position.replay_events.last(),
5687 Some(PositionReplayEvent::InstrumentClosed(event)) if *event == close
5688 ));
5689 assert!(restored.is_settled());
5690 assert!(restored.is_closed());
5691 assert_eq!(restored.realized_pnl, position.realized_pnl);
5692 assert_eq!(restored.avg_px_close, position.avg_px_close);
5693 assert_eq!(restored.ts_closed, position.ts_closed);
5694 }
5695
5696 #[rstest]
5697 fn test_apply_instrument_close_after_partial_close(binary_option: BinaryOption) {
5698 let instrument = InstrumentAny::BinaryOption(binary_option);
5699 let opening_fill = binary_fill(&instrument, "T-OPEN", "10.00", "0.400", 100);
5700 let mut reducing_fill = binary_fill(&instrument, "T-REDUCE", "4.00", "0.600", 200);
5701 reducing_fill.order_side = OrderSide::Sell;
5702 let mut position = Position::new(&instrument, opening_fill);
5703 position.apply(&reducing_fill);
5704
5705 position
5706 .apply_instrument_close(contract_expired(&instrument, "1.000"))
5707 .unwrap();
5708
5709 assert!(position.is_closed());
5710 assert_eq!(position.quantity, Quantity::from("0.00"));
5711 assert_eq!(position.buy_qty, Quantity::from("10.00"));
5712 assert_eq!(position.sell_qty, Quantity::from("4.00"));
5713 assert!((position.avg_px_close.unwrap() - dec!(0.84)).abs() < dec!(1e-12));
5714 assert!((position.realized_return - 1.1).abs() < 1e-12);
5715 assert_eq!(position.realized_pnl.unwrap().as_decimal(), dec!(4.20));
5716 assert_eq!(position.closing_order_id, None);
5717 }
5718
5719 #[rstest]
5720 #[case::fills_only(false, false, false)]
5721 #[case::fill_voided(true, false, true)]
5722 #[case::settled(false, true, true)]
5723 fn test_requires_replay_state(
5724 binary_option: BinaryOption,
5725 #[case] void_fill: bool,
5726 #[case] settle: bool,
5727 #[case] expected: bool,
5728 ) {
5729 let instrument = InstrumentAny::BinaryOption(binary_option);
5730 let fill = binary_fill(&instrument, "T-OPEN", "10.00", "0.400", 100);
5731 let mut position = Position::new(&instrument, fill.clone());
5732
5733 if void_fill {
5734 position
5735 .apply_fill_void(
5736 matching_fill_void(&fill, Quantity::from("2.00"), None),
5737 Quantity::from("2.00"),
5738 None,
5739 )
5740 .unwrap();
5741 }
5742
5743 if settle {
5744 position
5745 .apply_instrument_close(contract_expired(&instrument, "1.000"))
5746 .unwrap();
5747 }
5748
5749 assert_eq!(position.requires_replay_state(), expected);
5750 }
5751
5752 #[rstest]
5753 #[case::other_instrument(Some("OTHER.POLYMARKET"), InstrumentCloseType::ContractExpired, false)]
5754 #[case::end_of_session(None, InstrumentCloseType::EndOfSession, false)]
5755 #[case::not_open(None, InstrumentCloseType::ContractExpired, true)]
5756 fn test_apply_instrument_close_rejects_without_mutation(
5757 binary_option: BinaryOption,
5758 #[case] instrument_id: Option<&str>,
5759 #[case] close_type: InstrumentCloseType,
5760 #[case] settle_first: bool,
5761 ) {
5762 let instrument = InstrumentAny::BinaryOption(binary_option);
5763 let fill = binary_fill(&instrument, "T-OPEN", "10.00", "0.400", 100);
5764 let mut position = Position::new(&instrument, fill);
5765
5766 if settle_first {
5767 position
5768 .apply_instrument_close(contract_expired(&instrument, "1.000"))
5769 .unwrap();
5770 }
5771
5772 let before = serde_json::to_value(&position).unwrap();
5773
5774 let close = InstrumentClose::new(
5775 instrument_id.map_or_else(|| instrument.id(), InstrumentId::from),
5776 Price::from("1.000"),
5777 close_type,
5778 UnixNanos::from(400),
5779 UnixNanos::from(401),
5780 );
5781
5782 let result = position.apply_instrument_close(close);
5783
5784 assert!(result.is_err());
5785 assert_eq!(serde_json::to_value(&position).unwrap(), before);
5786 }
5787
5788 #[rstest]
5789 fn test_fill_void_replays_instrument_close(binary_option: BinaryOption) {
5790 let instrument = InstrumentAny::BinaryOption(binary_option);
5791 let opening_fill = binary_fill(&instrument, "T-OPEN", "10.00", "0.400", 100);
5792 let adding_fill = binary_fill(&instrument, "T-ADD", "5.00", "0.500", 200);
5793 let fill_voided = matching_fill_void(&adding_fill, Quantity::from("5.00"), None);
5794 let mut position = Position::new(&instrument, opening_fill);
5795 position.apply(&adding_fill);
5796 let close = contract_expired(&instrument, "1.000");
5797 position.apply_instrument_close(close).unwrap();
5798
5799 let closed_cycles_pnl = position
5800 .apply_fill_void(fill_voided, Quantity::from("5.00"), None)
5801 .unwrap();
5802
5803 assert_eq!(closed_cycles_pnl, None);
5804 assert!(position.is_closed());
5805 assert!(position.is_settled());
5806 assert_eq!(position.quantity, Quantity::from("0.00"));
5807 assert_eq!(position.peak_qty, Quantity::from("10.00"));
5808 assert_eq!(position.buy_qty, Quantity::from("10.00"));
5809 assert_eq!(position.realized_pnl.unwrap().as_decimal(), dec!(5.80));
5810 assert_eq!(position.ts_closed, Some(UnixNanos::from(300)));
5811 assert_eq!(position.fill_voids.len(), 1);
5812 assert_eq!(position.replay_events.len(), 3);
5813 }
5814
5815 #[rstest]
5816 fn test_fill_void_returns_all_closed_cycle_pnl(audusd_sim: CurrencyPair) {
5817 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5818 let position_id = PositionId::from("P-VOID-CYCLES");
5819 let fill = |client_order_id: &str,
5820 trade_id: &str,
5821 order_side: OrderSide,
5822 quantity: u32,
5823 price: &str,
5824 ts_event: u64| {
5825 OrderFilledSpec::builder()
5826 .instrument_id(instrument.id())
5827 .client_order_id(ClientOrderId::from(client_order_id))
5828 .trade_id(TradeId::from(trade_id))
5829 .order_side(order_side)
5830 .last_qty(Quantity::from(quantity))
5831 .last_px(Price::from(price))
5832 .currency(Currency::USD())
5833 .position_id(position_id)
5834 .ts_event(UnixNanos::from(ts_event))
5835 .build()
5836 };
5837 let fills = [
5838 fill("O-OPEN-1", "T-OPEN-1", OrderSide::Buy, 10, "1.0", 1),
5839 fill("O-CLOSE-1", "T-CLOSE-1", OrderSide::Sell, 10, "2.0", 2),
5840 fill("O-OPEN-2", "T-OPEN-2", OrderSide::Buy, 10, "3.0", 3),
5841 fill("O-CLOSE-2", "T-CLOSE-2", OrderSide::Sell, 10, "5.0", 4),
5842 fill("O-CURRENT", "T-CURRENT", OrderSide::Buy, 5, "6.0", 5),
5843 ];
5844 let current = fills.last().unwrap();
5845 let fill_voided = matching_fill_void(current, Quantity::from(1), None);
5846 let mut position = Position::new(&instrument, fills[0].clone());
5847 for fill in &fills[1..] {
5848 position.apply(fill);
5849 }
5850
5851 let closed_cycles_pnl = position
5852 .apply_fill_void(fill_voided, Quantity::from(1), None)
5853 .unwrap();
5854
5855 assert_eq!(closed_cycles_pnl, Some(Money::from("30.00 USD")));
5856 assert_eq!(position.side, PositionSide::Long);
5857 assert_eq!(position.quantity, Quantity::from(4));
5858 assert_eq!(position.avg_px_open, dec!(6.0));
5859 assert_eq!(position.realized_pnl, Some(Money::from("0.00 USD")));
5860 assert_eq!(position.events.len(), 1);
5861 assert_eq!(position.replay_events.len(), 5);
5862 assert_eq!(position.fill_voids.len(), 1);
5863 }
5864
5865 #[rstest]
5866 fn test_full_fill_void_preserves_unvoided_commission(audusd_sim: CurrencyPair) {
5867 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5868 let position_id = PositionId::from("P-FEE-VOID");
5869 let fill = OrderFilledSpec::builder()
5870 .instrument_id(instrument.id())
5871 .client_order_id(ClientOrderId::from("O-FEE"))
5872 .trade_id(TradeId::from("T-FEE"))
5873 .order_side(OrderSide::Buy)
5874 .last_qty(Quantity::from(10))
5875 .last_px(Price::from("1.00000"))
5876 .currency(Currency::USD())
5877 .position_id(position_id)
5878 .commission(Money::from("1.00 USD"))
5879 .build();
5880 let fill_voided = OrderFillVoidedSpec::builder()
5881 .instrument_id(fill.instrument_id)
5882 .client_order_id(fill.client_order_id)
5883 .venue_order_id(fill.venue_order_id)
5884 .account_id(fill.account_id)
5885 .trade_id(fill.trade_id)
5886 .voided_qty(fill.last_qty)
5887 .order_side(fill.order_side)
5888 .order_type(fill.order_type)
5889 .last_px(fill.last_px)
5890 .currency(fill.currency)
5891 .liquidity_side(fill.liquidity_side)
5892 .build();
5893 let mut position = Position::new(&instrument, fill);
5894
5895 position
5896 .apply_fill_void(fill_voided, Quantity::from(10), None)
5897 .unwrap();
5898
5899 assert_eq!(position.side, PositionSide::Flat);
5900 assert_eq!(position.quantity, Quantity::from(0));
5901 assert_eq!(position.commissions(), vec![Money::from("1.00 USD")]);
5902 assert_eq!(position.realized_pnl, Some(Money::from("-1.00 USD")));
5903 assert!(position.events.is_empty());
5904 }
5905
5906 #[rstest]
5907 fn test_full_fill_void_preserves_unvoided_base_commission() {
5908 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
5909 let position_id = PositionId::from("P-BASE-FEE-VOID");
5910 let fill = OrderFilledSpec::builder()
5911 .instrument_id(instrument.id())
5912 .client_order_id(ClientOrderId::from("O-BASE-FEE"))
5913 .trade_id(TradeId::from("T-BASE-FEE"))
5914 .order_side(OrderSide::Buy)
5915 .last_qty(Quantity::from("1.000000"))
5916 .last_px(Price::from("50000.00"))
5917 .currency(Currency::USDT())
5918 .position_id(position_id)
5919 .commission(Money::from("0.00100000 BTC"))
5920 .ts_event(UnixNanos::from(2_000))
5921 .ts_init(UnixNanos::from(1_900))
5922 .build();
5923 let fill_voided = matching_fill_void(&fill, fill.last_qty, None);
5924 let mut position = Position::new(&instrument, fill);
5925
5926 let closed_cycles_pnl = position
5927 .apply_fill_void(fill_voided, Quantity::from("1.000000"), None)
5928 .unwrap();
5929
5930 assert_eq!(closed_cycles_pnl, None);
5931 assert_eq!(position.entry, OrderSide::Sell);
5932 assert_eq!(position.side, PositionSide::Short);
5933 assert_eq!(position.signed_decimal_qty(), dec!(-0.001));
5934 assert_eq!(position.quantity.as_decimal(), dec!(0.001));
5935 assert_eq!(position.buy_qty.as_decimal(), Decimal::ZERO);
5936 assert_eq!(position.sell_qty.as_decimal(), Decimal::ZERO);
5937 assert_eq!(position.commissions(), vec![Money::from("0.00100000 BTC")]);
5938 assert_eq!(position.adjustments.len(), 1);
5939 assert_eq!(
5940 position.adjustments[0].adjustment_type,
5941 PositionAdjustmentType::Commission
5942 );
5943 assert_eq!(position.adjustments[0].quantity_change, Some(dec!(-0.001)));
5944 assert_eq!(position.opening_order_id, ClientOrderId::from("O-BASE-FEE"));
5945 assert_eq!(position.closing_order_id, None);
5946 assert_eq!(position.ts_init, UnixNanos::from(1_900));
5947 assert_eq!(position.ts_opened, UnixNanos::from(2_000));
5948 assert_eq!(position.ts_last, UnixNanos::from(2_000));
5949 assert_eq!(position.ts_closed, None);
5950 assert_eq!(position.duration_ns, DurationNanos::default());
5951 assert_eq!(position.avg_px_open, dec!(50_000.0));
5952 assert_eq!(position.avg_px_close, None);
5953 assert_eq!(position.realized_pnl, None);
5954 assert!(position.events.is_empty());
5955 assert!(position.is_open());
5956 assert!(!position.is_closed());
5957 }
5958
5959 #[rstest]
5960 fn test_surviving_base_commission_can_close_position() {
5961 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
5962 let position_id = PositionId::from("P-BASE-FEE-CLOSE");
5963 let opening = OrderFilledSpec::builder()
5964 .instrument_id(instrument.id())
5965 .client_order_id(ClientOrderId::from("O-BASE-OPEN"))
5966 .trade_id(TradeId::from("T-BASE-OPEN"))
5967 .order_side(OrderSide::Buy)
5968 .last_qty(Quantity::from("0.001000"))
5969 .last_px(Price::from("50000.00"))
5970 .currency(Currency::USDT())
5971 .position_id(position_id)
5972 .ts_event(UnixNanos::from(1_000))
5973 .ts_init(UnixNanos::from(900))
5974 .build();
5975 let fee_fill = OrderFilledSpec::builder()
5976 .instrument_id(instrument.id())
5977 .client_order_id(ClientOrderId::from("O-BASE-FEE"))
5978 .trade_id(TradeId::from("T-BASE-FEE"))
5979 .order_side(OrderSide::Buy)
5980 .last_qty(Quantity::from("1.000000"))
5981 .last_px(Price::from("51000.00"))
5982 .currency(Currency::USDT())
5983 .position_id(position_id)
5984 .commission(Money::from("0.00100000 BTC"))
5985 .ts_event(UnixNanos::from(2_000))
5986 .ts_init(UnixNanos::from(1_900))
5987 .build();
5988 let fill_voided = matching_fill_void(&fee_fill, fee_fill.last_qty, None);
5989 let mut position = Position::new(&instrument, opening);
5990 position.apply(&fee_fill);
5991
5992 let closed_cycles_pnl = position
5993 .apply_fill_void(fill_voided, Quantity::from("1.000000"), None)
5994 .unwrap();
5995
5996 assert_eq!(closed_cycles_pnl, None);
5997 assert_eq!(position.entry, OrderSide::Buy);
5998 assert_eq!(position.side, PositionSide::Flat);
5999 assert_eq!(position.signed_decimal_qty(), Decimal::ZERO);
6000 assert_eq!(position.quantity.as_decimal(), Decimal::ZERO);
6001 assert_eq!(position.buy_qty.as_decimal(), dec!(0.001));
6002 assert_eq!(position.sell_qty.as_decimal(), Decimal::ZERO);
6003 assert_eq!(position.commissions(), vec![Money::from("0.00100000 BTC")]);
6004 assert_eq!(position.events.len(), 1);
6005 assert_eq!(position.adjustments.len(), 1);
6006 assert_eq!(
6007 position.opening_order_id,
6008 ClientOrderId::from("O-BASE-OPEN")
6009 );
6010 assert_eq!(
6011 position.closing_order_id,
6012 Some(ClientOrderId::from("O-BASE-FEE"))
6013 );
6014 assert_eq!(position.ts_init, UnixNanos::from(900));
6015 assert_eq!(position.ts_opened, UnixNanos::from(1_000));
6016 assert_eq!(position.ts_last, UnixNanos::from(2_000));
6017 assert_eq!(position.ts_closed, Some(UnixNanos::from(2_000)));
6018 assert_eq!(position.duration_ns, DurationNanos::new(1_000));
6019 assert_eq!(position.avg_px_open, dec!(50_000.0));
6020 assert_eq!(position.avg_px_close, None);
6021 assert_eq!(position.realized_pnl, Some(Money::from("0.00 USDT")));
6022 assert!(!position.is_open());
6023 assert!(position.is_closed());
6024 }
6025
6026 #[rstest]
6027 fn test_surviving_base_commission_reopen_returns_previous_cycle_pnl() {
6028 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
6029 let position_id = PositionId::from("P-BASE-FEE-REOPEN");
6030 let opening = OrderFilledSpec::builder()
6031 .instrument_id(instrument.id())
6032 .client_order_id(ClientOrderId::from("O-OPEN"))
6033 .trade_id(TradeId::from("T-OPEN"))
6034 .order_side(OrderSide::Buy)
6035 .last_qty(Quantity::from("1.000000"))
6036 .last_px(Price::from("50000.00"))
6037 .currency(Currency::USDT())
6038 .position_id(position_id)
6039 .ts_event(UnixNanos::from(1_000))
6040 .build();
6041 let closing = OrderFilledSpec::builder()
6042 .instrument_id(instrument.id())
6043 .client_order_id(ClientOrderId::from("O-CLOSE"))
6044 .trade_id(TradeId::from("T-CLOSE"))
6045 .order_side(OrderSide::Sell)
6046 .last_qty(Quantity::from("1.000000"))
6047 .last_px(Price::from("51000.00"))
6048 .currency(Currency::USDT())
6049 .position_id(position_id)
6050 .ts_event(UnixNanos::from(2_000))
6051 .build();
6052 let reopening = OrderFilledSpec::builder()
6053 .instrument_id(instrument.id())
6054 .client_order_id(ClientOrderId::from("O-REOPEN"))
6055 .trade_id(TradeId::from("T-REOPEN"))
6056 .order_side(OrderSide::Buy)
6057 .last_qty(Quantity::from("1.000000"))
6058 .last_px(Price::from("52000.00"))
6059 .currency(Currency::USDT())
6060 .position_id(position_id)
6061 .commission(Money::from("0.00100000 BTC"))
6062 .ts_event(UnixNanos::from(3_000))
6063 .ts_init(UnixNanos::from(2_900))
6064 .build();
6065 let fill_voided = matching_fill_void(&reopening, reopening.last_qty, None);
6066 let mut position = Position::new(&instrument, opening);
6067 position.apply(&closing);
6068 position.apply(&reopening);
6069
6070 let closed_cycles_pnl = position
6071 .apply_fill_void(fill_voided, Quantity::from("1.000000"), None)
6072 .unwrap();
6073
6074 assert_eq!(closed_cycles_pnl, Some(Money::from("1000.00 USDT")));
6075 assert_eq!(position.entry, OrderSide::Sell);
6076 assert_eq!(position.side, PositionSide::Short);
6077 assert_eq!(position.signed_decimal_qty(), dec!(-0.001));
6078 assert_eq!(position.quantity.as_decimal(), dec!(0.001));
6079 assert_eq!(position.buy_qty.as_decimal(), Decimal::ZERO);
6080 assert_eq!(position.sell_qty.as_decimal(), Decimal::ZERO);
6081 assert_eq!(position.commissions(), vec![Money::from("0.00100000 BTC")]);
6082 assert!(position.events.is_empty());
6083 assert_eq!(position.adjustments.len(), 1);
6084 assert_eq!(position.opening_order_id, ClientOrderId::from("O-REOPEN"));
6085 assert_eq!(position.closing_order_id, None);
6086 assert_eq!(position.ts_init, UnixNanos::from(2_900));
6087 assert_eq!(position.ts_opened, UnixNanos::from(3_000));
6088 assert_eq!(position.ts_last, UnixNanos::from(3_000));
6089 assert_eq!(position.ts_closed, None);
6090 assert_eq!(position.duration_ns, DurationNanos::default());
6091 assert_eq!(position.avg_px_open, dec!(52_000.0));
6092 assert_eq!(position.avg_px_close, None);
6093 assert_eq!(position.realized_pnl, None);
6094 assert!(position.is_open());
6095 assert!(!position.is_closed());
6096 }
6097
6098 #[rstest]
6099 fn test_surviving_base_commission_can_flip_position() {
6100 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
6101 let position_id = PositionId::from("P-BASE-FEE-FLIP");
6102 let opening = OrderFilledSpec::builder()
6103 .instrument_id(instrument.id())
6104 .client_order_id(ClientOrderId::from("O-BASE-OPEN"))
6105 .trade_id(TradeId::from("T-BASE-OPEN"))
6106 .order_side(OrderSide::Buy)
6107 .last_qty(Quantity::from("0.000500"))
6108 .last_px(Price::from("50000.00"))
6109 .currency(Currency::USDT())
6110 .position_id(position_id)
6111 .ts_event(UnixNanos::from(1_000))
6112 .ts_init(UnixNanos::from(900))
6113 .build();
6114 let fee_fill = OrderFilledSpec::builder()
6115 .instrument_id(instrument.id())
6116 .client_order_id(ClientOrderId::from("O-BASE-FEE"))
6117 .trade_id(TradeId::from("T-BASE-FEE"))
6118 .order_side(OrderSide::Buy)
6119 .last_qty(Quantity::from("1.000000"))
6120 .last_px(Price::from("52000.00"))
6121 .currency(Currency::USDT())
6122 .position_id(position_id)
6123 .commission(Money::from("0.00100000 BTC"))
6124 .ts_event(UnixNanos::from(2_000))
6125 .ts_init(UnixNanos::from(1_900))
6126 .build();
6127 let fill_voided = matching_fill_void(&fee_fill, fee_fill.last_qty, None);
6128 let mut position = Position::new(&instrument, opening);
6129 position.apply(&fee_fill);
6130
6131 let closed_cycles_pnl = position
6132 .apply_fill_void(fill_voided, Quantity::from("1.000000"), None)
6133 .unwrap();
6134
6135 assert_eq!(closed_cycles_pnl, None);
6136 assert_eq!(position.entry, OrderSide::Sell);
6137 assert_eq!(position.side, PositionSide::Short);
6138 assert_eq!(position.signed_decimal_qty(), dec!(-0.0005));
6139 assert_eq!(position.quantity.as_decimal(), dec!(0.0005));
6140 assert_eq!(position.buy_qty.as_decimal(), dec!(0.0005));
6141 assert_eq!(position.sell_qty.as_decimal(), Decimal::ZERO);
6142 assert_eq!(position.commissions(), vec![Money::from("0.00100000 BTC")]);
6143 assert_eq!(position.events.len(), 1);
6144 assert_eq!(position.adjustments.len(), 1);
6145 assert_eq!(
6146 position.opening_order_id,
6147 ClientOrderId::from("O-BASE-OPEN")
6148 );
6149 assert_eq!(position.closing_order_id, None);
6150 assert_eq!(position.ts_init, UnixNanos::from(900));
6151 assert_eq!(position.ts_opened, UnixNanos::from(1_000));
6152 assert_eq!(position.ts_last, UnixNanos::from(2_000));
6153 assert_eq!(position.ts_closed, None);
6154 assert_eq!(position.duration_ns, DurationNanos::default());
6155 assert_eq!(position.avg_px_open, dec!(52_000.0));
6156 assert_eq!(position.avg_px_close, None);
6157 assert_eq!(position.realized_pnl, Some(Money::from("0.00 USDT")));
6158 assert!(position.is_open());
6159 assert!(!position.is_closed());
6160 }
6161
6162 #[rstest]
6163 fn test_fill_void_replays_netting_flip_fragments_with_one_trade_id(audusd_sim: CurrencyPair) {
6164 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
6165 let position_id = PositionId::from("P-FLIP-VOID");
6166 let opening = OrderFilledSpec::builder()
6167 .instrument_id(instrument.id())
6168 .client_order_id(ClientOrderId::from("O-OPEN"))
6169 .trade_id(TradeId::from("T-OPEN"))
6170 .order_side(OrderSide::Buy)
6171 .last_qty(Quantity::from(10))
6172 .last_px(Price::from("1.00000"))
6173 .currency(Currency::USD())
6174 .position_id(position_id)
6175 .build();
6176 let closing = OrderFilledSpec::builder()
6177 .instrument_id(instrument.id())
6178 .client_order_id(ClientOrderId::from("O-FLIP"))
6179 .trade_id(TradeId::from("T-FLIP"))
6180 .order_side(OrderSide::Sell)
6181 .last_qty(Quantity::from(10))
6182 .last_px(Price::from("1.10000"))
6183 .currency(Currency::USD())
6184 .position_id(position_id)
6185 .build();
6186 let mut reopening = closing.clone();
6187 reopening.last_qty = Quantity::from(5);
6188 reopening.event_id = uuid4();
6189 reopening.causation_id = Some(closing.event_id);
6190 let fill_voided = matching_fill_void(&closing, Quantity::from(12), None);
6191 let mut position = Position::new(&instrument, opening);
6192 position.apply(&closing);
6193 assert!(!position.is_duplicate_replay_fill(&reopening));
6194 position.apply(&reopening);
6195
6196 position
6197 .apply_fill_void(fill_voided, Quantity::from(12), None)
6198 .unwrap();
6199
6200 assert_eq!(position.side, PositionSide::Long);
6201 assert_eq!(position.quantity, Quantity::from(7));
6202 assert_eq!(position.buy_qty, Quantity::from(10));
6203 assert_eq!(position.sell_qty, Quantity::from(3));
6204 assert_eq!(position.replay_events.len(), 3);
6205 assert!(position.is_duplicate_replay_fill(&reopening));
6206 }
6207
6208 #[rstest]
6209 fn test_fill_void_replays_split_fragments_in_one_corrected_cycle(audusd_sim: CurrencyPair) {
6210 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
6211 let position_id = PositionId::from("P-FLIP-CYCLE-VOID");
6212 let opening = OrderFilledSpec::builder()
6213 .instrument_id(instrument.id())
6214 .client_order_id(ClientOrderId::from("O-SELL-1"))
6215 .trade_id(TradeId::from("T-SELL-1"))
6216 .order_side(OrderSide::Sell)
6217 .last_qty(Quantity::from(17))
6218 .last_px(Price::from("1.00000"))
6219 .currency(Currency::USD())
6220 .position_id(position_id)
6221 .build();
6222 let second_sell = OrderFilledSpec::builder()
6223 .instrument_id(instrument.id())
6224 .client_order_id(ClientOrderId::from("O-SELL-2"))
6225 .trade_id(TradeId::from("T-SELL-2"))
6226 .order_side(OrderSide::Sell)
6227 .last_qty(Quantity::from(17))
6228 .last_px(Price::from("1.00000"))
6229 .currency(Currency::USD())
6230 .position_id(position_id)
6231 .build();
6232 let closing = OrderFilledSpec::builder()
6233 .instrument_id(instrument.id())
6234 .client_order_id(ClientOrderId::from("O-FLIP"))
6235 .trade_id(TradeId::from("T-FLIP"))
6236 .order_side(OrderSide::Buy)
6237 .last_qty(Quantity::from(34))
6238 .last_px(Price::from("1.10000"))
6239 .currency(Currency::USD())
6240 .position_id(position_id)
6241 .build();
6242 let mut reopening = closing.clone();
6243 reopening.last_qty = Quantity::from(591);
6244 reopening.event_id = uuid4();
6245 reopening.causation_id = Some(closing.event_id);
6246 let fill_voided = matching_fill_void(&second_sell, Quantity::from(2), None);
6247 let mut position = Position::new(&instrument, opening);
6248 position.apply(&second_sell);
6249 position.apply(&closing);
6250 position.apply(&reopening);
6251
6252 position
6253 .apply_fill_void(fill_voided, Quantity::from(2), None)
6254 .unwrap();
6255
6256 assert_eq!(position.side, PositionSide::Long);
6257 assert_eq!(position.quantity, Quantity::from(593));
6258 assert_eq!(position.buy_qty, Quantity::from(593));
6260 assert_eq!(position.sell_qty, Quantity::from(0));
6261 assert_eq!(position.events.len(), 4);
6262 assert_eq!(position.replay_events.len(), 4);
6263 assert_eq!(position.fill_voids.len(), 1);
6264 assert_eq!(position.trade_ids.len(), 3);
6265 assert!(position.trade_ids.contains(&TradeId::from("T-FLIP")));
6266 }
6267
6268 #[rstest]
6269 fn test_fill_void_replays_partially_voided_reversal(audusd_sim: CurrencyPair) {
6270 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
6271 let position_id = PositionId::from("P-REVERSAL-PARTIAL-VOID");
6272 let opening = OrderFilledSpec::builder()
6273 .instrument_id(instrument.id())
6274 .client_order_id(ClientOrderId::from("O-OPEN"))
6275 .trade_id(TradeId::from("T-OPEN"))
6276 .order_side(OrderSide::Sell)
6277 .last_qty(Quantity::from(10))
6278 .last_px(Price::from("1.00000"))
6279 .currency(Currency::USD())
6280 .position_id(position_id)
6281 .build();
6282 let partial_close = OrderFilledSpec::builder()
6283 .instrument_id(instrument.id())
6284 .client_order_id(ClientOrderId::from("O-CLOSE"))
6285 .trade_id(TradeId::from("T-CLOSE"))
6286 .order_side(OrderSide::Buy)
6287 .last_qty(Quantity::from(4))
6288 .last_px(Price::from("0.90000"))
6289 .currency(Currency::USD())
6290 .position_id(position_id)
6291 .build();
6292 let reversal = OrderFilledSpec::builder()
6293 .instrument_id(instrument.id())
6294 .client_order_id(ClientOrderId::from("O-REVERSE"))
6295 .trade_id(TradeId::from("T-REVERSE"))
6296 .order_side(OrderSide::Buy)
6297 .last_qty(Quantity::from(11))
6298 .last_px(Price::from("1.10000"))
6299 .currency(Currency::USD())
6300 .position_id(position_id)
6301 .build();
6302 let fill_voided = matching_fill_void(&reversal, Quantity::from(2), None);
6303 let mut position = Position::new(&instrument, opening);
6304 position.apply(&partial_close);
6305 position.apply(&reversal);
6306
6307 position
6308 .apply_fill_void(fill_voided, Quantity::from(2), None)
6309 .unwrap();
6310
6311 assert_eq!(position.side, PositionSide::Long);
6312 assert_eq!(position.quantity, Quantity::from(3));
6313 assert_eq!(position.avg_px_open, dec!(1.1));
6314 assert_eq!(position.avg_px_close, None);
6315 assert_eq!(position.realized_return, 0.0);
6316 assert_eq!(position.buy_qty, Quantity::from(3));
6317 assert_eq!(position.sell_qty, Quantity::from(0));
6318 assert_eq!(position.realized_pnl, Some(Money::from("-0.20 USD")));
6319 assert_eq!(position.fill_voids.len(), 1);
6320 }
6321
6322 #[rstest]
6323 fn test_position_realized_pnl_with_interleaved_order_sides(
6324 currency_pair_btcusdt: CurrencyPair,
6325 ) {
6326 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
6327 let order1 = OrderTestBuilder::new(OrderType::Market)
6328 .instrument_id(btcusdt.id())
6329 .side(OrderSide::Buy)
6330 .quantity(Quantity::from(12))
6331 .build();
6332 let commission1 = calculate_commission(
6333 &btcusdt,
6334 order1.quantity(),
6335 Price::from("10000.0"),
6336 None,
6337 dec!(0.001),
6338 );
6339 let fill1 = TestOrderEventStubs::filled(
6340 &order1,
6341 &btcusdt,
6342 Some(TradeId::from("1")),
6343 Some(PositionId::from("P-19700101-000000-001-001-1")),
6344 Some(Price::from("10000.0")),
6345 None,
6346 None,
6347 Some(commission1),
6348 None,
6349 None,
6350 );
6351 let mut position = Position::new(&btcusdt, fill1.into());
6352 let order2 = OrderTestBuilder::new(OrderType::Market)
6353 .instrument_id(btcusdt.id())
6354 .side(OrderSide::Buy)
6355 .quantity(Quantity::from(17))
6356 .build();
6357 let commission2 = calculate_commission(
6358 &btcusdt,
6359 order2.quantity(),
6360 Price::from("9999.0"),
6361 None,
6362 dec!(0.001),
6363 );
6364 let fill2 = TestOrderEventStubs::filled(
6365 &order2,
6366 &btcusdt,
6367 Some(TradeId::from("2")),
6368 Some(PositionId::from("P-19700101-000000-001-001-1")),
6369 Some(Price::from("9999.0")),
6370 None,
6371 None,
6372 Some(commission2),
6373 None,
6374 None,
6375 );
6376 position.apply(&fill2.into());
6377 assert_eq!(position.quantity, Quantity::from(29));
6378 assert_eq!(
6379 position.realized_pnl,
6380 Some(Money::from("-289.98300000 USDT"))
6381 );
6382 assert_eq!(position.avg_px_open, (dec!(289983) / dec!(29)));
6383 let order3 = OrderTestBuilder::new(OrderType::Market)
6384 .instrument_id(btcusdt.id())
6385 .side(OrderSide::Sell)
6386 .quantity(Quantity::from(9))
6387 .build();
6388 let commission3 = calculate_commission(
6389 &btcusdt,
6390 order3.quantity(),
6391 Price::from("10001.0"),
6392 None,
6393 dec!(0.001),
6394 );
6395 let fill3 = TestOrderEventStubs::filled(
6396 &order3,
6397 &btcusdt,
6398 Some(TradeId::from("3")),
6399 Some(PositionId::from("P-19700101-000000-001-001-1")),
6400 Some(Price::from("10001.0")),
6401 None,
6402 None,
6403 Some(commission3),
6404 None,
6405 None,
6406 );
6407 position.apply(&fill3.into());
6408 assert_eq!(position.quantity, Quantity::from(20));
6409 assert_eq!(
6410 position.realized_pnl,
6411 Some(native_money(dec!(-1325721) / dec!(3625), Currency::USDT()))
6412 );
6413 assert_eq!(position.avg_px_open, (dec!(289983) / dec!(29)));
6414 let order4 = OrderTestBuilder::new(OrderType::Market)
6415 .instrument_id(btcusdt.id())
6416 .side(OrderSide::Buy)
6417 .quantity(Quantity::from(3))
6418 .build();
6419 let commission4 = calculate_commission(
6420 &btcusdt,
6421 order4.quantity(),
6422 Price::from("10003.0"),
6423 None,
6424 dec!(0.001),
6425 );
6426 let fill4 = TestOrderEventStubs::filled(
6427 &order4,
6428 &btcusdt,
6429 Some(TradeId::from("4")),
6430 Some(PositionId::from("P-19700101-000000-001-001-1")),
6431 Some(Price::from("10003.0")),
6432 None,
6433 None,
6434 Some(commission4),
6435 None,
6436 None,
6437 );
6438 position.apply(&fill4.into());
6439 assert_eq!(position.quantity, Quantity::from(23));
6440 assert_eq!(
6441 position.realized_pnl,
6442 Some(native_money(
6443 dec!(-11476029) / dec!(29000),
6444 Currency::USDT()
6445 ))
6446 );
6447 assert_eq!(position.avg_px_open, (dec!(6669921) / dec!(667)));
6448 let order5 = OrderTestBuilder::new(OrderType::Market)
6449 .instrument_id(btcusdt.id())
6450 .side(OrderSide::Sell)
6451 .quantity(Quantity::from(4))
6452 .build();
6453 let commission5 = calculate_commission(
6454 &btcusdt,
6455 order5.quantity(),
6456 Price::from("10005.0"),
6457 None,
6458 dec!(0.001),
6459 );
6460 let fill5 = TestOrderEventStubs::filled(
6461 &order5,
6462 &btcusdt,
6463 Some(TradeId::from("5")),
6464 Some(PositionId::from("P-19700101-000000-001-001-1")),
6465 Some(Price::from("10005.0")),
6466 None,
6467 None,
6468 Some(commission5),
6469 None,
6470 None,
6471 );
6472 position.apply(&fill5.into());
6473 assert_eq!(position.quantity, Quantity::from(19));
6474 assert_eq!(
6475 position.realized_pnl,
6476 Some(native_money(
6477 dec!(-276986007) / dec!(667000),
6478 Currency::USDT()
6479 ))
6480 );
6481 assert_eq!(position.avg_px_open, (dec!(6669921) / dec!(667)));
6482 assert_eq!(
6483 format!("{position}"),
6484 "Position(LONG 19.000000 BTCUSDT.BINANCE, id=P-19700101-000000-001-001-1)"
6485 );
6486 }
6487
6488 #[rstest]
6489 fn test_calculate_pnl_when_given_position_side_flat_returns_zero(
6490 currency_pair_btcusdt: CurrencyPair,
6491 ) {
6492 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
6493 let order = OrderTestBuilder::new(OrderType::Market)
6494 .instrument_id(btcusdt.id())
6495 .side(OrderSide::Buy)
6496 .quantity(Quantity::from(12))
6497 .build();
6498 let fill = TestOrderEventStubs::filled(
6499 &order,
6500 &btcusdt,
6501 None,
6502 Some(PositionId::from("P-123456")),
6503 Some(Price::from("10500.0")),
6504 None,
6505 None,
6506 None,
6507 None,
6508 None,
6509 );
6510 let position = Position::new(&btcusdt, fill.into());
6511 let result = position
6512 .calculate_pnl(dec!(10500.0), dec!(10500.0), Quantity::from("100000.0"))
6513 .unwrap();
6514 assert_eq!(result, Money::from("0 USDT"));
6515 }
6516
6517 #[rstest]
6518 fn test_calculate_pnl_for_long_position_win(currency_pair_btcusdt: CurrencyPair) {
6519 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
6520 let order = OrderTestBuilder::new(OrderType::Market)
6521 .instrument_id(btcusdt.id())
6522 .side(OrderSide::Buy)
6523 .quantity(Quantity::from(12))
6524 .build();
6525 let commission = calculate_commission(
6526 &btcusdt,
6527 order.quantity(),
6528 Price::from("10500.0"),
6529 None,
6530 dec!(0.001),
6531 );
6532 let fill = TestOrderEventStubs::filled(
6533 &order,
6534 &btcusdt,
6535 None,
6536 Some(PositionId::from("P-123456")),
6537 Some(Price::from("10500.0")),
6538 None,
6539 None,
6540 Some(commission),
6541 None,
6542 None,
6543 );
6544 let position = Position::new(&btcusdt, fill.into());
6545 let pnl = position
6546 .calculate_pnl(dec!(10500.0), dec!(10510.0), Quantity::from("12.0"))
6547 .unwrap();
6548 assert_eq!(pnl, Money::from("120 USDT"));
6549 assert_eq!(position.realized_pnl, Some(Money::from("-126 USDT")));
6550 assert_eq!(
6551 position.unrealized_pnl(Price::from("10510.0")).unwrap(),
6552 Money::from("120.0 USDT")
6553 );
6554 assert_eq!(
6555 position.total_pnl(Price::from("10510.0")).unwrap(),
6556 Money::from("-6 USDT")
6557 );
6558 assert_eq!(position.commissions(), vec![Money::from("126.0 USDT")]);
6559 }
6560
6561 #[rstest]
6562 fn test_calculate_pnl_for_long_position_loss(currency_pair_btcusdt: CurrencyPair) {
6563 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
6564 let order = OrderTestBuilder::new(OrderType::Market)
6565 .instrument_id(btcusdt.id())
6566 .side(OrderSide::Buy)
6567 .quantity(Quantity::from(12))
6568 .build();
6569 let commission = calculate_commission(
6570 &btcusdt,
6571 order.quantity(),
6572 Price::from("10500.0"),
6573 None,
6574 dec!(0.001),
6575 );
6576 let fill = TestOrderEventStubs::filled(
6577 &order,
6578 &btcusdt,
6579 None,
6580 Some(PositionId::from("P-123456")),
6581 Some(Price::from("10500.0")),
6582 None,
6583 None,
6584 Some(commission),
6585 None,
6586 None,
6587 );
6588 let position = Position::new(&btcusdt, fill.into());
6589 let pnl = position
6590 .calculate_pnl(dec!(10500.0), dec!(10480.5), Quantity::from("10.0"))
6591 .unwrap();
6592 assert_eq!(pnl, Money::from("-195 USDT"));
6593 assert_eq!(position.realized_pnl, Some(Money::from("-126 USDT")));
6594 assert_eq!(
6595 position.unrealized_pnl(Price::from("10480.50")).unwrap(),
6596 Money::from("-234.0 USDT")
6597 );
6598 assert_eq!(
6599 position.total_pnl(Price::from("10480.50")).unwrap(),
6600 Money::from("-360 USDT")
6601 );
6602 assert_eq!(position.commissions(), vec![Money::from("126.0 USDT")]);
6603 }
6604
6605 #[rstest]
6606 fn test_calculate_pnl_for_short_position_winning(currency_pair_btcusdt: CurrencyPair) {
6607 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
6608 let order = OrderTestBuilder::new(OrderType::Market)
6609 .instrument_id(btcusdt.id())
6610 .side(OrderSide::Sell)
6611 .quantity(Quantity::from("10.15"))
6612 .build();
6613 let commission = calculate_commission(
6614 &btcusdt,
6615 order.quantity(),
6616 Price::from("10500.0"),
6617 None,
6618 dec!(0.001),
6619 );
6620 let fill = TestOrderEventStubs::filled(
6621 &order,
6622 &btcusdt,
6623 None,
6624 Some(PositionId::from("P-123456")),
6625 Some(Price::from("10500.0")),
6626 None,
6627 None,
6628 Some(commission),
6629 None,
6630 None,
6631 );
6632 let position = Position::new(&btcusdt, fill.into());
6633 let pnl = position
6634 .calculate_pnl(dec!(10500.0), dec!(10390.0), Quantity::from("10.15"))
6635 .unwrap();
6636 assert_eq!(pnl, Money::from("1116.5 USDT"));
6637 assert_eq!(
6638 position.unrealized_pnl(Price::from("10390.0")).unwrap(),
6639 Money::from("1116.5 USDT")
6640 );
6641 assert_eq!(position.realized_pnl, Some(Money::from("-106.575 USDT")));
6642 assert_eq!(position.commissions(), vec![Money::from("106.575 USDT")]);
6643 assert_eq!(
6644 position.notional_value(Price::from("10390.0")),
6645 Money::from("105458.5 USDT")
6646 );
6647 }
6648
6649 #[rstest]
6650 fn test_calculate_pnl_for_short_position_loss(currency_pair_btcusdt: CurrencyPair) {
6651 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
6652 let order = OrderTestBuilder::new(OrderType::Market)
6653 .instrument_id(btcusdt.id())
6654 .side(OrderSide::Sell)
6655 .quantity(Quantity::from("10.0"))
6656 .build();
6657 let commission = calculate_commission(
6658 &btcusdt,
6659 order.quantity(),
6660 Price::from("10500.0"),
6661 None,
6662 dec!(0.001),
6663 );
6664 let fill = TestOrderEventStubs::filled(
6665 &order,
6666 &btcusdt,
6667 None,
6668 Some(PositionId::from("P-123456")),
6669 Some(Price::from("10500.0")),
6670 None,
6671 None,
6672 Some(commission),
6673 None,
6674 None,
6675 );
6676 let position = Position::new(&btcusdt, fill.into());
6677 let pnl = position
6678 .calculate_pnl(dec!(10500.0), dec!(10670.5), Quantity::from("10.0"))
6679 .unwrap();
6680 assert_eq!(pnl, Money::from("-1705 USDT"));
6681 assert_eq!(
6682 position.unrealized_pnl(Price::from("10670.5")).unwrap(),
6683 Money::from("-1705 USDT")
6684 );
6685 assert_eq!(position.realized_pnl, Some(Money::from("-105 USDT")));
6686 assert_eq!(position.commissions(), vec![Money::from("105 USDT")]);
6687 assert_eq!(
6688 position.notional_value(Price::from("10670.5")),
6689 Money::from("106705 USDT")
6690 );
6691 }
6692
6693 #[rstest]
6694 fn test_calculate_pnl_for_inverse1(btcusd_bybit: CryptoPerpetual) {
6695 let btcusd_bybit = InstrumentAny::CryptoPerpetual(btcusd_bybit);
6696 let order = OrderTestBuilder::new(OrderType::Market)
6697 .instrument_id(btcusd_bybit.id())
6698 .side(OrderSide::Sell)
6699 .quantity(Quantity::from("100000"))
6700 .build();
6701 let commission = calculate_commission(
6702 &btcusd_bybit,
6703 order.quantity(),
6704 Price::from("10000.0"),
6705 None,
6706 dec!(0.00075),
6707 );
6708 let fill = TestOrderEventStubs::filled(
6709 &order,
6710 &btcusd_bybit,
6711 None,
6712 Some(PositionId::from("P-123456")),
6713 Some(Price::from("10000.0")),
6714 None,
6715 None,
6716 Some(commission),
6717 None,
6718 None,
6719 );
6720 let position = Position::new(&btcusd_bybit, fill.into());
6721 let pnl = position
6722 .calculate_pnl(dec!(10000.0), dec!(11000.0), Quantity::from("100000.0"))
6723 .unwrap();
6724 assert_eq!(pnl, native_money(dec!(-10) / dec!(11), Currency::BTC()));
6725 assert_eq!(
6726 position.unrealized_pnl(Price::from("11000.0")).unwrap(),
6727 native_money(dec!(-10) / dec!(11), Currency::BTC())
6728 );
6729 assert_eq!(position.realized_pnl, Some(Money::from("-0.00750000 BTC")));
6730 assert_eq!(
6731 position.notional_value(Price::from("11000.0")),
6732 native_money(dec!(100) / dec!(11), Currency::BTC())
6733 );
6734 }
6735
6736 #[rstest]
6737 #[case("0.030", "0.001 BTC", "0.003 BTC", "0.00098 BTC")]
6738 #[case("0.000", "-0.002 BTC", "0 BTC", "-0.00202 BTC")]
6739 fn test_calculate_pnl_for_inverse_option_values_premium_in_base(
6740 mut crypto_option_btc_deribit: CryptoOption,
6741 #[case] close_px: &str,
6742 #[case] expected_unrealized_pnl: &str,
6743 #[case] expected_notional: &str,
6744 #[case] expected_realized_pnl: &str,
6745 ) {
6746 crypto_option_btc_deribit.is_inverse = true;
6747 crypto_option_btc_deribit.multiplier = Quantity::from("0.01");
6748 let option = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
6749 let open_order = OrderTestBuilder::new(OrderType::Market)
6750 .instrument_id(option.id())
6751 .side(OrderSide::Buy)
6752 .quantity(Quantity::from("10.0"))
6753 .build();
6754 let close_order = OrderTestBuilder::new(OrderType::Market)
6755 .instrument_id(option.id())
6756 .side(OrderSide::Sell)
6757 .quantity(Quantity::from("10.0"))
6758 .build();
6759 let open_fill = TestOrderEventStubs::filled(
6760 &open_order,
6761 &option,
6762 Some(TradeId::new("1")),
6763 None,
6764 Some(Price::from("0.020")),
6765 None,
6766 None,
6767 Some(Money::from("0.00001 BTC")),
6768 None,
6769 None,
6770 );
6771 let close_fill = TestOrderEventStubs::filled(
6772 &close_order,
6773 &option,
6774 Some(TradeId::new("2")),
6775 None,
6776 Some(Price::from(close_px)),
6777 None,
6778 None,
6779 Some(Money::from("0.00001 BTC")),
6780 None,
6781 None,
6782 );
6783 let mut position = Position::new(&option, open_fill.into());
6784 let unrealized_pnl = position.unrealized_pnl(Price::from(close_px)).unwrap();
6785 let notional = position.notional_value(Price::from(close_px));
6786
6787 position.apply(&close_fill.into());
6788
6789 assert_eq!(unrealized_pnl, Money::from(expected_unrealized_pnl));
6790 assert_eq!(notional, Money::from(expected_notional));
6791 assert_eq!(
6792 position.realized_pnl,
6793 Some(Money::from(expected_realized_pnl))
6794 );
6795 }
6796
6797 #[rstest]
6798 fn test_calculate_pnl_scales_by_contract_multiplier(
6799 mut btcusd_bybit: CryptoPerpetual,
6800 mut crypto_perpetual_ethusdt: CryptoPerpetual,
6801 ) {
6802 btcusd_bybit.multiplier = Quantity::from(10);
6803 let btcusd_bybit = InstrumentAny::CryptoPerpetual(btcusd_bybit);
6804 let inverse_order = OrderTestBuilder::new(OrderType::Market)
6805 .instrument_id(btcusd_bybit.id())
6806 .side(OrderSide::Sell)
6807 .quantity(Quantity::from("100000"))
6808 .build();
6809 let inverse_fill = TestOrderEventStubs::filled(
6810 &inverse_order,
6811 &btcusd_bybit,
6812 None,
6813 Some(PositionId::from("P-MULTIPLIER-INVERSE")),
6814 Some(Price::from("10000.0")),
6815 None,
6816 None,
6817 None,
6818 None,
6819 None,
6820 );
6821 let inverse_position = Position::new(&btcusd_bybit, inverse_fill.into());
6822
6823 assert_eq!(
6824 inverse_position
6825 .calculate_pnl(dec!(10000.0), dec!(11000.0), Quantity::from("100000.0"))
6826 .unwrap(),
6827 native_money(dec!(-100) / dec!(11), Currency::BTC())
6828 );
6829
6830 crypto_perpetual_ethusdt.multiplier = Quantity::from(10);
6831 let ethusdt = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt);
6832 let linear_order = OrderTestBuilder::new(OrderType::Market)
6833 .instrument_id(ethusdt.id())
6834 .side(OrderSide::Buy)
6835 .quantity(Quantity::from("2.000"))
6836 .build();
6837 let linear_fill = TestOrderEventStubs::filled(
6838 &linear_order,
6839 ðusdt,
6840 None,
6841 Some(PositionId::from("P-MULTIPLIER-LINEAR")),
6842 Some(Price::from("1000.00")),
6843 None,
6844 None,
6845 None,
6846 None,
6847 None,
6848 );
6849 let linear_position = Position::new(ðusdt, linear_fill.into());
6850
6851 assert_eq!(
6852 linear_position
6853 .calculate_pnl(dec!(1000.0), dec!(1100.0), Quantity::from("2.000"))
6854 .unwrap(),
6855 Money::new(2000.0, linear_position.settlement_currency)
6856 );
6857 }
6858
6859 #[rstest]
6860 fn test_try_notional_value_for_inverse_zero_price_returns_error(btcusd_bybit: CryptoPerpetual) {
6861 let btcusd_bybit = InstrumentAny::CryptoPerpetual(btcusd_bybit);
6862 let order = OrderTestBuilder::new(OrderType::Market)
6863 .instrument_id(btcusd_bybit.id())
6864 .side(OrderSide::Sell)
6865 .quantity(Quantity::from("100000"))
6866 .build();
6867 let fill = TestOrderEventStubs::filled(
6868 &order,
6869 &btcusd_bybit,
6870 None,
6871 Some(PositionId::from("P-ZERO-PRICE")),
6872 Some(Price::from("10000.0")),
6873 None,
6874 None,
6875 None,
6876 None,
6877 None,
6878 );
6879 let mut position = Position::new(&btcusd_bybit, fill.into());
6880
6881 let result = position.try_notional_value(Price::new(0.0, 1));
6882
6883 assert_eq!(
6884 result.unwrap_err().to_string(),
6885 "price must be positive for inverse notional valuation"
6886 );
6887 assert!(
6888 position
6889 .try_calculate_pnl(dec!(10_000.0), dec!(0.0), position.quantity)
6890 .is_err()
6891 );
6892 assert!(position.try_unrealized_pnl(Price::new(0.0, 1)).is_err());
6893 assert!(position.try_total_pnl(Price::new(0.0, 1)).is_err());
6894 assert!(position.try_unrealized_pnl(Price::new(-1.0, 1)).is_err());
6895 assert!(
6896 position
6897 .calculate_pnl(dec!(10_000.0), dec!(0.0), position.quantity)
6898 .is_err()
6899 );
6900 assert!(position.unrealized_pnl(Price::new(0.0, 1)).is_err());
6901 assert!(position.total_pnl(Price::new(0.0, 1)).is_err());
6902
6903 position.base_currency = None;
6904 let result = position.try_notional_value(Price::from("10000.0"));
6905
6906 assert_eq!(
6907 result.unwrap_err().to_string(),
6908 "inverse position BTCUSD.BYBIT has no base currency"
6909 );
6910 assert!(position.try_unrealized_pnl(Price::from("10000.0")).is_err());
6911 }
6912
6913 #[rstest]
6914 fn test_calculate_avg_px_zero_quantity_paths(stub_position_long: Position) {
6915 let mut position = stub_position_long;
6916 let mut fill = position.events[0].clone();
6917 position.signed_qty = Decimal::ZERO;
6918 position.quantity = Quantity::zero(position.size_precision);
6919 position.cost_open = Decimal::ZERO;
6920 fill.last_qty = Quantity::zero(position.size_precision);
6921 assert!(
6922 position
6923 .handle_order_fill(&fill, fill.last_px.as_decimal(), Decimal::ZERO)
6924 .is_err()
6925 );
6926 fill.last_qty = Quantity::from(5);
6927 fill.last_px = Price::from("2");
6928 position.handle_order_fill(&fill, dec!(2), dec!(5)).unwrap();
6929 assert_eq!(position.avg_px_open, dec!(2));
6930 assert_eq!(position.cost_open, dec!(10));
6931 }
6932
6933 #[rstest]
6934 fn test_calculate_return_with_zero_open_price(stub_position_long: Position) {
6935 assert_eq!(
6936 stub_position_long.calculate_return(Decimal::ZERO, dec!(1.5)),
6937 0.0
6938 );
6939 assert_eq!(stub_position_long.calculate_return(dec!(2), dec!(3)), 0.5);
6940 }
6941
6942 #[rstest]
6943 fn test_calculate_points_inverse_rejects_non_positive_prices(btcusd_bybit: CryptoPerpetual) {
6944 let btcusd_bybit = InstrumentAny::CryptoPerpetual(btcusd_bybit);
6945 let order = OrderTestBuilder::new(OrderType::Market)
6946 .instrument_id(btcusd_bybit.id())
6947 .side(OrderSide::Sell)
6948 .quantity(Quantity::from("100000"))
6949 .build();
6950 let fill = TestOrderEventStubs::filled(
6951 &order,
6952 &btcusd_bybit,
6953 None,
6954 Some(PositionId::from("P-INVERSE-GUARD")),
6955 Some(Price::from("10000.0")),
6956 None,
6957 None,
6958 None,
6959 None,
6960 None,
6961 );
6962 let position = Position::new(&btcusd_bybit, fill.into());
6963
6964 for (open, close) in [
6965 (dec!(-1), dec!(10000)),
6966 (dec!(10000), dec!(-1)),
6967 (Decimal::ZERO, dec!(10000)),
6968 (dec!(10000), Decimal::ZERO),
6969 ] {
6970 assert!(
6971 position
6972 .try_calculate_pnl(open, close, position.quantity)
6973 .is_err()
6974 );
6975 }
6976
6977 assert!(
6978 position
6979 .try_calculate_pnl(dec!(0.0000000000000001), dec!(10000), position.quantity)
6980 .is_err()
6981 );
6982 }
6983
6984 #[rstest]
6985 fn test_calculate_pnl_for_inverse2(ethusd_bybit: CryptoPerpetual) {
6986 let ethusd_bybit = InstrumentAny::CryptoPerpetual(ethusd_bybit);
6987 let order = OrderTestBuilder::new(OrderType::Market)
6988 .instrument_id(ethusd_bybit.id())
6989 .side(OrderSide::Sell)
6990 .quantity(Quantity::from("100000"))
6991 .build();
6992 let commission = calculate_commission(
6993 ðusd_bybit,
6994 order.quantity(),
6995 Price::from("375.95"),
6996 None,
6997 dec!(0.00075),
6998 );
6999 let fill = TestOrderEventStubs::filled(
7000 &order,
7001 ðusd_bybit,
7002 None,
7003 Some(PositionId::from("P-123456")),
7004 Some(Price::from("375.95")),
7005 None,
7006 None,
7007 Some(commission),
7008 None,
7009 None,
7010 );
7011 let position = Position::new(ðusd_bybit, fill.into());
7012
7013 assert_eq!(
7014 position.unrealized_pnl(Price::from("370.00")).unwrap(),
7015 native_money(dec!(1190000) / dec!(278203), Currency::ETH())
7016 );
7017 assert_eq!(
7018 position.notional_value(Price::from("370.00")),
7019 native_money(dec!(10000) / dec!(37), Currency::ETH())
7020 );
7021 }
7022
7023 #[rstest]
7024 fn test_notional_value_for_quanto_uses_settlement_currency(ethbtc_quanto: CryptoFuture) {
7025 let instrument = InstrumentAny::CryptoFuture(ethbtc_quanto);
7026 let order = OrderTestBuilder::new(OrderType::Market)
7027 .instrument_id(instrument.id())
7028 .side(OrderSide::Buy)
7029 .quantity(Quantity::from("5"))
7030 .build();
7031 let price = Price::from("0.03600");
7032 let fill = TestOrderEventStubs::filled(
7033 &order,
7034 &instrument,
7035 None,
7036 Some(PositionId::from("P-QUANTO-NOTIONAL")),
7037 Some(price),
7038 None,
7039 None,
7040 None,
7041 None,
7042 None,
7043 );
7044 let position = Position::new(&instrument, fill.into());
7045 let position_notional = position.notional_value(price);
7046 let instrument_notional =
7047 instrument.calculate_notional_value(position.quantity, price, None);
7048
7049 assert_eq!(position_notional, instrument_notional);
7050 assert_eq!(position_notional, Money::from("0.18 USDT"));
7051 }
7052
7053 #[rstest]
7054 fn test_calculate_unrealized_pnl_for_long(currency_pair_btcusdt: CurrencyPair) {
7055 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
7056 let order1 = OrderTestBuilder::new(OrderType::Market)
7057 .instrument_id(btcusdt.id())
7058 .side(OrderSide::Buy)
7059 .quantity(Quantity::from("2.000000"))
7060 .build();
7061 let order2 = OrderTestBuilder::new(OrderType::Market)
7062 .instrument_id(btcusdt.id())
7063 .side(OrderSide::Buy)
7064 .quantity(Quantity::from("2.000000"))
7065 .build();
7066 let commission1 = calculate_commission(
7067 &btcusdt,
7068 order1.quantity(),
7069 Price::from("10500.0"),
7070 None,
7071 dec!(0.001),
7072 );
7073 let fill1 = TestOrderEventStubs::filled(
7074 &order1,
7075 &btcusdt,
7076 Some(TradeId::new("1")),
7077 Some(PositionId::new("P-123456")),
7078 Some(Price::from("10500.00")),
7079 None,
7080 None,
7081 Some(commission1),
7082 None,
7083 None,
7084 );
7085 let commission2 = calculate_commission(
7086 &btcusdt,
7087 order2.quantity(),
7088 Price::from("10500.0"),
7089 None,
7090 dec!(0.001),
7091 );
7092 let fill2 = TestOrderEventStubs::filled(
7093 &order2,
7094 &btcusdt,
7095 Some(TradeId::new("2")),
7096 Some(PositionId::new("P-123456")),
7097 Some(Price::from("10500.00")),
7098 None,
7099 None,
7100 Some(commission2),
7101 None,
7102 None,
7103 );
7104 let mut position = Position::new(&btcusdt, fill1.into());
7105 position.apply(&fill2.into());
7106 let pnl = position.unrealized_pnl(Price::from("11505.60")).unwrap();
7107 assert_eq!(pnl, Money::from("4022.40000000 USDT"));
7108 assert_eq!(
7109 position.realized_pnl,
7110 Some(Money::from("-42.00000000 USDT"))
7111 );
7112 assert_eq!(
7113 position.commissions(),
7114 vec![Money::from("42.00000000 USDT")]
7115 );
7116 }
7117
7118 #[rstest]
7119 fn test_calculate_unrealized_pnl_for_short(currency_pair_btcusdt: CurrencyPair) {
7120 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
7121 let order = OrderTestBuilder::new(OrderType::Market)
7122 .instrument_id(btcusdt.id())
7123 .side(OrderSide::Sell)
7124 .quantity(Quantity::from("5.912000"))
7125 .build();
7126 let commission = calculate_commission(
7127 &btcusdt,
7128 order.quantity(),
7129 Price::from("10505.60"),
7130 None,
7131 dec!(0.001),
7132 );
7133 let fill = TestOrderEventStubs::filled(
7134 &order,
7135 &btcusdt,
7136 Some(TradeId::new("1")),
7137 Some(PositionId::new("P-123456")),
7138 Some(Price::from("10505.60")),
7139 None,
7140 None,
7141 Some(commission),
7142 None,
7143 None,
7144 );
7145 let position = Position::new(&btcusdt, fill.into());
7146 let pnl = position.unrealized_pnl(Price::from("10407.15")).unwrap();
7147 assert_eq!(pnl, Money::from("582.03640000 USDT"));
7148 assert_eq!(
7149 position.realized_pnl,
7150 Some(Money::from("-62.10910720 USDT"))
7151 );
7152 assert_eq!(
7153 position.commissions(),
7154 vec![Money::from("62.10910720 USDT")]
7155 );
7156 }
7157
7158 #[rstest]
7159 fn test_calculate_unrealized_pnl_for_long_inverse(btcusd_bybit: CryptoPerpetual) {
7160 let btcusd_bybit = InstrumentAny::CryptoPerpetual(btcusd_bybit);
7161 let order = OrderTestBuilder::new(OrderType::Market)
7162 .instrument_id(btcusd_bybit.id())
7163 .side(OrderSide::Buy)
7164 .quantity(Quantity::from("100000"))
7165 .build();
7166 let commission = calculate_commission(
7167 &btcusd_bybit,
7168 order.quantity(),
7169 Price::from("10500.0"),
7170 None,
7171 dec!(0.00075),
7172 );
7173 let fill = TestOrderEventStubs::filled(
7174 &order,
7175 &btcusd_bybit,
7176 Some(TradeId::new("1")),
7177 Some(PositionId::new("P-123456")),
7178 Some(Price::from("10500.00")),
7179 None,
7180 None,
7181 Some(commission),
7182 None,
7183 None,
7184 );
7185
7186 let position = Position::new(&btcusd_bybit, fill.into());
7187 let pnl = position.unrealized_pnl(Price::from("11505.60")).unwrap();
7188 assert_eq!(
7189 pnl,
7190 native_money(dec!(41900) / dec!(50337), Currency::BTC())
7191 );
7192 assert_eq!(position.realized_pnl, Some(Money::from("-0.00714286 BTC")));
7193 assert_eq!(position.commissions(), vec![Money::from("0.00714286 BTC")]);
7194 }
7195
7196 #[rstest]
7197 fn test_calculate_unrealized_pnl_for_short_inverse(btcusd_bybit: CryptoPerpetual) {
7198 let btcusd_bybit = InstrumentAny::CryptoPerpetual(btcusd_bybit);
7199 let order = OrderTestBuilder::new(OrderType::Market)
7200 .instrument_id(btcusd_bybit.id())
7201 .side(OrderSide::Sell)
7202 .quantity(Quantity::from("1250000"))
7203 .build();
7204 let commission = calculate_commission(
7205 &btcusd_bybit,
7206 order.quantity(),
7207 Price::from("15500.00"),
7208 None,
7209 dec!(0.00075),
7210 );
7211 let fill = TestOrderEventStubs::filled(
7212 &order,
7213 &btcusd_bybit,
7214 Some(TradeId::new("1")),
7215 Some(PositionId::new("P-123456")),
7216 Some(Price::from("15500.00")),
7217 None,
7218 None,
7219 Some(commission),
7220 None,
7221 None,
7222 );
7223 let position = Position::new(&btcusd_bybit, fill.into());
7224 let pnl = position.unrealized_pnl(Price::from("12506.65")).unwrap();
7225
7226 assert_eq!(
7227 pnl,
7228 native_money(dec!(149667500) / dec!(7754123), Currency::BTC())
7229 );
7230 assert_eq!(position.realized_pnl, Some(Money::from("-0.06048387 BTC")));
7231 assert_eq!(position.commissions(), vec![Money::from("0.06048387 BTC")]);
7232 }
7233
7234 #[rstest]
7235 #[case(OrderSide::Buy, 25, 25.0)]
7236 #[case(OrderSide::Sell,25,-25.0)]
7237 fn test_signed_qty_decimal_qty_for_equity(
7238 #[case] order_side: OrderSide,
7239 #[case] quantity: i64,
7240 #[case] expected: f64,
7241 audusd_sim: CurrencyPair,
7242 ) {
7243 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
7244 let order = OrderTestBuilder::new(OrderType::Market)
7245 .instrument_id(audusd_sim.id())
7246 .side(order_side)
7247 .quantity(Quantity::from(quantity))
7248 .build();
7249
7250 let commission = calculate_commission(
7251 &audusd_sim,
7252 order.quantity(),
7253 Price::from("1.0"),
7254 None,
7255 dec!(0.00002),
7256 );
7257 let fill = TestOrderEventStubs::filled(
7258 &order,
7259 &audusd_sim,
7260 None,
7261 Some(PositionId::from("P-123456")),
7262 None,
7263 None,
7264 None,
7265 Some(commission),
7266 None,
7267 None,
7268 );
7269 let position = Position::new(&audusd_sim, fill.into());
7270 assert_eq!(position.signed_qty, Decimal::try_from(expected).unwrap());
7271 }
7272
7273 #[rstest]
7274 fn test_position_with_commission_none(audusd_sim: CurrencyPair) {
7275 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
7276 let fill = OrderFilledSpec::builder()
7277 .position_id(PositionId::from("1"))
7278 .build();
7279
7280 let position = Position::new(&audusd_sim, fill);
7281 assert_eq!(position.realized_pnl, Some(Money::from("0 USD")));
7282 }
7283
7284 #[rstest]
7285 fn test_position_with_commission_zero(audusd_sim: CurrencyPair) {
7286 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
7287 let fill = OrderFilledSpec::builder()
7288 .position_id(PositionId::from("1"))
7289 .commission(Money::from("0 USD"))
7290 .build();
7291
7292 let position = Position::new(&audusd_sim, fill);
7293 assert_eq!(position.realized_pnl, Some(Money::from("0 USD")));
7294 }
7295
7296 #[rstest]
7297 fn test_cache_purge_order_events() {
7298 let audusd_sim = audusd_sim();
7299 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
7300
7301 let order1 = OrderTestBuilder::new(OrderType::Market)
7302 .client_order_id(ClientOrderId::new("O-1"))
7303 .instrument_id(audusd_sim.id())
7304 .side(OrderSide::Buy)
7305 .quantity(Quantity::from(50_000))
7306 .build();
7307
7308 let order2 = OrderTestBuilder::new(OrderType::Market)
7309 .client_order_id(ClientOrderId::new("O-2"))
7310 .instrument_id(audusd_sim.id())
7311 .side(OrderSide::Buy)
7312 .quantity(Quantity::from(50_000))
7313 .build();
7314
7315 let position_id = PositionId::new("P-123456");
7316
7317 let fill1 = TestOrderEventStubs::filled(
7318 &order1,
7319 &audusd_sim,
7320 Some(TradeId::new("1")),
7321 Some(position_id),
7322 Some(Price::from("1.00001")),
7323 None,
7324 None,
7325 None,
7326 None,
7327 None,
7328 );
7329
7330 let mut position = Position::new(&audusd_sim, fill1.into());
7331
7332 let fill2 = TestOrderEventStubs::filled(
7333 &order2,
7334 &audusd_sim,
7335 Some(TradeId::new("2")),
7336 Some(position_id),
7337 Some(Price::from("1.00002")),
7338 None,
7339 None,
7340 None,
7341 None,
7342 None,
7343 );
7344
7345 position.apply(&fill2.into());
7346 position
7347 .purge_events_for_order(order1.client_order_id())
7348 .unwrap();
7349
7350 assert_eq!(position.events.len(), 1);
7351 assert_eq!(position.trade_ids.len(), 1);
7352 assert_eq!(position.events[0].client_order_id, order2.client_order_id());
7353 assert!(position.trade_ids.contains(&TradeId::new("2")));
7354 }
7355
7356 #[rstest]
7357 fn test_purge_all_events_returns_none_for_last_event_and_trade_id() {
7358 let audusd_sim = audusd_sim();
7359 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
7360
7361 let order = OrderTestBuilder::new(OrderType::Market)
7362 .client_order_id(ClientOrderId::new("O-1"))
7363 .instrument_id(audusd_sim.id())
7364 .side(OrderSide::Buy)
7365 .quantity(Quantity::from(100_000))
7366 .build();
7367
7368 let position_id = PositionId::new("P-123456");
7369 let fill = TestOrderEventStubs::filled(
7370 &order,
7371 &audusd_sim,
7372 Some(TradeId::new("1")),
7373 Some(position_id),
7374 Some(Price::from("1.00050")),
7375 None,
7376 None,
7377 None,
7378 Some(UnixNanos::from(1_000_000_000)), None,
7380 );
7381
7382 let mut position = Position::new(&audusd_sim, fill.into());
7383
7384 assert_eq!(position.events.len(), 1);
7385 assert!(position.last_event().is_some());
7386 assert!(position.last_trade_id().is_some());
7387
7388 let original_ts_opened = position.ts_opened;
7390 let original_ts_last = position.ts_last;
7391 assert_ne!(original_ts_opened, UnixNanos::default());
7392 assert_ne!(original_ts_last, UnixNanos::default());
7393
7394 position
7395 .purge_events_for_order(order.client_order_id())
7396 .unwrap();
7397
7398 assert_eq!(position.events.len(), 0);
7399 assert_eq!(position.trade_ids.len(), 0);
7400 assert!(position.last_event().is_none());
7401 assert!(position.last_trade_id().is_none());
7402
7403 assert_eq!(position.ts_opened, UnixNanos::default());
7406 assert_eq!(position.ts_last, UnixNanos::default());
7407 assert_eq!(position.ts_closed, Some(UnixNanos::default()));
7408 assert_eq!(position.duration_ns, DurationNanos::default());
7409
7410 assert!(position.is_closed());
7413 assert!(!position.is_open());
7414 assert_eq!(position.side, PositionSide::Flat);
7415 }
7416
7417 #[rstest]
7418 fn test_revive_from_empty_shell(audusd_sim: CurrencyPair) {
7419 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
7421
7422 let order1 = OrderTestBuilder::new(OrderType::Market)
7424 .instrument_id(audusd_sim.id())
7425 .side(OrderSide::Buy)
7426 .quantity(Quantity::from(100_000))
7427 .build();
7428
7429 let fill1 = TestOrderEventStubs::filled(
7430 &order1,
7431 &audusd_sim,
7432 None,
7433 Some(PositionId::new("P-1")),
7434 Some(Price::from("1.00000")),
7435 None,
7436 None,
7437 None,
7438 Some(UnixNanos::from(1_000_000_000)),
7439 None,
7440 );
7441
7442 let mut position = Position::new(&audusd_sim, fill1.into());
7443 position
7444 .purge_events_for_order(order1.client_order_id())
7445 .unwrap();
7446
7447 assert!(position.is_closed());
7449 assert_eq!(position.ts_closed, Some(UnixNanos::default()));
7450 assert_eq!(position.event_count(), 0);
7451
7452 let order2 = OrderTestBuilder::new(OrderType::Market)
7454 .instrument_id(audusd_sim.id())
7455 .side(OrderSide::Buy)
7456 .quantity(Quantity::from(50_000))
7457 .build();
7458
7459 let fill2 = TestOrderEventStubs::filled(
7460 &order2,
7461 &audusd_sim,
7462 None,
7463 Some(PositionId::new("P-1")),
7464 Some(Price::from("1.00020")),
7465 None,
7466 None,
7467 None,
7468 Some(UnixNanos::from(3_000_000_000)),
7469 None,
7470 );
7471
7472 let fill2_typed: OrderFilled = fill2.clone().into();
7473 position.apply(&fill2_typed);
7474
7475 assert!(position.is_long());
7477 assert!(!position.is_closed());
7478 assert!(position.ts_closed.is_none());
7479 assert_eq!(position.ts_opened, fill2.ts_event());
7480 assert_eq!(position.ts_last, fill2.ts_event());
7481 assert_eq!(position.event_count(), 1);
7482 assert_eq!(position.quantity, Quantity::from(50_000));
7483 }
7484
7485 #[rstest]
7486 fn test_empty_shell_position_invariants(audusd_sim: CurrencyPair) {
7487 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
7489
7490 let order = OrderTestBuilder::new(OrderType::Market)
7491 .instrument_id(audusd_sim.id())
7492 .side(OrderSide::Buy)
7493 .quantity(Quantity::from(100_000))
7494 .build();
7495
7496 let fill = TestOrderEventStubs::filled(
7497 &order,
7498 &audusd_sim,
7499 None,
7500 Some(PositionId::new("P-1")),
7501 Some(Price::from("1.00000")),
7502 None,
7503 None,
7504 None,
7505 Some(UnixNanos::from(1_000_000_000)),
7506 None,
7507 );
7508
7509 let mut position = Position::new(&audusd_sim, fill.into());
7510 position
7511 .purge_events_for_order(order.client_order_id())
7512 .unwrap();
7513
7514 assert_eq!(
7516 position.event_count(),
7517 0,
7518 "Precondition: event_count must be 0"
7519 );
7520
7521 assert!(
7523 position.is_closed(),
7524 "INV1: Empty shell must report is_closed() == true"
7525 );
7526 assert!(
7527 !position.is_open(),
7528 "INV1: Empty shell must report is_open() == false"
7529 );
7530
7531 assert_eq!(
7533 position.side,
7534 PositionSide::Flat,
7535 "INV2: Empty shell must be FLAT"
7536 );
7537
7538 assert!(
7540 position.ts_closed.is_some(),
7541 "INV3: Empty shell must have ts_closed.is_some()"
7542 );
7543 assert_eq!(
7544 position.ts_closed,
7545 Some(UnixNanos::default()),
7546 "INV3: Empty shell ts_closed must be 0"
7547 );
7548
7549 assert_eq!(
7551 position.ts_opened,
7552 UnixNanos::default(),
7553 "INV4: Empty shell ts_opened must be 0"
7554 );
7555 assert_eq!(
7556 position.ts_last,
7557 UnixNanos::default(),
7558 "INV4: Empty shell ts_last must be 0"
7559 );
7560 assert_eq!(
7561 position.duration_ns,
7562 DurationNanos::default(),
7563 "INV4: Empty shell duration_ns must be 0"
7564 );
7565
7566 assert_eq!(
7568 position.quantity,
7569 Quantity::zero(audusd_sim.size_precision()),
7570 "INV5: Empty shell quantity must be 0"
7571 );
7572
7573 assert!(
7575 position.events.is_empty(),
7576 "INV6: Empty shell must have no events"
7577 );
7578 assert!(
7579 position.trade_ids.is_empty(),
7580 "INV6: Empty shell must have no trade IDs"
7581 );
7582 assert!(
7583 position.last_event().is_none(),
7584 "INV6: Empty shell must have no last event"
7585 );
7586 assert!(
7587 position.last_trade_id().is_none(),
7588 "INV6: Empty shell must have no last trade ID"
7589 );
7590 }
7591
7592 #[rstest]
7593 fn test_position_pnl_precision_with_very_small_amounts(audusd_sim: CurrencyPair) {
7594 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
7597 let order = OrderTestBuilder::new(OrderType::Market)
7598 .instrument_id(audusd_sim.id())
7599 .side(OrderSide::Buy)
7600 .quantity(Quantity::from(100))
7601 .build();
7602
7603 let small_commission = Money::new(0.01, Currency::USD());
7605 let fill = TestOrderEventStubs::filled(
7606 &order,
7607 &audusd_sim,
7608 None,
7609 None,
7610 Some(Price::from("1.00001")),
7611 Some(Quantity::from(100)),
7612 None,
7613 Some(small_commission),
7614 None,
7615 None,
7616 );
7617
7618 let position = Position::new(&audusd_sim, fill.into());
7619
7620 assert_eq!(position.commissions().len(), 1);
7622 let recorded_commission = position.commissions()[0];
7623 assert!(
7624 recorded_commission.as_f64() > 0.0,
7625 "Commission of 0.01 should be preserved"
7626 );
7627
7628 let realized = position.realized_pnl.unwrap().as_f64();
7630 assert!(
7631 realized < 0.0,
7632 "Realized PnL should be negative due to commission"
7633 );
7634 }
7635
7636 #[rstest]
7637 fn test_position_pnl_precision_with_high_precision_instrument() {
7638 use crate::instruments::stubs::crypto_perpetual_ethusdt;
7640 let ethusdt = crypto_perpetual_ethusdt();
7641 let ethusdt = InstrumentAny::CryptoPerpetual(ethusdt);
7642
7643 let size_precision = ethusdt.size_precision();
7645
7646 let order = OrderTestBuilder::new(OrderType::Market)
7647 .instrument_id(ethusdt.id())
7648 .side(OrderSide::Buy)
7649 .quantity(Quantity::from("1.123456789"))
7650 .build();
7651
7652 let fill = TestOrderEventStubs::filled(
7653 &order,
7654 ðusdt,
7655 None,
7656 None,
7657 Some(Price::from("2345.123456789")),
7658 Some(Quantity::from("1.123456789")),
7659 None,
7660 Some(Money::from("0.1 USDT")),
7661 None,
7662 None,
7663 );
7664
7665 let position = Position::new(ðusdt, fill.into());
7666
7667 let avg_px = position.avg_px_open;
7669 assert_eq!(avg_px, dec!(2345.123456789));
7670
7671 assert_eq!(position.size_precision, size_precision);
7672 assert_eq!(position.quantity.precision, 9);
7673 assert_eq!(position.quantity.as_decimal(), dec!(1.123456789));
7674 assert_eq!(position.signed_qty, dec!(1.123456789));
7675 }
7676
7677 #[rstest]
7678 fn test_position_pnl_accumulation_across_many_fills(audusd_sim: CurrencyPair) {
7679 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
7681 let order = OrderTestBuilder::new(OrderType::Market)
7682 .instrument_id(audusd_sim.id())
7683 .side(OrderSide::Buy)
7684 .quantity(Quantity::from(1000))
7685 .build();
7686
7687 let initial_fill = TestOrderEventStubs::filled(
7688 &order,
7689 &audusd_sim,
7690 Some(TradeId::new("1")),
7691 None,
7692 Some(Price::from("1.00000")),
7693 Some(Quantity::from(10)),
7694 None,
7695 Some(Money::from("0.01 USD")),
7696 None,
7697 None,
7698 );
7699
7700 let mut position = Position::new(&audusd_sim, initial_fill.into());
7701
7702 for i in 2..=100 {
7704 let price_offset = f64::from(i) * 0.00001;
7705 let fill = TestOrderEventStubs::filled(
7706 &order,
7707 &audusd_sim,
7708 Some(TradeId::new(i.to_string())),
7709 None,
7710 Some(Price::from(&format!("{:.5}", 1.0 + price_offset))),
7711 Some(Quantity::from(10)),
7712 None,
7713 Some(Money::from("0.01 USD")),
7714 None,
7715 None,
7716 );
7717 position.apply(&fill.into());
7718 }
7719
7720 assert_eq!(position.events.len(), 100);
7722 assert_eq!(position.quantity, Quantity::from(1000));
7723
7724 let total_commission: f64 = position.commissions().iter().map(Money::as_f64).sum();
7726 assert!(
7727 (total_commission - 1.0).abs() < 1e-10,
7728 "Commission accumulation should be accurate: expected 1.0, was {total_commission}"
7729 );
7730
7731 let avg_px = position.avg_px_open;
7733 assert!(
7734 avg_px > dec!(1.0) && avg_px < dec!(1.001),
7735 "Average price should be reasonable: got {avg_px}"
7736 );
7737 }
7738
7739 #[rstest]
7740 fn test_position_pnl_with_extreme_price_values(audusd_sim: CurrencyPair) {
7741 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
7743
7744 let order_small = OrderTestBuilder::new(OrderType::Market)
7746 .instrument_id(audusd_sim.id())
7747 .side(OrderSide::Buy)
7748 .quantity(Quantity::from(100_000))
7749 .build();
7750
7751 let fill_small = TestOrderEventStubs::filled(
7752 &order_small,
7753 &audusd_sim,
7754 None,
7755 None,
7756 Some(Price::from("0.00001")),
7757 Some(Quantity::from(100_000)),
7758 None,
7759 None,
7760 None,
7761 None,
7762 );
7763
7764 let position_small = Position::new(&audusd_sim, fill_small.into());
7765 assert_eq!(position_small.avg_px_open, dec!(0.00001));
7766
7767 let last_price_small = Price::from("0.00002");
7769 let unrealized = position_small.unrealized_pnl(last_price_small).unwrap();
7770 assert!(
7771 unrealized.as_f64() > 0.0,
7772 "Unrealized PnL should be positive when price doubles"
7773 );
7774
7775 let order_large = OrderTestBuilder::new(OrderType::Market)
7777 .instrument_id(audusd_sim.id())
7778 .side(OrderSide::Buy)
7779 .quantity(Quantity::from(100))
7780 .build();
7781
7782 let fill_large = TestOrderEventStubs::filled(
7783 &order_large,
7784 &audusd_sim,
7785 None,
7786 None,
7787 Some(Price::from("99999.99999")),
7788 Some(Quantity::from(100)),
7789 None,
7790 None,
7791 None,
7792 None,
7793 );
7794
7795 let position_large = Position::new(&audusd_sim, fill_large.into());
7796 assert!(
7797 (position_large.avg_px_open - dec!(99999.99999)).abs() < dec!(1e-6),
7798 "Large price should be preserved within f64 tolerance"
7799 );
7800 }
7801
7802 #[rstest]
7803 fn test_position_pnl_roundtrip_precision(audusd_sim: CurrencyPair) {
7804 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
7806 let buy_order = OrderTestBuilder::new(OrderType::Market)
7807 .instrument_id(audusd_sim.id())
7808 .side(OrderSide::Buy)
7809 .quantity(Quantity::from(100_000))
7810 .build();
7811
7812 let sell_order = OrderTestBuilder::new(OrderType::Market)
7813 .instrument_id(audusd_sim.id())
7814 .side(OrderSide::Sell)
7815 .quantity(Quantity::from(100_000))
7816 .build();
7817
7818 let open_fill = TestOrderEventStubs::filled(
7820 &buy_order,
7821 &audusd_sim,
7822 Some(TradeId::new("1")),
7823 None,
7824 Some(Price::from("1.123456")),
7825 None,
7826 None,
7827 Some(Money::from("0.50 USD")),
7828 None,
7829 None,
7830 );
7831
7832 let mut position = Position::new(&audusd_sim, open_fill.into());
7833
7834 let close_fill = TestOrderEventStubs::filled(
7836 &sell_order,
7837 &audusd_sim,
7838 Some(TradeId::new("2")),
7839 None,
7840 Some(Price::from("1.123456")),
7841 None,
7842 None,
7843 Some(Money::from("0.50 USD")),
7844 None,
7845 None,
7846 );
7847
7848 position.apply(&close_fill.into());
7849
7850 assert!(position.is_closed());
7852
7853 let realized = position.realized_pnl.unwrap().as_f64();
7855 assert!(
7856 (realized - (-1.0)).abs() < 1e-10,
7857 "Realized PnL should be exactly -1.0 USD (commissions), was {realized}"
7858 );
7859 }
7860
7861 #[rstest]
7862 fn test_position_commission_in_base_currency_buy() {
7863 let btc_usdt = currency_pair_btcusdt();
7865 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
7866
7867 let order = OrderTestBuilder::new(OrderType::Market)
7868 .instrument_id(btc_usdt.id())
7869 .side(OrderSide::Buy)
7870 .quantity(Quantity::from("1.0"))
7871 .build();
7872
7873 let fill = match TestOrderEventStubs::filled(
7875 &order,
7876 &btc_usdt,
7877 Some(TradeId::new("1")),
7878 None,
7879 Some(Price::from("50000.0")),
7880 Some(Quantity::from("1.0")),
7881 None,
7882 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
7883 None,
7884 None,
7885 ) {
7886 OrderEventAny::Filled(fill) => fill,
7887 _ => unreachable!(),
7888 };
7889
7890 let position = Position::new(&btc_usdt, fill.clone());
7891 let replayed_position = Position::new(&btc_usdt, fill);
7892
7893 assert!(
7895 (position.quantity.as_f64() - 0.999).abs() < 1e-9,
7896 "Position quantity should be 0.999 BTC (1.0 - 0.001 commission), was {}",
7897 position.quantity.as_f64()
7898 );
7899
7900 assert!(
7902 (position.signed_qty - dec!(0.999)).abs() < dec!(1e-9),
7903 "Signed qty should be 0.999, was {}",
7904 position.signed_qty
7905 );
7906
7907 assert_eq!(
7909 position.adjustments.len(),
7910 1,
7911 "Should have 1 adjustment event"
7912 );
7913 let adjustment = &position.adjustments[0];
7914 assert_eq!(
7915 adjustment.adjustment_type,
7916 PositionAdjustmentType::Commission
7917 );
7918 assert_eq!(
7919 adjustment.quantity_change,
7920 Some(rust_decimal_macros::dec!(-0.001))
7921 );
7922 assert_eq!(adjustment.pnl_change, None);
7923 assert_eq!(
7924 adjustment.event_id,
7925 replayed_position.adjustments[0].event_id
7926 );
7927 }
7928
7929 #[rstest]
7930 fn test_tokenized_asset_base_commission_buy_then_close_net_holding() {
7931 let instrument = InstrumentAny::TokenizedAsset(tokenized_asset_aaplx());
7932 let base_currency = instrument.base_currency().unwrap();
7933 let buy = OrderTestBuilder::new(OrderType::Market)
7934 .instrument_id(instrument.id())
7935 .client_order_id(ClientOrderId::from("O-BUY"))
7936 .side(OrderSide::Buy)
7937 .quantity(Quantity::from("1.0000"))
7938 .build();
7939 let OrderEventAny::Filled(buy_fill) = TestOrderEventStubs::filled(
7940 &buy,
7941 &instrument,
7942 Some(TradeId::new("1")),
7943 None,
7944 Some(Price::from("200.00")),
7945 Some(Quantity::from("1.0000")),
7946 None,
7947 Some(Money::new(0.001, base_currency)),
7948 None,
7949 None,
7950 ) else {
7951 unreachable!()
7952 };
7953
7954 let mut position = Position::new(&instrument, buy_fill);
7955
7956 assert_eq!(position.quantity, Quantity::from("0.9990"));
7957 assert_eq!(position.adjustments.len(), 1);
7958 assert_eq!(
7959 position.adjustments[0].adjustment_type,
7960 PositionAdjustmentType::Commission
7961 );
7962 assert_eq!(
7963 position.adjustments[0].quantity_change,
7964 Some(rust_decimal_macros::dec!(-0.001))
7965 );
7966
7967 let sell = OrderTestBuilder::new(OrderType::Market)
7968 .instrument_id(instrument.id())
7969 .client_order_id(ClientOrderId::from("O-SELL"))
7970 .side(OrderSide::Sell)
7971 .quantity(Quantity::from("0.9990"))
7972 .build();
7973 let OrderEventAny::Filled(sell_fill) = TestOrderEventStubs::filled(
7974 &sell,
7975 &instrument,
7976 Some(TradeId::new("2")),
7977 None,
7978 Some(Price::from("210.00")),
7979 Some(Quantity::from("0.9990")),
7980 None,
7981 Some(Money::zero(instrument.quote_currency())),
7982 None,
7983 None,
7984 ) else {
7985 unreachable!()
7986 };
7987
7988 position.apply(&sell_fill);
7989
7990 assert_eq!(position.side, PositionSide::Flat);
7991 assert!(position.quantity.is_zero());
7992 assert!(position.is_closed());
7993 }
7994
7995 #[rstest]
7996 fn test_base_commission_adjustment_id_flips_low_bit_of_fill_id(audusd_sim: CurrencyPair) {
7997 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
7998 let base_currency = instrument.base_currency().unwrap();
7999 let fill = OrderFilledSpec::builder()
8000 .instrument_id(instrument.id())
8001 .client_order_id(ClientOrderId::from("O-1"))
8002 .trade_id(TradeId::from("T-1"))
8003 .order_side(OrderSide::Buy)
8004 .last_qty(Quantity::from(10))
8005 .last_px(Price::from("1.00000"))
8006 .currency(Currency::USD())
8007 .commission(Money::new(2.0, base_currency))
8008 .position_id(PositionId::from("P-COMMISSION"))
8009 .event_id(UUID4::from_str("a1b2c3d4-e5f6-4789-8abc-def012345679").unwrap())
8010 .ts_event(UnixNanos::from(1))
8011 .build();
8012
8013 let position = Position::new(&instrument, fill);
8014
8015 assert_eq!(position.adjustments.len(), 1);
8016 assert_eq!(
8017 position.adjustments[0].event_id,
8018 UUID4::from_str("a1b2c3d4-e5f6-4789-8abc-def012345678").unwrap()
8019 );
8020 }
8021
8022 #[rstest]
8023 fn test_position_commission_in_base_currency_sell() {
8024 let btc_usdt = currency_pair_btcusdt();
8026 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
8027
8028 let order = OrderTestBuilder::new(OrderType::Market)
8029 .instrument_id(btc_usdt.id())
8030 .side(OrderSide::Sell)
8031 .quantity(Quantity::from("1.0"))
8032 .build();
8033
8034 let fill = TestOrderEventStubs::filled(
8036 &order,
8037 &btc_usdt,
8038 Some(TradeId::new("1")),
8039 None,
8040 Some(Price::from("50000.0")),
8041 Some(Quantity::from("1.0")),
8042 None,
8043 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
8044 None,
8045 None,
8046 );
8047
8048 let position = Position::new(&btc_usdt, fill.into());
8049
8050 assert!(
8053 (position.quantity.as_f64() - 1.001).abs() < 1e-9,
8054 "Position quantity should be 1.001 BTC (1.0 + 0.001 commission), was {}",
8055 position.quantity.as_f64()
8056 );
8057
8058 assert!(
8060 (position.signed_qty - (dec!(-1.001))).abs() < dec!(1e-9),
8061 "Signed qty should be -1.001, was {}",
8062 position.signed_qty
8063 );
8064
8065 assert_eq!(
8067 position.adjustments.len(),
8068 1,
8069 "Should have 1 adjustment event"
8070 );
8071 let adjustment = &position.adjustments[0];
8072 assert_eq!(
8073 adjustment.adjustment_type,
8074 PositionAdjustmentType::Commission
8075 );
8076 assert_eq!(
8078 adjustment.quantity_change,
8079 Some(rust_decimal_macros::dec!(-0.001))
8080 );
8081 assert_eq!(adjustment.pnl_change, None);
8082 }
8083
8084 #[rstest]
8085 fn test_position_commission_in_quote_currency_no_adjustment() {
8086 let btc_usdt = currency_pair_btcusdt();
8088 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
8089
8090 let order = OrderTestBuilder::new(OrderType::Market)
8091 .instrument_id(btc_usdt.id())
8092 .side(OrderSide::Buy)
8093 .quantity(Quantity::from("1.0"))
8094 .build();
8095
8096 let fill = TestOrderEventStubs::filled(
8098 &order,
8099 &btc_usdt,
8100 Some(TradeId::new("1")),
8101 None,
8102 Some(Price::from("50000.0")),
8103 Some(Quantity::from("1.0")),
8104 None,
8105 Some(Money::new(50.0, Currency::USD())),
8106 None,
8107 None,
8108 );
8109
8110 let position = Position::new(&btc_usdt, fill.into());
8111
8112 assert!(
8114 (position.quantity.as_f64() - 1.0).abs() < 1e-9,
8115 "Position quantity should be 1.0 BTC (no adjustment for quote currency commission), was {}",
8116 position.quantity.as_f64()
8117 );
8118
8119 assert_eq!(
8121 position.adjustments.len(),
8122 0,
8123 "Should have no adjustment events for quote currency commission"
8124 );
8125 }
8126
8127 #[rstest]
8128 fn test_position_reset_clears_adjustments() {
8129 let btc_usdt = currency_pair_btcusdt();
8131 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
8132
8133 let buy_order = OrderTestBuilder::new(OrderType::Market)
8135 .instrument_id(btc_usdt.id())
8136 .side(OrderSide::Buy)
8137 .quantity(Quantity::from("1.0"))
8138 .build();
8139
8140 let buy_fill = TestOrderEventStubs::filled(
8141 &buy_order,
8142 &btc_usdt,
8143 Some(TradeId::new("1")),
8144 None,
8145 Some(Price::from("50000.0")),
8146 Some(Quantity::from("1.0")),
8147 None,
8148 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
8149 None,
8150 None,
8151 );
8152
8153 let mut position = Position::new(&btc_usdt, buy_fill.into());
8154 assert_eq!(position.adjustments.len(), 1, "Should have 1 adjustment");
8155
8156 let sell_order = OrderTestBuilder::new(OrderType::Market)
8158 .instrument_id(btc_usdt.id())
8159 .side(OrderSide::Sell)
8160 .quantity(Quantity::from("0.999"))
8161 .build();
8162
8163 let sell_fill = TestOrderEventStubs::filled(
8164 &sell_order,
8165 &btc_usdt,
8166 Some(TradeId::new("2")),
8167 None,
8168 Some(Price::from("51000.0")),
8169 Some(Quantity::from("0.999")),
8170 None,
8171 Some(Money::new(50.0, Currency::USD())), None,
8173 None,
8174 );
8175
8176 position.apply(&sell_fill.into());
8177 assert_eq!(position.side, PositionSide::Flat);
8178 assert_eq!(
8179 position.adjustments.len(),
8180 1,
8181 "Should still have 1 adjustment (no new one from quote commission)"
8182 );
8183
8184 let buy_order2 = OrderTestBuilder::new(OrderType::Market)
8186 .instrument_id(btc_usdt.id())
8187 .side(OrderSide::Buy)
8188 .quantity(Quantity::from("2.0"))
8189 .build();
8190
8191 let buy_fill2 = TestOrderEventStubs::filled(
8192 &buy_order2,
8193 &btc_usdt,
8194 Some(TradeId::new("3")),
8195 None,
8196 Some(Price::from("52000.0")),
8197 Some(Quantity::from("2.0")),
8198 None,
8199 Some(Money::new(0.002, btc_usdt.base_currency().unwrap())),
8200 None,
8201 None,
8202 );
8203
8204 position.apply(&buy_fill2.into());
8205
8206 assert_eq!(
8208 position.adjustments.len(),
8209 1,
8210 "Adjustments should be cleared on position reset, only new adjustment"
8211 );
8212 assert_eq!(
8213 position.adjustments[0].quantity_change,
8214 Some(rust_decimal_macros::dec!(-0.002)),
8215 "New adjustment should be for the new fill"
8216 );
8217 assert_eq!(position.events.len(), 1, "Events should also be reset");
8218 }
8219
8220 #[rstest]
8221 fn test_id_accessors_deduplicate_and_sort(audusd_sim: CurrencyPair) {
8222 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
8223 let position_id = PositionId::from("P-IDS");
8224 let fill1 = OrderFilledSpec::builder()
8225 .instrument_id(instrument.id())
8226 .client_order_id(ClientOrderId::from("O-2"))
8227 .venue_order_id(VenueOrderId::from("V-2"))
8228 .trade_id(TradeId::from("T-3"))
8229 .order_side(OrderSide::Buy)
8230 .last_qty(Quantity::from(10))
8231 .last_px(Price::from("1.00000"))
8232 .currency(Currency::USD())
8233 .position_id(position_id)
8234 .ts_event(UnixNanos::from(1))
8235 .build();
8236 let fill2 = OrderFilledSpec::builder()
8237 .instrument_id(instrument.id())
8238 .client_order_id(ClientOrderId::from("O-1"))
8239 .venue_order_id(VenueOrderId::from("V-1"))
8240 .trade_id(TradeId::from("T-1"))
8241 .order_side(OrderSide::Buy)
8242 .last_qty(Quantity::from(10))
8243 .last_px(Price::from("1.00000"))
8244 .currency(Currency::USD())
8245 .position_id(position_id)
8246 .ts_event(UnixNanos::from(2))
8247 .build();
8248 let fill3 = OrderFilledSpec::builder()
8249 .instrument_id(instrument.id())
8250 .client_order_id(ClientOrderId::from("O-1"))
8251 .venue_order_id(VenueOrderId::from("V-1"))
8252 .trade_id(TradeId::from("T-2"))
8253 .order_side(OrderSide::Buy)
8254 .last_qty(Quantity::from(10))
8255 .last_px(Price::from("1.00000"))
8256 .currency(Currency::USD())
8257 .position_id(position_id)
8258 .ts_event(UnixNanos::from(3))
8259 .build();
8260 let mut position = Position::new(&instrument, fill1);
8261 position.apply(&fill2);
8262 position.apply(&fill3);
8263
8264 assert_eq!(
8265 position.client_order_ids(),
8266 vec![ClientOrderId::from("O-1"), ClientOrderId::from("O-2")]
8267 );
8268 assert_eq!(
8269 position.venue_order_ids(),
8270 vec![VenueOrderId::from("V-1"), VenueOrderId::from("V-2")]
8271 );
8272 assert_eq!(
8273 position.trade_ids(),
8274 vec![
8275 TradeId::from("T-1"),
8276 TradeId::from("T-2"),
8277 TradeId::from("T-3")
8278 ]
8279 );
8280 }
8281
8282 #[rstest]
8283 fn test_is_open_and_is_closed_require_both_conditions(audusd_sim: CurrencyPair) {
8284 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
8285 let position_id = PositionId::from("P-PREDICATES");
8286 let fill_open = OrderFilledSpec::builder()
8287 .instrument_id(instrument.id())
8288 .client_order_id(ClientOrderId::from("O-1"))
8289 .trade_id(TradeId::from("T-1"))
8290 .order_side(OrderSide::Buy)
8291 .last_qty(Quantity::from(10))
8292 .last_px(Price::from("1.00000"))
8293 .currency(Currency::USD())
8294 .position_id(position_id)
8295 .ts_event(UnixNanos::from(1))
8296 .build();
8297 let fill_close = OrderFilledSpec::builder()
8298 .instrument_id(instrument.id())
8299 .client_order_id(ClientOrderId::from("O-2"))
8300 .trade_id(TradeId::from("T-2"))
8301 .order_side(OrderSide::Sell)
8302 .last_qty(Quantity::from(10))
8303 .last_px(Price::from("1.00000"))
8304 .currency(Currency::USD())
8305 .position_id(position_id)
8306 .ts_event(UnixNanos::from(2))
8307 .build();
8308 let mut position = Position::new(&instrument, fill_open);
8309 position.apply(&fill_close);
8310
8311 assert!(position.is_closed());
8312 assert!(!position.is_open());
8313
8314 position
8315 .apply_adjustment(PositionAdjusted::new(
8316 position.trader_id,
8317 position.strategy_id,
8318 position.instrument_id,
8319 position.id,
8320 position.account_id,
8321 PositionAdjustmentType::Funding,
8322 Some(dec!(5)),
8323 None,
8324 None,
8325 uuid4(),
8326 UnixNanos::from(3),
8327 UnixNanos::from(3),
8328 ))
8329 .unwrap();
8330
8331 assert_eq!(position.side, PositionSide::Long);
8332 assert_eq!(position.ts_closed, Some(UnixNanos::from(2)));
8333 assert!(!position.is_open());
8334 assert!(!position.is_closed());
8335 }
8336
8337 #[rstest]
8338 fn test_voided_fill_rebate_leaves_no_residual_commission(audusd_sim: CurrencyPair) {
8339 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
8340 let position_id = PositionId::from("P-VOID-REBATE");
8341 let fill1 = OrderFilledSpec::builder()
8342 .instrument_id(instrument.id())
8343 .client_order_id(ClientOrderId::from("O-1"))
8344 .trade_id(TradeId::from("T-1"))
8345 .order_side(OrderSide::Buy)
8346 .last_qty(Quantity::from(10))
8347 .last_px(Price::from("1.00000"))
8348 .currency(Currency::USD())
8349 .position_id(position_id)
8350 .ts_event(UnixNanos::from(1))
8351 .build();
8352 let rebate = Money::from("-2.00 USD");
8353 let fill2 = OrderFilledSpec::builder()
8354 .instrument_id(instrument.id())
8355 .client_order_id(ClientOrderId::from("O-2"))
8356 .trade_id(TradeId::from("T-2"))
8357 .order_side(OrderSide::Buy)
8358 .last_qty(Quantity::from(10))
8359 .last_px(Price::from("1.00000"))
8360 .currency(Currency::USD())
8361 .commission(rebate)
8362 .position_id(position_id)
8363 .ts_event(UnixNanos::from(2))
8364 .build();
8365 let void2 = matching_fill_void(&fill2, Quantity::from(10), Some(rebate));
8366 let mut position = Position::new(&instrument, fill1);
8367 position.apply(&fill2);
8368
8369 position
8370 .apply_fill_void(void2, Quantity::from(10), Some(rebate))
8371 .unwrap();
8372
8373 assert_eq!(position.quantity, Quantity::from(10));
8374 assert!(position.commissions().is_empty());
8375 assert_eq!(position.realized_pnl, Some(Money::from("0.00 USD")));
8376 }
8377
8378 #[rstest]
8379 fn test_voided_fill_commission_accumulates_into_realized_pnl(audusd_sim: CurrencyPair) {
8380 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
8381 let position_id = PositionId::from("P-SURVIVING-QUOTE");
8382 let fill1 = OrderFilledSpec::builder()
8383 .instrument_id(instrument.id())
8384 .client_order_id(ClientOrderId::from("O-1"))
8385 .trade_id(TradeId::from("T-1"))
8386 .order_side(OrderSide::Buy)
8387 .last_qty(Quantity::from(10))
8388 .last_px(Price::from("1.00000"))
8389 .currency(Currency::USD())
8390 .commission(Money::from("2.00 USD"))
8391 .position_id(position_id)
8392 .ts_event(UnixNanos::from(1))
8393 .build();
8394 let fill2 = OrderFilledSpec::builder()
8395 .instrument_id(instrument.id())
8396 .client_order_id(ClientOrderId::from("O-2"))
8397 .trade_id(TradeId::from("T-2"))
8398 .order_side(OrderSide::Buy)
8399 .last_qty(Quantity::from(10))
8400 .last_px(Price::from("2.00000"))
8401 .currency(Currency::USD())
8402 .commission(Money::from("1.00 USD"))
8403 .position_id(position_id)
8404 .ts_event(UnixNanos::from(2))
8405 .build();
8406 let void2 = matching_fill_void(&fill2, Quantity::from(10), None);
8407 let mut position = Position::new(&instrument, fill1);
8408 position.apply(&fill2);
8409
8410 position
8411 .apply_fill_void(void2, Quantity::from(10), None)
8412 .unwrap();
8413
8414 assert_eq!(position.quantity, Quantity::from(10));
8415 assert_eq!(position.commissions(), vec![Money::from("3.00 USD")]);
8416 assert_eq!(position.realized_pnl, Some(Money::from("-3.00 USD")));
8417 }
8418
8419 #[rstest]
8420 fn test_voided_fill_base_commission_preserves_avg_px_open(audusd_sim: CurrencyPair) {
8421 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
8422 let base_currency = instrument.base_currency().unwrap();
8423 let position_id = PositionId::from("P-SURVIVING-BASE");
8424 let fill1 = OrderFilledSpec::builder()
8425 .instrument_id(instrument.id())
8426 .client_order_id(ClientOrderId::from("O-1"))
8427 .trade_id(TradeId::from("T-1"))
8428 .order_side(OrderSide::Buy)
8429 .last_qty(Quantity::from(10))
8430 .last_px(Price::from("1.00000"))
8431 .currency(Currency::USD())
8432 .commission(Money::new(2.0, base_currency))
8433 .position_id(position_id)
8434 .ts_event(UnixNanos::from(1))
8435 .build();
8436 let fill2 = OrderFilledSpec::builder()
8437 .instrument_id(instrument.id())
8438 .client_order_id(ClientOrderId::from("O-2"))
8439 .trade_id(TradeId::from("T-2"))
8440 .order_side(OrderSide::Buy)
8441 .last_qty(Quantity::from(10))
8442 .last_px(Price::from("2.00000"))
8443 .currency(Currency::USD())
8444 .commission(Money::new(1.0, base_currency))
8445 .position_id(position_id)
8446 .ts_event(UnixNanos::from(2))
8447 .build();
8448 let void2 = matching_fill_void(&fill2, Quantity::from(10), None);
8449 let mut position = Position::new(&instrument, fill1);
8450 position.apply(&fill2);
8451
8452 position
8453 .apply_fill_void(void2, Quantity::from(10), None)
8454 .unwrap();
8455
8456 assert_eq!(position.quantity, Quantity::from(7));
8457 assert_eq!(position.avg_px_open, dec!(1.0));
8458 assert_eq!(position.commissions(), vec![Money::new(3.0, base_currency)]);
8459 }
8460
8461 #[rstest]
8462 fn test_fill_fragments_requires_matching_order_and_trade(audusd_sim: CurrencyPair) {
8463 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
8464 let position_id = PositionId::from("P-FRAGMENTS");
8465 let fill1 = OrderFilledSpec::builder()
8466 .instrument_id(instrument.id())
8467 .client_order_id(ClientOrderId::from("O-1"))
8468 .trade_id(TradeId::from("T-1"))
8469 .order_side(OrderSide::Buy)
8470 .last_qty(Quantity::from(10))
8471 .last_px(Price::from("1.00000"))
8472 .currency(Currency::USD())
8473 .position_id(position_id)
8474 .ts_event(UnixNanos::from(1))
8475 .build();
8476 let fill2 = OrderFilledSpec::builder()
8477 .instrument_id(instrument.id())
8478 .client_order_id(ClientOrderId::from("O-1"))
8479 .trade_id(TradeId::from("T-2"))
8480 .order_side(OrderSide::Buy)
8481 .last_qty(Quantity::from(10))
8482 .last_px(Price::from("1.00000"))
8483 .currency(Currency::USD())
8484 .position_id(position_id)
8485 .ts_event(UnixNanos::from(2))
8486 .build();
8487 let mut position = Position::new(&instrument, fill1);
8488 position.apply(&fill2);
8489
8490 let fragments = position.fill_fragments(ClientOrderId::from("O-1"), TradeId::from("T-1"));
8491
8492 assert_eq!(fragments.len(), 1);
8493 assert_eq!(fragments[0].trade_id, TradeId::from("T-1"));
8494 assert!(
8495 position
8496 .fill_fragments(ClientOrderId::from("O-2"), TradeId::from("T-1"))
8497 .is_empty()
8498 );
8499 }
8500
8501 #[rstest]
8502 fn test_apply_fill_void_scopes_staleness_check_to_same_trade(audusd_sim: CurrencyPair) {
8503 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
8504 let position_id = PositionId::from("P-VOID-TRADES");
8505 let fill1 = OrderFilledSpec::builder()
8506 .instrument_id(instrument.id())
8507 .client_order_id(ClientOrderId::from("O-1"))
8508 .trade_id(TradeId::from("T-1"))
8509 .order_side(OrderSide::Buy)
8510 .last_qty(Quantity::from(10))
8511 .last_px(Price::from("1.00000"))
8512 .currency(Currency::USD())
8513 .position_id(position_id)
8514 .ts_event(UnixNanos::from(1))
8515 .build();
8516 let fill2 = OrderFilledSpec::builder()
8517 .instrument_id(instrument.id())
8518 .client_order_id(ClientOrderId::from("O-1"))
8519 .trade_id(TradeId::from("T-2"))
8520 .order_side(OrderSide::Buy)
8521 .last_qty(Quantity::from(10))
8522 .last_px(Price::from("1.00000"))
8523 .currency(Currency::USD())
8524 .position_id(position_id)
8525 .ts_event(UnixNanos::from(2))
8526 .build();
8527 let void1 = matching_fill_void(&fill1, Quantity::from(6), None);
8528 let void2 = matching_fill_void(&fill2, Quantity::from(2), None);
8529 let mut position = Position::new(&instrument, fill1);
8530 position.apply(&fill2);
8531 position
8532 .apply_fill_void(void1, Quantity::from(6), None)
8533 .unwrap();
8534
8535 position
8536 .apply_fill_void(void2, Quantity::from(2), None)
8537 .unwrap();
8538
8539 assert_eq!(position.quantity, Quantity::from(12));
8540 assert_eq!(position.fill_voids.len(), 2);
8541 }
8542
8543 #[rstest]
8544 fn test_apply_fill_void_removes_quantity_from_voided_trade_only(audusd_sim: CurrencyPair) {
8545 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
8546 let position_id = PositionId::from("P-VOID-ATTRIBUTION");
8547 let fill1 = OrderFilledSpec::builder()
8548 .instrument_id(instrument.id())
8549 .client_order_id(ClientOrderId::from("O-1"))
8550 .trade_id(TradeId::from("T-1"))
8551 .order_side(OrderSide::Buy)
8552 .last_qty(Quantity::from(10))
8553 .last_px(Price::from("1.00000"))
8554 .currency(Currency::USD())
8555 .position_id(position_id)
8556 .ts_event(UnixNanos::from(1))
8557 .build();
8558 let fill2 = OrderFilledSpec::builder()
8559 .instrument_id(instrument.id())
8560 .client_order_id(ClientOrderId::from("O-1"))
8561 .trade_id(TradeId::from("T-2"))
8562 .order_side(OrderSide::Buy)
8563 .last_qty(Quantity::from(10))
8564 .last_px(Price::from("2.00000"))
8565 .currency(Currency::USD())
8566 .position_id(position_id)
8567 .ts_event(UnixNanos::from(2))
8568 .build();
8569 let void1 = matching_fill_void(&fill1, Quantity::from(4), None);
8570 let mut position = Position::new(&instrument, fill1);
8571 position.apply(&fill2);
8572
8573 position
8574 .apply_fill_void(void1, Quantity::from(4), None)
8575 .unwrap();
8576
8577 assert_eq!(position.quantity, Quantity::from(16));
8578 assert_eq!(position.avg_px_open, dec!(1.625));
8579 }
8580
8581 #[rstest]
8582 fn test_purge_events_for_order_retains_other_order_fill_voids(audusd_sim: CurrencyPair) {
8583 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
8584 let position_id = PositionId::from("P-PURGE-VOID");
8585 let fill1 = OrderFilledSpec::builder()
8586 .instrument_id(instrument.id())
8587 .client_order_id(ClientOrderId::from("O-1"))
8588 .trade_id(TradeId::from("T-1"))
8589 .order_side(OrderSide::Buy)
8590 .last_qty(Quantity::from(10))
8591 .last_px(Price::from("1.00000"))
8592 .currency(Currency::USD())
8593 .position_id(position_id)
8594 .ts_event(UnixNanos::from(1))
8595 .build();
8596 let fill2 = OrderFilledSpec::builder()
8597 .instrument_id(instrument.id())
8598 .client_order_id(ClientOrderId::from("O-2"))
8599 .trade_id(TradeId::from("T-2"))
8600 .order_side(OrderSide::Buy)
8601 .last_qty(Quantity::from(10))
8602 .last_px(Price::from("1.00000"))
8603 .currency(Currency::USD())
8604 .position_id(position_id)
8605 .ts_event(UnixNanos::from(2))
8606 .build();
8607 let void1 = matching_fill_void(&fill1, Quantity::from(2), None);
8608 let void2 = matching_fill_void(&fill2, Quantity::from(3), None);
8609 let mut position = Position::new(&instrument, fill1);
8610 position.apply(&fill2);
8611 position
8612 .apply_fill_void(void1, Quantity::from(2), None)
8613 .unwrap();
8614 position
8615 .apply_fill_void(void2, Quantity::from(3), None)
8616 .unwrap();
8617
8618 assert_eq!(position.fill_voids.len(), 2);
8619
8620 position
8621 .purge_events_for_order(ClientOrderId::from("O-1"))
8622 .unwrap();
8623
8624 assert_eq!(position.fill_voids.len(), 1);
8625 assert_eq!(
8626 position.fill_voids[0].event.client_order_id,
8627 ClientOrderId::from("O-2")
8628 );
8629 assert_eq!(position.fill_voids[0].voided_qty, Quantity::from(3));
8630 }
8631
8632 #[rstest]
8633 fn test_purge_events_for_order_clears_adjustments_when_flat() {
8634 let btc_usdt = currency_pair_btcusdt();
8636 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
8637
8638 let order = OrderTestBuilder::new(OrderType::Market)
8639 .instrument_id(btc_usdt.id())
8640 .side(OrderSide::Buy)
8641 .quantity(Quantity::from("1.0"))
8642 .build();
8643
8644 let fill = TestOrderEventStubs::filled(
8645 &order,
8646 &btc_usdt,
8647 Some(TradeId::new("1")),
8648 None,
8649 Some(Price::from("50000.0")),
8650 Some(Quantity::from("1.0")),
8651 None,
8652 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
8653 None,
8654 None,
8655 );
8656
8657 let mut position = Position::new(&btc_usdt, fill.into());
8658 assert_eq!(position.adjustments.len(), 1, "Should have 1 adjustment");
8659 assert_eq!(position.events.len(), 1);
8660
8661 position
8663 .purge_events_for_order(order.client_order_id())
8664 .unwrap();
8665
8666 assert_eq!(position.side, PositionSide::Flat);
8667 assert_eq!(position.events.len(), 0, "Events should be cleared");
8668 assert_eq!(
8669 position.adjustments.len(),
8670 0,
8671 "Adjustments should be cleared when position goes flat"
8672 );
8673 assert_eq!(position.quantity, Quantity::zero(btc_usdt.size_precision()));
8674 }
8675
8676 #[rstest]
8677 fn test_purge_events_for_order_clears_adjustments_on_rebuild() {
8678 let btc_usdt = currency_pair_btcusdt();
8680 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
8681
8682 let order1 = OrderTestBuilder::new(OrderType::Market)
8684 .instrument_id(btc_usdt.id())
8685 .side(OrderSide::Buy)
8686 .quantity(Quantity::from("1.0"))
8687 .client_order_id(ClientOrderId::new("O-001"))
8688 .build();
8689
8690 let fill1 = TestOrderEventStubs::filled(
8691 &order1,
8692 &btc_usdt,
8693 Some(TradeId::new("1")),
8694 None,
8695 Some(Price::from("50000.0")),
8696 Some(Quantity::from("1.0")),
8697 None,
8698 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
8699 None,
8700 None,
8701 );
8702
8703 let mut position = Position::new(&btc_usdt, fill1.into());
8704 assert_eq!(position.adjustments.len(), 1);
8705
8706 let order2 = OrderTestBuilder::new(OrderType::Market)
8708 .instrument_id(btc_usdt.id())
8709 .side(OrderSide::Buy)
8710 .quantity(Quantity::from("2.0"))
8711 .client_order_id(ClientOrderId::new("O-002"))
8712 .build();
8713
8714 let fill2 = TestOrderEventStubs::filled(
8715 &order2,
8716 &btc_usdt,
8717 Some(TradeId::new("2")),
8718 None,
8719 Some(Price::from("51000.0")),
8720 Some(Quantity::from("2.0")),
8721 None,
8722 Some(Money::new(0.002, btc_usdt.base_currency().unwrap())),
8723 None,
8724 None,
8725 );
8726
8727 position.apply(&fill2.into());
8728 assert_eq!(position.adjustments.len(), 2, "Should have 2 adjustments");
8729 assert_eq!(position.events.len(), 2);
8730
8731 position
8733 .purge_events_for_order(order1.client_order_id())
8734 .unwrap();
8735
8736 assert_eq!(position.events.len(), 1, "Should have 1 remaining event");
8737 assert_eq!(
8738 position.adjustments.len(),
8739 1,
8740 "Should have only the adjustment from remaining fill"
8741 );
8742 assert_eq!(
8743 position.adjustments[0].quantity_change,
8744 Some(rust_decimal_macros::dec!(-0.002)),
8745 "Should be the adjustment from order2"
8746 );
8747 assert!(
8748 (position.quantity.as_f64() - 1.998).abs() < 1e-9,
8749 "Quantity should be 2.0 - 0.002 commission"
8750 );
8751 }
8752
8753 fn btcusdt_fill(trade_id: &str, client_order_id: &str, quantity: &str) -> OrderFilled {
8754 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
8755 let order = OrderTestBuilder::new(OrderType::Market)
8756 .instrument_id(instrument.id())
8757 .side(OrderSide::Buy)
8758 .quantity(Quantity::from(quantity))
8759 .client_order_id(ClientOrderId::new(client_order_id))
8760 .build();
8761
8762 TestOrderEventStubs::filled(
8763 &order,
8764 &instrument,
8765 Some(TradeId::new(trade_id)),
8766 None,
8767 Some(Price::from("50000.0")),
8768 Some(Quantity::from(quantity)),
8769 None,
8770 Some(Money::new(1.0, instrument.quote_currency())),
8771 None,
8772 None,
8773 )
8774 .into()
8775 }
8776
8777 fn funding_payment(position: &Position, pnl: f64) -> PositionAdjusted {
8778 PositionAdjusted::new(
8779 position.trader_id,
8780 position.strategy_id,
8781 position.instrument_id,
8782 position.id,
8783 position.account_id,
8784 PositionAdjustmentType::Funding,
8785 None,
8786 Some(Money::new(pnl, position.settlement_currency)),
8787 None,
8788 uuid4(),
8789 UnixNanos::from(5),
8790 UnixNanos::from(5),
8791 )
8792 }
8793
8794 fn btcusdt_position() -> Position {
8795 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
8796 Position::new(&instrument, btcusdt_fill("1", "O-001", "1.0"))
8797 }
8798
8799 #[rstest]
8800 fn test_revert_last_adjustment_restores_prior_state() {
8801 let mut position = btcusdt_position();
8802 let before = serde_json::to_value(&position).unwrap();
8803 let prior = position.clone_without_events();
8804 position
8805 .apply_adjustment(funding_payment(&position, -10.0))
8806 .unwrap();
8807
8808 position.revert_last_adjustment(&prior).unwrap();
8809
8810 assert_eq!(serde_json::to_value(&position).unwrap(), before);
8811 }
8812
8813 fn assert_reverts_to_snapshot(mut position: Position, payment: PositionAdjusted) {
8814 let before = serde_json::to_value(&position).unwrap();
8815 let prior = position.clone_without_events();
8816 position.apply_adjustment(payment).unwrap();
8817
8818 position.revert_last_adjustment(&prior).unwrap();
8819
8820 assert_eq!(serde_json::to_value(&position).unwrap(), before);
8821 }
8822
8823 #[rstest]
8824 fn test_revert_last_adjustment_after_earlier_adjustment() {
8825 let mut position = btcusdt_position();
8826 position
8827 .apply_adjustment(funding_payment(&position, -10.0))
8828 .unwrap();
8829 let mut payment = funding_payment(&position, -5.0);
8830 payment.ts_event = UnixNanos::from(6);
8831
8832 assert_reverts_to_snapshot(position, payment);
8833 }
8834
8835 #[rstest]
8836 fn test_revert_last_adjustment_after_zero_value_adjustment_at_same_time() {
8837 let mut position = btcusdt_position();
8838 let prior = position.clone_without_events();
8839 let mut zero_payment = funding_payment(&position, 0.0);
8840 zero_payment.ts_event = position.ts_last;
8841 position.apply_adjustment(zero_payment).unwrap();
8842 let before = serde_json::to_value(&position).unwrap();
8843 position
8844 .apply_adjustment(funding_payment(&position, -5.0))
8845 .unwrap();
8846
8847 position.revert_last_adjustment(&prior).unwrap();
8849
8850 assert_eq!(serde_json::to_value(&position).unwrap(), before);
8851 }
8852
8853 #[rstest]
8854 #[case::partial("0.2", None)]
8855 #[case::full("0.5", Some(1.0))]
8856 fn test_revert_last_adjustment_after_voided_fill(
8857 #[case] voided_qty: &str,
8858 #[case] commission_voided: Option<f64>,
8859 ) {
8860 let mut position = btcusdt_position();
8861 let mut fill = btcusdt_fill("2", "O-002", "0.5");
8862 fill.ts_event = UnixNanos::from(3);
8863 position.apply(&fill);
8864 let commission_voided =
8865 commission_voided.map(|amount| Money::new(amount, position.settlement_currency));
8866 position
8867 .apply_fill_void(
8868 matching_fill_void(&fill, Quantity::from(voided_qty), commission_voided),
8869 Quantity::from(voided_qty),
8870 commission_voided,
8871 )
8872 .unwrap();
8873 let payment = funding_payment(&position, -5.0);
8874
8875 assert_reverts_to_snapshot(position, payment);
8876 }
8877
8878 #[rstest]
8879 fn test_revert_last_adjustment_after_purge() {
8880 let mut position = btcusdt_position();
8881 let mut fill = btcusdt_fill("2", "O-002", "0.5");
8882 fill.ts_event = UnixNanos::from(3);
8883 position.apply(&fill);
8884 position
8885 .apply_adjustment(funding_payment(&position, -10.0))
8886 .unwrap();
8887 position
8888 .purge_events_for_order(ClientOrderId::new("O-002"))
8889 .unwrap();
8890 let mut payment = funding_payment(&position, -5.0);
8891 payment.ts_event = UnixNanos::from(6);
8892
8893 assert_reverts_to_snapshot(position, payment);
8894 }
8895
8896 #[rstest]
8897 fn test_revert_last_adjustment_after_reopening() {
8898 let mut position = btcusdt_position();
8899 let mut close = btcusdt_fill("2", "O-002", "1.0");
8900 close.order_side = OrderSide::Sell;
8901 close.ts_event = UnixNanos::from(2);
8902 position.apply(&close);
8903 let mut reopen = btcusdt_fill("3", "O-003", "0.5");
8904 reopen.ts_event = UnixNanos::from(3);
8905 position.apply(&reopen);
8906 let payment = funding_payment(&position, -5.0);
8907
8908 assert_reverts_to_snapshot(position, payment);
8909 }
8910
8911 #[rstest]
8912 fn test_revert_last_adjustment_rejects_snapshot_of_another_position() {
8913 let mut position = btcusdt_position();
8914 let mut prior = position.clone_without_events();
8915 prior.id = PositionId::new("P-OTHER");
8916 position
8917 .apply_adjustment(funding_payment(&position, -10.0))
8918 .unwrap();
8919 let after = serde_json::to_value(&position).unwrap();
8920
8921 let error = position.revert_last_adjustment(&prior).unwrap_err();
8922
8923 assert!(error.to_string().contains("prior.id"), "{error}");
8924 assert_eq!(serde_json::to_value(&position).unwrap(), after);
8925 }
8926
8927 #[rstest]
8928 fn test_revert_last_adjustment_rejects_after_later_fill() {
8929 let mut position = btcusdt_position();
8930 let prior = position.clone_without_events();
8931 position
8932 .apply_adjustment(funding_payment(&position, -10.0))
8933 .unwrap();
8934 position.apply(&btcusdt_fill("2", "O-002", "0.5"));
8935 let after = serde_json::to_value(&position).unwrap();
8936
8937 let error = position.revert_last_adjustment(&prior).unwrap_err();
8938
8939 assert!(
8940 error.to_string().contains("not its latest adjustment"),
8941 "{error}"
8942 );
8943 assert_eq!(serde_json::to_value(&position).unwrap(), after);
8944 }
8945
8946 #[rstest]
8947 fn test_revert_last_adjustment_rejects_stale_snapshot() {
8948 let mut position = btcusdt_position();
8949 let prior = position.clone_without_events();
8950 position
8951 .apply_adjustment(funding_payment(&position, -10.0))
8952 .unwrap();
8953 position
8954 .apply_adjustment(funding_payment(&position, -5.0))
8955 .unwrap();
8956 let after = serde_json::to_value(&position).unwrap();
8957
8958 let error = position.revert_last_adjustment(&prior).unwrap_err();
8959
8960 assert!(error.to_string().contains("predates adjustment"), "{error}");
8961 assert_eq!(serde_json::to_value(&position).unwrap(), after);
8962 }
8963
8964 #[rstest]
8965 fn test_revert_last_adjustment_rejects_snapshot_before_zero_value_adjustment() {
8966 let mut position = btcusdt_position();
8967 let prior = position.clone_without_events();
8968 position
8969 .apply_adjustment(funding_payment(&position, 0.0))
8970 .unwrap();
8971 let mut later_payment = funding_payment(&position, -5.0);
8972 later_payment.ts_event = UnixNanos::from(6);
8973 later_payment.ts_init = UnixNanos::from(6);
8974 position.apply_adjustment(later_payment).unwrap();
8975 let after = serde_json::to_value(&position).unwrap();
8976
8977 let error = position.revert_last_adjustment(&prior).unwrap_err();
8978
8979 assert!(error.to_string().contains("predates adjustment"), "{error}");
8980 assert_eq!(serde_json::to_value(&position).unwrap(), after);
8981 }
8982
8983 #[rstest]
8984 fn test_revert_last_adjustment_rejects_snapshot_before_zero_value_adjustment_and_voided_fill() {
8985 let mut position = btcusdt_position();
8986 let prior = position.clone_without_events();
8987 position
8988 .apply_adjustment(funding_payment(&position, 0.0))
8989 .unwrap();
8990 let mut fill = btcusdt_fill("2", "O-002", "0.5");
8991 fill.ts_event = UnixNanos::from(6);
8992 position.apply(&fill);
8993 let commission = Money::new(1.0, position.settlement_currency);
8994 position
8995 .apply_fill_void(
8996 matching_fill_void(&fill, Quantity::from("0.5"), Some(commission)),
8997 Quantity::from("0.5"),
8998 Some(commission),
8999 )
9000 .unwrap();
9001 let mut later_payment = funding_payment(&position, -5.0);
9002 later_payment.ts_event = UnixNanos::from(7);
9003 position.apply_adjustment(later_payment).unwrap();
9004 let after = serde_json::to_value(&position).unwrap();
9005
9006 let error = position.revert_last_adjustment(&prior).unwrap_err();
9007
9008 assert!(error.to_string().contains("predates adjustment"), "{error}");
9009 assert_eq!(serde_json::to_value(&position).unwrap(), after);
9010 }
9011
9012 #[rstest]
9013 fn test_revert_last_adjustment_rejects_snapshot_with_pnl_at_money_bounds() {
9014 let mut position = btcusdt_position();
9015 let mut prior = position.clone_without_events();
9016 prior.realized_pnl = Some(Money::from_raw(MONEY_RAW_MAX, position.settlement_currency));
9017 position
9018 .apply_adjustment(funding_payment(&position, 10.0))
9019 .unwrap();
9020 let after = serde_json::to_value(&position).unwrap();
9021
9022 let error = position.revert_last_adjustment(&prior).unwrap_err();
9023
9024 assert!(
9025 error.to_string().contains("cannot take the PnL change"),
9026 "{error}"
9027 );
9028 assert_eq!(serde_json::to_value(&position).unwrap(), after);
9029 }
9030
9031 #[rstest]
9032 #[case::size_precision("prior.size_precision")]
9033 #[case::settlement_currency("prior.settlement_currency")]
9034 fn test_revert_last_adjustment_rejects_snapshot_of_other_instrument_config(
9035 #[case] field: &str,
9036 ) {
9037 let mut position = btcusdt_position();
9038 let mut prior = position.clone_without_events();
9039 match field {
9040 "prior.size_precision" => prior.size_precision += 1,
9041 _ => prior.settlement_currency = Currency::USD(),
9042 }
9043
9044 position
9045 .apply_adjustment(funding_payment(&position, -10.0))
9046 .unwrap();
9047 let after = serde_json::to_value(&position).unwrap();
9048
9049 let error = position.revert_last_adjustment(&prior).unwrap_err();
9050
9051 assert!(error.to_string().contains(field), "{error}");
9052 assert_eq!(serde_json::to_value(&position).unwrap(), after);
9053 }
9054
9055 #[rstest]
9056 fn test_revert_last_adjustment_rejects_snapshot_with_other_pnl_currency() {
9057 let mut position = btcusdt_position();
9058 let mut prior = position.clone_without_events();
9059 prior.realized_pnl = Some(Money::new(1.0, Currency::USD()));
9060 position
9061 .apply_adjustment(funding_payment(&position, -10.0))
9062 .unwrap();
9063 let after = serde_json::to_value(&position).unwrap();
9064
9065 let error = position.revert_last_adjustment(&prior).unwrap_err();
9066
9067 assert!(
9068 error.to_string().contains("cannot take the PnL change"),
9069 "{error}"
9070 );
9071 assert_eq!(serde_json::to_value(&position).unwrap(), after);
9072 }
9073
9074 #[rstest]
9075 fn test_purge_events_preserves_manual_adjustments() {
9076 let btc_usdt = currency_pair_btcusdt();
9078 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
9079
9080 let order1 = OrderTestBuilder::new(OrderType::Market)
9082 .instrument_id(btc_usdt.id())
9083 .side(OrderSide::Buy)
9084 .quantity(Quantity::from("1.0"))
9085 .client_order_id(ClientOrderId::new("O-001"))
9086 .build();
9087
9088 let fill1 = TestOrderEventStubs::filled(
9089 &order1,
9090 &btc_usdt,
9091 Some(TradeId::new("1")),
9092 None,
9093 Some(Price::from("50000.0")),
9094 Some(Quantity::from("1.0")),
9095 None,
9096 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
9097 None,
9098 None,
9099 );
9100
9101 let mut position = Position::new(&btc_usdt, fill1.into());
9102 assert_eq!(position.adjustments.len(), 1);
9103
9104 let funding_adjustment = PositionAdjusted::new(
9106 position.trader_id,
9107 position.strategy_id,
9108 position.instrument_id,
9109 position.id,
9110 position.account_id,
9111 PositionAdjustmentType::Funding,
9112 None,
9113 Some(Money::new(10.0, btc_usdt.quote_currency())),
9114 None, uuid4(),
9116 UnixNanos::default(),
9117 UnixNanos::default(),
9118 );
9119 position.apply_adjustment(funding_adjustment).unwrap();
9120 assert_eq!(position.adjustments.len(), 2);
9121
9122 let order2 = OrderTestBuilder::new(OrderType::Market)
9124 .instrument_id(btc_usdt.id())
9125 .side(OrderSide::Buy)
9126 .quantity(Quantity::from("2.0"))
9127 .client_order_id(ClientOrderId::new("O-002"))
9128 .build();
9129
9130 let fill2 = TestOrderEventStubs::filled(
9131 &order2,
9132 &btc_usdt,
9133 Some(TradeId::new("2")),
9134 None,
9135 Some(Price::from("51000.0")),
9136 Some(Quantity::from("2.0")),
9137 None,
9138 Some(Money::new(0.002, btc_usdt.base_currency().unwrap())),
9139 None,
9140 None,
9141 );
9142
9143 position.apply(&fill2.into());
9144 assert_eq!(
9145 position.adjustments.len(),
9146 3,
9147 "Should have 3 adjustments: 2 commissions + 1 funding"
9148 );
9149
9150 position
9152 .purge_events_for_order(order1.client_order_id())
9153 .unwrap();
9154
9155 assert_eq!(position.events.len(), 1, "Should have 1 remaining event");
9156 assert_eq!(
9157 position.adjustments.len(),
9158 2,
9159 "Should have funding adjustment + commission from remaining fill"
9160 );
9161
9162 let has_funding = position.adjustments.iter().any(|adj| {
9164 adj.adjustment_type == PositionAdjustmentType::Funding
9165 && adj.pnl_change == Some(Money::new(10.0, btc_usdt.quote_currency()))
9166 });
9167 assert!(has_funding, "Funding adjustment should be preserved");
9168
9169 assert_eq!(
9172 position.realized_pnl,
9173 Some(Money::new(10.0, btc_usdt.quote_currency())),
9174 "Realized PnL should be the funding payment only (commission is in BTC, not USDT)"
9175 );
9176 }
9177
9178 #[rstest]
9179 fn test_position_commission_affects_buy_and_sell_qty() {
9180 let btc_usdt = currency_pair_btcusdt();
9182 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
9183
9184 let buy_order = OrderTestBuilder::new(OrderType::Market)
9185 .instrument_id(btc_usdt.id())
9186 .side(OrderSide::Buy)
9187 .quantity(Quantity::from("1.0"))
9188 .build();
9189
9190 let fill = TestOrderEventStubs::filled(
9192 &buy_order,
9193 &btc_usdt,
9194 Some(TradeId::new("1")),
9195 None,
9196 Some(Price::from("50000.0")),
9197 Some(Quantity::from("1.0")),
9198 None,
9199 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
9200 None,
9201 None,
9202 );
9203
9204 let position = Position::new(&btc_usdt, fill.into());
9205
9206 assert!(
9208 (position.buy_qty.as_f64() - 1.0).abs() < 1e-9,
9209 "buy_qty should be 1.0 (order fill amount), was {}",
9210 position.buy_qty.as_f64()
9211 );
9212
9213 assert!(
9215 (position.quantity.as_f64() - 0.999).abs() < 1e-9,
9216 "position.quantity should be 0.999 (1.0 - 0.001 commission), was {}",
9217 position.quantity.as_f64()
9218 );
9219
9220 assert_eq!(position.adjustments.len(), 1);
9222 assert_eq!(
9223 position.adjustments[0].quantity_change,
9224 Some(rust_decimal_macros::dec!(-0.001))
9225 );
9226 }
9227
9228 #[rstest]
9229 fn test_position_perpetual_commission_no_adjustment() {
9230 let eth_perp = crypto_perpetual_ethusdt();
9232 let eth_perp = InstrumentAny::CryptoPerpetual(eth_perp);
9233
9234 let order = OrderTestBuilder::new(OrderType::Market)
9235 .instrument_id(eth_perp.id())
9236 .side(OrderSide::Buy)
9237 .quantity(Quantity::from("1.0"))
9238 .build();
9239
9240 let fill = TestOrderEventStubs::filled(
9242 &order,
9243 ð_perp,
9244 Some(TradeId::new("1")),
9245 None,
9246 Some(Price::from("3000.0")),
9247 Some(Quantity::from("1.0")),
9248 None,
9249 Some(Money::new(0.001, eth_perp.base_currency().unwrap())),
9250 None,
9251 None,
9252 );
9253
9254 let position = Position::new(ð_perp, fill.into());
9255
9256 assert!(
9258 (position.quantity.as_f64() - 1.0).abs() < 1e-9,
9259 "Perpetual position should be 1.0 contracts (no adjustment), was {}",
9260 position.quantity.as_f64()
9261 );
9262
9263 assert!(
9265 (position.signed_qty - dec!(1.0)).abs() < dec!(1e-9),
9266 "Signed qty should be 1.0, was {}",
9267 position.signed_qty
9268 );
9269 }
9270
9271 #[rstest]
9272 fn test_signed_decimal_qty_long(stub_position_long: Position) {
9273 let signed_qty = stub_position_long.signed_decimal_qty();
9274 assert!(signed_qty > Decimal::ZERO);
9275 assert_eq!(signed_qty, stub_position_long.signed_qty);
9276 }
9277
9278 #[rstest]
9279 fn test_signed_decimal_qty_short(stub_position_short: Position) {
9280 let signed_qty = stub_position_short.signed_decimal_qty();
9281 assert!(signed_qty < Decimal::ZERO);
9282 assert_eq!(signed_qty, stub_position_short.signed_qty);
9283 }
9284
9285 #[rstest]
9286 fn test_signed_decimal_qty_flat(audusd_sim: CurrencyPair) {
9287 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
9288 let order = OrderTestBuilder::new(OrderType::Market)
9289 .instrument_id(audusd_sim.id())
9290 .side(OrderSide::Buy)
9291 .quantity(Quantity::from(100_000))
9292 .build();
9293 let fill = TestOrderEventStubs::filled(
9294 &order,
9295 &audusd_sim,
9296 Some(TradeId::new("1")),
9297 None,
9298 Some(Price::from("1.00001")),
9299 None,
9300 None,
9301 None,
9302 None,
9303 None,
9304 );
9305 let mut position = Position::new(&audusd_sim, fill.into());
9306
9307 let close_order = OrderTestBuilder::new(OrderType::Market)
9308 .instrument_id(audusd_sim.id())
9309 .side(OrderSide::Sell)
9310 .quantity(Quantity::from(100_000))
9311 .build();
9312 let close_fill = TestOrderEventStubs::filled(
9313 &close_order,
9314 &audusd_sim,
9315 Some(TradeId::new("2")),
9316 None,
9317 Some(Price::from("1.00002")),
9318 None,
9319 None,
9320 None,
9321 None,
9322 None,
9323 );
9324 position.apply(&close_fill.into());
9325
9326 assert_eq!(position.side, PositionSide::Flat);
9327 assert_eq!(position.signed_decimal_qty(), Decimal::ZERO);
9328 }
9329
9330 #[rstest]
9331 fn test_position_flat_with_floating_point_precision_edge_case() {
9332 let btc_usdt = currency_pair_btcusdt();
9336 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
9337
9338 let order1 = OrderTestBuilder::new(OrderType::Market)
9339 .instrument_id(btc_usdt.id())
9340 .side(OrderSide::Buy)
9341 .quantity(Quantity::from("0.123456789"))
9342 .build();
9343 let fill1 = TestOrderEventStubs::filled(
9344 &order1,
9345 &btc_usdt,
9346 Some(TradeId::new("1")),
9347 None,
9348 Some(Price::from("50000.00")),
9349 None,
9350 None,
9351 None,
9352 None,
9353 None,
9354 );
9355 let mut position = Position::new(&btc_usdt, fill1.into());
9356
9357 assert_eq!(position.side, PositionSide::Long);
9358 assert!(position.quantity.is_positive());
9359
9360 let order2 = OrderTestBuilder::new(OrderType::Market)
9361 .instrument_id(btc_usdt.id())
9362 .side(OrderSide::Sell)
9363 .quantity(Quantity::from("0.123456789"))
9364 .build();
9365 let fill2 = TestOrderEventStubs::filled(
9366 &order2,
9367 &btc_usdt,
9368 Some(TradeId::new("2")),
9369 None,
9370 Some(Price::from("50000.00")),
9371 None,
9372 None,
9373 None,
9374 None,
9375 None,
9376 );
9377 position.apply(&fill2.into());
9378
9379 assert_eq!(
9380 position.side,
9381 PositionSide::Flat,
9382 "Position should be FLAT, not {:?}",
9383 position.side
9384 );
9385 assert!(
9386 position.quantity.is_zero(),
9387 "Quantity should be zero, was {}",
9388 position.quantity
9389 );
9390 assert_eq!(
9391 position.signed_qty,
9392 dec!(0.0),
9393 "signed_qty should be normalized to 0.0, was {}",
9394 position.signed_qty
9395 );
9396 assert!(position.is_closed());
9397 }
9398
9399 #[rstest]
9400 #[case(OrderSide::Buy, OrderSide::Sell, "162.50", "176.50", 171.5)]
9401 #[case(OrderSide::Sell, OrderSide::Buy, "140.00", "126.00", 131.0)]
9402 fn test_position_exact_close_after_partial_fills_preserves_open_average(
9403 #[case] entry: OrderSide,
9404 #[case] exit: OrderSide,
9405 #[case] first_close_px: &str,
9406 #[case] final_close_px: &str,
9407 #[case] expected_avg_close: f64,
9408 ) {
9409 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
9410 let position_id = PositionId::new("P-PARTIAL-CLOSE");
9411 let open_order = OrderTestBuilder::new(OrderType::Market)
9412 .instrument_id(instrument.id())
9413 .client_order_id(ClientOrderId::new("O-OPEN"))
9414 .side(entry)
9415 .quantity(Quantity::from("0.7"))
9416 .build();
9417 let open_fill = TestOrderEventStubs::filled(
9418 &open_order,
9419 &instrument,
9420 Some(TradeId::new("T-OPEN")),
9421 Some(position_id),
9422 Some(Price::from("151.25")),
9423 None,
9424 None,
9425 Some(Money::from("0 USDT")),
9426 Some(UnixNanos::from(1_000)),
9427 None,
9428 );
9429 let mut position = Position::new(&instrument, open_fill.into());
9430
9431 for (client_order_id, trade_id, quantity, price, ts_event) in [
9432 ("O-CLOSE-1", "T-CLOSE-1", "0.25", first_close_px, 1_100),
9433 ("O-CLOSE-2", "T-CLOSE-2", "0.45", final_close_px, 1_250),
9434 ] {
9435 let close_order = OrderTestBuilder::new(OrderType::Market)
9436 .instrument_id(instrument.id())
9437 .client_order_id(ClientOrderId::new(client_order_id))
9438 .side(exit)
9439 .quantity(Quantity::from(quantity))
9440 .build();
9441 let close_fill = TestOrderEventStubs::filled(
9442 &close_order,
9443 &instrument,
9444 Some(TradeId::new(trade_id)),
9445 Some(position_id),
9446 Some(Price::from(price)),
9447 None,
9448 None,
9449 Some(Money::from("0 USDT")),
9450 Some(UnixNanos::from(ts_event)),
9451 None,
9452 );
9453 position.apply(&close_fill.into());
9454 }
9455
9456 assert_eq!(position.entry, entry);
9457 assert_eq!(position.side, PositionSide::Flat);
9458 assert_eq!(position.signed_qty, dec!(0.0));
9459 assert_eq!(position.quantity, Quantity::zero(6));
9460 assert_eq!(position.peak_qty, Quantity::from("0.7"));
9461 assert_eq!(position.buy_qty, Quantity::from("0.7"));
9462 assert_eq!(position.sell_qty, Quantity::from("0.7"));
9463 assert_eq!(position.avg_px_open, dec!(151.25));
9464 assert_eq!(
9465 position.avg_px_close,
9466 Some(Decimal::try_from(expected_avg_close).unwrap())
9467 );
9468 assert_eq!(position.realized_return, 0.133_884_297_520_661_17);
9469 assert_eq!(position.realized_pnl, Some(Money::from("14.17500000 USDT")));
9470 assert_eq!(position.commissions(), vec![Money::from("0 USDT")]);
9471 assert_eq!(position.opening_order_id, ClientOrderId::new("O-OPEN"));
9472 assert_eq!(
9473 position.closing_order_id,
9474 Some(ClientOrderId::new("O-CLOSE-2"))
9475 );
9476 assert_eq!(position.ts_opened, UnixNanos::from(1_000));
9477 assert_eq!(position.ts_last, UnixNanos::from(1_250));
9478 assert_eq!(position.ts_closed, Some(UnixNanos::from(1_250)));
9479 assert_eq!(position.duration_ns, DurationNanos::new(250));
9480 assert_eq!(position.event_count(), 3);
9481 assert!(position.is_closed());
9482 }
9483
9484 #[rstest]
9485 #[case(
9486 OrderSide::Buy,
9487 OrderSide::Sell,
9488 "140.00",
9489 "126.00",
9490 PositionSide::Short,
9491 -0.000_001
9492 )]
9493 #[case(
9494 OrderSide::Sell,
9495 OrderSide::Buy,
9496 "162.50",
9497 "176.50",
9498 PositionSide::Long,
9499 0.000_001
9500 )]
9501 fn test_position_true_reversal_uses_fill_price(
9502 #[case] entry: OrderSide,
9503 #[case] exit: OrderSide,
9504 #[case] first_close_px: &str,
9505 #[case] reversal_px: &str,
9506 #[case] expected_side: PositionSide,
9507 #[case] expected_signed_qty: f64,
9508 ) {
9509 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
9510 let position_id = PositionId::new("P-REVERSAL");
9511 let open_order = OrderTestBuilder::new(OrderType::Market)
9512 .instrument_id(instrument.id())
9513 .client_order_id(ClientOrderId::new("O-REVERSAL-OPEN"))
9514 .side(entry)
9515 .quantity(Quantity::from("0.7"))
9516 .build();
9517 let open_fill = TestOrderEventStubs::filled(
9518 &open_order,
9519 &instrument,
9520 Some(TradeId::new("T-REVERSAL-OPEN")),
9521 Some(position_id),
9522 Some(Price::from("151.25")),
9523 None,
9524 None,
9525 Some(Money::from("0 USDT")),
9526 Some(UnixNanos::from(2_000)),
9527 None,
9528 );
9529 let mut position = Position::new(&instrument, open_fill.into());
9530
9531 let close_order = OrderTestBuilder::new(OrderType::Market)
9532 .instrument_id(instrument.id())
9533 .client_order_id(ClientOrderId::new("O-REVERSAL-CLOSE"))
9534 .side(exit)
9535 .quantity(Quantity::from("0.25"))
9536 .build();
9537 let close_fill = TestOrderEventStubs::filled(
9538 &close_order,
9539 &instrument,
9540 Some(TradeId::new("T-REVERSAL-CLOSE")),
9541 Some(position_id),
9542 Some(Price::from(first_close_px)),
9543 None,
9544 None,
9545 Some(Money::from("0 USDT")),
9546 Some(UnixNanos::from(2_050)),
9547 None,
9548 );
9549 position.apply(&close_fill.into());
9550
9551 let reversal_order = OrderTestBuilder::new(OrderType::Market)
9552 .instrument_id(instrument.id())
9553 .client_order_id(ClientOrderId::new("O-REVERSAL"))
9554 .side(exit)
9555 .quantity(Quantity::from("0.450001"))
9556 .build();
9557 let reversal_fill = TestOrderEventStubs::filled(
9558 &reversal_order,
9559 &instrument,
9560 Some(TradeId::new("T-REVERSAL")),
9561 Some(position_id),
9562 Some(Price::from(reversal_px)),
9563 None,
9564 None,
9565 Some(Money::from("0 USDT")),
9566 Some(UnixNanos::from(2_100)),
9567 None,
9568 );
9569 position.apply(&reversal_fill.into());
9570
9571 assert_eq!(position.entry, exit);
9572 assert_eq!(position.side, expected_side);
9573 assert_eq!(
9574 position.signed_qty,
9575 Decimal::try_from(expected_signed_qty).unwrap()
9576 );
9577 assert_eq!(position.quantity, Quantity::from("0.000001"));
9578 assert_eq!(position.peak_qty, Quantity::from("0.7"));
9579 assert_eq!(position.avg_px_open, Price::from(reversal_px).as_decimal());
9580 assert_eq!(
9581 position.realized_pnl,
9582 Some(Money::from("-14.17500000 USDT"))
9583 );
9584 assert_eq!(position.commissions(), vec![Money::from("0 USDT")]);
9585 assert_eq!(
9586 position.opening_order_id,
9587 ClientOrderId::new("O-REVERSAL-OPEN")
9588 );
9589 assert_eq!(position.closing_order_id, None);
9590 assert_eq!(position.ts_opened, UnixNanos::from(2_000));
9591 assert_eq!(position.ts_last, UnixNanos::from(2_100));
9592 assert_eq!(position.ts_closed, None);
9593 assert_eq!(position.duration_ns, DurationNanos::default());
9594 assert_eq!(position.event_count(), 3);
9595 assert!(position.is_open());
9596 }
9597
9598 #[rstest]
9599 #[case(OrderSide::Buy, OrderSide::Sell)]
9600 #[case(OrderSide::Sell, OrderSide::Buy)]
9601 fn test_position_reversal_starts_new_close_episode(
9602 #[case] entry: OrderSide,
9603 #[case] exit: OrderSide,
9604 ) {
9605 let (
9606 open_px,
9607 first_close_px,
9608 reversal_px,
9609 expected_side,
9610 new_first_close_px,
9611 new_final_close_px,
9612 expected_avg_close,
9613 expected_return,
9614 ) = if entry == OrderSide::Buy {
9615 (
9616 "100.00",
9617 "110.00",
9618 "120.00",
9619 PositionSide::Short,
9620 "110.00",
9621 "90.00",
9622 95.0,
9623 0.208_333_333_333_333_34,
9624 )
9625 } else {
9626 (
9627 "120.00",
9628 "110.00",
9629 "100.00",
9630 PositionSide::Long,
9631 "110.00",
9632 "130.00",
9633 125.0,
9634 0.25,
9635 )
9636 };
9637 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
9638 let position_id = PositionId::new("P-REVERSAL-EPISODE");
9639 let open_order = OrderTestBuilder::new(OrderType::Market)
9640 .instrument_id(instrument.id())
9641 .client_order_id(ClientOrderId::new("O-OPEN"))
9642 .side(entry)
9643 .quantity(Quantity::from("10"))
9644 .build();
9645 let open_fill = TestOrderEventStubs::filled(
9646 &open_order,
9647 &instrument,
9648 Some(TradeId::new("T-OPEN")),
9649 Some(position_id),
9650 Some(Price::from(open_px)),
9651 None,
9652 None,
9653 Some(Money::from("1 USDT")),
9654 Some(UnixNanos::from(3_000)),
9655 None,
9656 );
9657 let mut position = Position::new(&instrument, open_fill.into());
9658
9659 for (client_order_id, trade_id, quantity, price, ts_event) in [
9660 ("O-CLOSE", "T-CLOSE", "4", first_close_px, 3_100),
9661 ("O-REVERSE", "T-REVERSE", "8", reversal_px, 3_200),
9662 ] {
9663 let order = OrderTestBuilder::new(OrderType::Market)
9664 .instrument_id(instrument.id())
9665 .client_order_id(ClientOrderId::new(client_order_id))
9666 .side(exit)
9667 .quantity(Quantity::from(quantity))
9668 .build();
9669 let fill = TestOrderEventStubs::filled(
9670 &order,
9671 &instrument,
9672 Some(TradeId::new(trade_id)),
9673 Some(position_id),
9674 Some(Price::from(price)),
9675 None,
9676 None,
9677 Some(Money::from("1 USDT")),
9678 Some(UnixNanos::from(ts_event)),
9679 None,
9680 );
9681 position.apply(&fill.into());
9682 }
9683
9684 assert_eq!(position.side, expected_side);
9685 assert_eq!(position.quantity, Quantity::from("2"));
9686 assert_eq!(position.avg_px_open, Price::from(reversal_px).as_decimal());
9687 assert_eq!(position.avg_px_close, None);
9688 assert_eq!(position.realized_return, 0.0);
9689 if expected_side == PositionSide::Long {
9690 assert_eq!(position.buy_qty, Quantity::from("2"));
9691 assert_eq!(position.sell_qty, Quantity::from("0"));
9692 } else {
9693 assert_eq!(position.buy_qty, Quantity::from("0"));
9694 assert_eq!(position.sell_qty, Quantity::from("2"));
9695 }
9696 assert_eq!(position.realized_pnl, Some(Money::from("157 USDT")));
9697
9698 for (client_order_id, trade_id, quantity, price, ts_event) in [
9699 (
9700 "O-NEW-CLOSE-1",
9701 "T-NEW-CLOSE-1",
9702 "0.5",
9703 new_first_close_px,
9704 3_300,
9705 ),
9706 (
9707 "O-NEW-CLOSE-2",
9708 "T-NEW-CLOSE-2",
9709 "1.5",
9710 new_final_close_px,
9711 3_400,
9712 ),
9713 ] {
9714 let order = OrderTestBuilder::new(OrderType::Market)
9715 .instrument_id(instrument.id())
9716 .client_order_id(ClientOrderId::new(client_order_id))
9717 .side(entry)
9718 .quantity(Quantity::from(quantity))
9719 .build();
9720 let fill = TestOrderEventStubs::filled(
9721 &order,
9722 &instrument,
9723 Some(TradeId::new(trade_id)),
9724 Some(position_id),
9725 Some(Price::from(price)),
9726 None,
9727 None,
9728 Some(Money::from("1 USDT")),
9729 Some(UnixNanos::from(ts_event)),
9730 None,
9731 );
9732 position.apply(&fill.into());
9733 }
9734
9735 assert_eq!(position.side, PositionSide::Flat);
9736 assert_eq!(position.quantity, Quantity::zero(6));
9737 assert_eq!(position.buy_qty, Quantity::from("2"));
9738 assert_eq!(position.sell_qty, Quantity::from("2"));
9739 assert_eq!(
9740 position.avg_px_close,
9741 Some(Decimal::try_from(expected_avg_close).unwrap())
9742 );
9743 assert_eq!(position.realized_return, expected_return);
9744 assert_eq!(position.realized_pnl, Some(Money::from("205 USDT")));
9745 assert_eq!(position.commissions(), vec![Money::from("5 USDT")]);
9746 }
9747
9748 #[rstest]
9749 fn test_position_adjustment_floating_point_precision_edge_case() {
9750 let btc_usdt = currency_pair_btcusdt();
9752 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
9753
9754 let order = OrderTestBuilder::new(OrderType::Market)
9755 .instrument_id(btc_usdt.id())
9756 .side(OrderSide::Buy)
9757 .quantity(Quantity::from("1.0"))
9758 .build();
9759 let fill = TestOrderEventStubs::filled(
9760 &order,
9761 &btc_usdt,
9762 Some(TradeId::new("1")),
9763 None,
9764 Some(Price::from("50000.00")),
9765 None,
9766 None,
9767 None,
9768 None,
9769 None,
9770 );
9771 let mut position = Position::new(&btc_usdt, fill.into());
9772
9773 let adjustment = PositionAdjusted::new(
9774 position.trader_id,
9775 position.strategy_id,
9776 position.instrument_id,
9777 position.id,
9778 position.account_id,
9779 PositionAdjustmentType::Commission,
9780 Some(Decimal::from_str("-1.0").unwrap()),
9781 None,
9782 None,
9783 uuid4(),
9784 UnixNanos::default(),
9785 UnixNanos::default(),
9786 );
9787 position.apply_adjustment(adjustment).unwrap();
9788
9789 assert_eq!(
9790 position.side,
9791 PositionSide::Flat,
9792 "Position should be FLAT after zeroing adjustment"
9793 );
9794 assert!(
9795 position.quantity.is_zero(),
9796 "Quantity should be zero after adjustment"
9797 );
9798 assert_eq!(
9799 position.signed_qty,
9800 dec!(0.0),
9801 "signed_qty should be normalized to 0.0"
9802 );
9803 }
9804
9805 #[rstest]
9806 fn test_position_spot_buy_partial_fills_with_base_commission() {
9807 let eth_usdt = currency_pair_ethusdt();
9810 let eth_usdt = InstrumentAny::CurrencyPair(eth_usdt);
9811
9812 let order1 = OrderTestBuilder::new(OrderType::Market)
9813 .instrument_id(eth_usdt.id())
9814 .side(OrderSide::Buy)
9815 .quantity(Quantity::from("0.00350"))
9816 .build();
9817
9818 let fill1 = TestOrderEventStubs::filled(
9819 &order1,
9820 ð_usdt,
9821 Some(TradeId::new("1")),
9822 None,
9823 Some(Price::from("2042.69")),
9824 Some(Quantity::from("0.00350")),
9825 None,
9826 Some(Money::new(0.00001, eth_usdt.base_currency().unwrap())),
9827 None,
9828 None,
9829 );
9830
9831 let mut position = Position::new(ð_usdt, fill1.into());
9832
9833 assert_eq!(position.quantity, Quantity::from("0.00349"));
9834 assert_eq!(position.signed_qty, dec!(0.00349));
9835 assert_eq!(position.side, PositionSide::Long);
9836 assert_eq!(position.adjustments.len(), 1);
9837 assert_eq!(
9838 position.adjustments[0].quantity_change,
9839 Some(rust_decimal_macros::dec!(-0.00001))
9840 );
9841
9842 let order2 = OrderTestBuilder::new(OrderType::Market)
9843 .instrument_id(eth_usdt.id())
9844 .side(OrderSide::Buy)
9845 .quantity(Quantity::from("0.00350"))
9846 .build();
9847
9848 let fill2 = TestOrderEventStubs::filled(
9849 &order2,
9850 ð_usdt,
9851 Some(TradeId::new("2")),
9852 None,
9853 Some(Price::from("2042.69")),
9854 Some(Quantity::from("0.00350")),
9855 None,
9856 Some(Money::new(0.00001, eth_usdt.base_currency().unwrap())),
9857 None,
9858 None,
9859 );
9860
9861 position.apply(&fill2.into());
9862
9863 assert_eq!(position.quantity, Quantity::from("0.00698"));
9864 assert_eq!(position.signed_qty, dec!(0.00698));
9865 assert_eq!(position.adjustments.len(), 2);
9866
9867 let order3 = OrderTestBuilder::new(OrderType::Market)
9868 .instrument_id(eth_usdt.id())
9869 .side(OrderSide::Buy)
9870 .quantity(Quantity::from("0.00300"))
9871 .build();
9872
9873 let fill3 = TestOrderEventStubs::filled(
9874 &order3,
9875 ð_usdt,
9876 Some(TradeId::new("3")),
9877 None,
9878 Some(Price::from("2042.69")),
9879 Some(Quantity::from("0.00300")),
9880 None,
9881 Some(Money::new(0.00001, eth_usdt.base_currency().unwrap())),
9882 None,
9883 None,
9884 );
9885
9886 position.apply(&fill3.into());
9887
9888 assert_eq!(position.quantity, Quantity::from("0.00997"));
9891 assert_eq!(position.signed_qty, dec!(0.00997));
9892 assert_eq!(position.side, PositionSide::Long);
9893 assert_eq!(position.adjustments.len(), 3);
9894
9895 assert_eq!(position.buy_qty, Quantity::from("0.01000"));
9897 }
9898
9899 #[rstest]
9900 fn test_position_spot_sell_partial_fills_with_base_commission() {
9901 let btc_usdt = currency_pair_btcusdt();
9902 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
9903
9904 let order1 = OrderTestBuilder::new(OrderType::Market)
9905 .instrument_id(btc_usdt.id())
9906 .side(OrderSide::Sell)
9907 .quantity(Quantity::from("0.5"))
9908 .build();
9909
9910 let fill1 = TestOrderEventStubs::filled(
9911 &order1,
9912 &btc_usdt,
9913 Some(TradeId::new("1")),
9914 None,
9915 Some(Price::from("50000.0")),
9916 Some(Quantity::from("0.5")),
9917 None,
9918 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
9919 None,
9920 None,
9921 );
9922
9923 let mut position = Position::new(&btc_usdt, fill1.into());
9924
9925 assert!((position.signed_qty - (dec!(-0.501))).abs() < dec!(1e-9));
9927 assert_eq!(position.side, PositionSide::Short);
9928 assert_eq!(position.adjustments.len(), 1);
9929
9930 let order2 = OrderTestBuilder::new(OrderType::Market)
9931 .instrument_id(btc_usdt.id())
9932 .side(OrderSide::Sell)
9933 .quantity(Quantity::from("0.5"))
9934 .build();
9935
9936 let fill2 = TestOrderEventStubs::filled(
9937 &order2,
9938 &btc_usdt,
9939 Some(TradeId::new("2")),
9940 None,
9941 Some(Price::from("50000.0")),
9942 Some(Quantity::from("0.5")),
9943 None,
9944 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
9945 None,
9946 None,
9947 );
9948
9949 position.apply(&fill2.into());
9950
9951 assert!((position.signed_qty - (dec!(-1.002))).abs() < dec!(1e-9));
9953 assert!((position.quantity.as_f64() - 1.002).abs() < 1e-9);
9954 assert_eq!(position.adjustments.len(), 2);
9955 assert_eq!(position.sell_qty, Quantity::from("1.0"));
9956 }
9957
9958 #[rstest]
9959 fn test_position_spot_round_trip_close_flat_with_quote_commission() {
9960 let eth_usdt = currency_pair_ethusdt();
9961 let eth_usdt = InstrumentAny::CurrencyPair(eth_usdt);
9962
9963 let buy_order = OrderTestBuilder::new(OrderType::Market)
9964 .instrument_id(eth_usdt.id())
9965 .side(OrderSide::Buy)
9966 .quantity(Quantity::from("1.00000"))
9967 .build();
9968
9969 let buy_fill = TestOrderEventStubs::filled(
9970 &buy_order,
9971 ð_usdt,
9972 Some(TradeId::new("1")),
9973 None,
9974 Some(Price::from("2000.00")),
9975 Some(Quantity::from("1.00000")),
9976 None,
9977 Some(Money::new(0.001, eth_usdt.base_currency().unwrap())),
9978 None,
9979 None,
9980 );
9981
9982 let mut position = Position::new(ð_usdt, buy_fill.into());
9983
9984 assert_eq!(position.quantity, Quantity::from("0.99900"));
9986 assert_eq!(position.side, PositionSide::Long);
9987
9988 let sell_order = OrderTestBuilder::new(OrderType::Market)
9989 .instrument_id(eth_usdt.id())
9990 .side(OrderSide::Sell)
9991 .quantity(Quantity::from("0.99900"))
9992 .build();
9993
9994 let sell_fill = TestOrderEventStubs::filled(
9995 &sell_order,
9996 ð_usdt,
9997 Some(TradeId::new("2")),
9998 None,
9999 Some(Price::from("2100.00")),
10000 Some(Quantity::from("0.99900")),
10001 None,
10002 Some(Money::new(2.0, Currency::USDT())),
10003 None,
10004 None,
10005 );
10006
10007 position.apply(&sell_fill.into());
10008
10009 assert_eq!(position.side, PositionSide::Flat);
10010 assert_eq!(position.signed_qty, dec!(0.0));
10011 assert!(position.is_closed());
10012 assert_eq!(position.adjustments.len(), 1);
10014
10015 let realized = position.realized_pnl.unwrap().as_f64();
10017 assert!(
10018 (realized - 97.9).abs() < 0.01,
10019 "Realized PnL should be ~97.90 USDT, was {realized}"
10020 );
10021 }
10022
10023 #[rstest]
10024 fn test_position_spot_commission_accumulation_multiple_partial_fills() {
10025 let eth_usdt = currency_pair_ethusdt();
10026 let eth_usdt = InstrumentAny::CurrencyPair(eth_usdt);
10027
10028 let order1 = OrderTestBuilder::new(OrderType::Market)
10029 .instrument_id(eth_usdt.id())
10030 .side(OrderSide::Buy)
10031 .quantity(Quantity::from("0.50000"))
10032 .build();
10033
10034 let fill1 = TestOrderEventStubs::filled(
10035 &order1,
10036 ð_usdt,
10037 Some(TradeId::new("1")),
10038 None,
10039 Some(Price::from("2000.00")),
10040 Some(Quantity::from("0.50000")),
10041 None,
10042 Some(Money::new(0.0005, eth_usdt.base_currency().unwrap())),
10043 None,
10044 None,
10045 );
10046
10047 let mut position = Position::new(ð_usdt, fill1.into());
10048
10049 let order2 = OrderTestBuilder::new(OrderType::Market)
10050 .instrument_id(eth_usdt.id())
10051 .side(OrderSide::Buy)
10052 .quantity(Quantity::from("0.50000"))
10053 .build();
10054
10055 let fill2 = TestOrderEventStubs::filled(
10056 &order2,
10057 ð_usdt,
10058 Some(TradeId::new("2")),
10059 None,
10060 Some(Price::from("2010.00")),
10061 Some(Quantity::from("0.50000")),
10062 None,
10063 Some(Money::new(0.0005, eth_usdt.base_currency().unwrap())),
10064 None,
10065 None,
10066 );
10067
10068 position.apply(&fill2.into());
10069
10070 assert_eq!(position.quantity, Quantity::from("0.99900"));
10072 assert_eq!(position.buy_qty, Quantity::from("1.00000"));
10073
10074 assert_eq!(position.adjustments.len(), 2);
10075 for adj in &position.adjustments {
10076 assert_eq!(adj.adjustment_type, PositionAdjustmentType::Commission);
10077 assert_eq!(
10078 adj.quantity_change,
10079 Some(rust_decimal_macros::dec!(-0.0005))
10080 );
10081 }
10082
10083 let commissions = position.commissions();
10084 assert_eq!(commissions.len(), 1);
10085 let eth_commission = commissions[0];
10086 assert!(
10087 (eth_commission.as_f64() - 0.001).abs() < 1e-9,
10088 "Total ETH commission should be 0.001, was {}",
10089 eth_commission.as_f64()
10090 );
10091 }
10092
10093 #[rstest]
10094 fn test_position_apply_fill_with_earlier_timestamp_adjusts_ts_opened(audusd_sim: CurrencyPair) {
10095 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
10096 let order1 = OrderTestBuilder::new(OrderType::Market)
10097 .instrument_id(audusd_sim.id())
10098 .side(OrderSide::Buy)
10099 .quantity(Quantity::from(100_000))
10100 .build();
10101 let order2 = OrderTestBuilder::new(OrderType::Market)
10102 .instrument_id(audusd_sim.id())
10103 .side(OrderSide::Buy)
10104 .quantity(Quantity::from(100_000))
10105 .build();
10106
10107 let fill1 = TestOrderEventStubs::filled(
10109 &order1,
10110 &audusd_sim,
10111 Some(TradeId::new("t1")),
10112 None,
10113 Some(Price::from("1.00001")),
10114 None,
10115 None,
10116 None,
10117 Some(UnixNanos::from(2_000u64)),
10118 None,
10119 );
10120 let mut position = Position::new(&audusd_sim, fill1.into());
10121 assert_eq!(position.ts_opened, UnixNanos::from(2_000u64));
10122
10123 let fill2 = TestOrderEventStubs::filled(
10125 &order2,
10126 &audusd_sim,
10127 Some(TradeId::new("t2")),
10128 None,
10129 Some(Price::from("1.00002")),
10130 None,
10131 None,
10132 None,
10133 Some(UnixNanos::from(1_000u64)),
10134 None,
10135 );
10136
10137 position.apply(&fill2.into());
10139 assert_eq!(position.ts_opened, UnixNanos::from(2_000u64));
10140 assert_eq!(position.opening_order_id, order1.client_order_id());
10141 assert_eq!(position.events.len(), 2);
10142 }
10143
10144 #[rstest]
10145 fn test_position_close_before_open_clamps_duration(audusd_sim: CurrencyPair) {
10146 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
10147 let opening_order = OrderTestBuilder::new(OrderType::Market)
10148 .instrument_id(audusd_sim.id())
10149 .side(OrderSide::Buy)
10150 .quantity(Quantity::from(100_000))
10151 .build();
10152 let closing_order = OrderTestBuilder::new(OrderType::Market)
10153 .instrument_id(audusd_sim.id())
10154 .side(OrderSide::Sell)
10155 .quantity(Quantity::from(100_000))
10156 .build();
10157 let opening_fill = TestOrderEventStubs::filled(
10158 &opening_order,
10159 &audusd_sim,
10160 Some(TradeId::new("OPEN")),
10161 None,
10162 Some(Price::from("1.00001")),
10163 None,
10164 None,
10165 None,
10166 Some(UnixNanos::from(2_000u64)),
10167 None,
10168 );
10169 let closing_fill = TestOrderEventStubs::filled(
10170 &closing_order,
10171 &audusd_sim,
10172 Some(TradeId::new("CLOSE")),
10173 None,
10174 Some(Price::from("1.00002")),
10175 None,
10176 None,
10177 None,
10178 Some(UnixNanos::from(1_000u64)),
10179 None,
10180 );
10181 let mut position = Position::new(&audusd_sim, opening_fill.into());
10182
10183 position.apply(&closing_fill.into());
10184
10185 assert_eq!(position.side, PositionSide::Flat);
10186 assert_eq!(position.ts_opened, UnixNanos::from(2_000u64));
10187 assert_eq!(position.ts_closed, Some(UnixNanos::from(1_000u64)));
10188 assert_eq!(position.duration_ns, DurationNanos::default());
10189 assert_eq!(
10190 position.closing_order_id,
10191 Some(closing_order.client_order_id())
10192 );
10193 }
10194
10195 #[rstest]
10196 fn test_position_commissions_multi_currency_insertion_order(audusd_sim: CurrencyPair) {
10197 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
10202 let order_template = OrderTestBuilder::new(OrderType::Market)
10203 .instrument_id(audusd_sim.id())
10204 .side(OrderSide::Buy)
10205 .quantity(Quantity::from(100_000))
10206 .build();
10207
10208 let fill_usd = TestOrderEventStubs::filled(
10209 &order_template,
10210 &audusd_sim,
10211 Some(TradeId::new("t1")),
10212 None,
10213 Some(Price::from("1.00001")),
10214 None,
10215 None,
10216 Some(Money::from("1.0 USD")),
10217 None,
10218 None,
10219 );
10220 let mut position = Position::new(&audusd_sim, fill_usd.into());
10221
10222 let fill_usdt = TestOrderEventStubs::filled(
10223 &order_template,
10224 &audusd_sim,
10225 Some(TradeId::new("t2")),
10226 None,
10227 Some(Price::from("1.00001")),
10228 None,
10229 None,
10230 Some(Money::from("2.0 USDT")),
10231 None,
10232 None,
10233 );
10234 position.apply(&fill_usdt.into());
10235
10236 let fill_usd_again = TestOrderEventStubs::filled(
10237 &order_template,
10238 &audusd_sim,
10239 Some(TradeId::new("t3")),
10240 None,
10241 Some(Price::from("1.00001")),
10242 None,
10243 None,
10244 Some(Money::from("0.5 USD")),
10245 None,
10246 None,
10247 );
10248 position.apply(&fill_usd_again.into());
10249
10250 let fill_btc = TestOrderEventStubs::filled(
10251 &order_template,
10252 &audusd_sim,
10253 Some(TradeId::new("t4")),
10254 None,
10255 Some(Price::from("1.00001")),
10256 None,
10257 None,
10258 Some(Money::from("0.0001 BTC")),
10259 None,
10260 None,
10261 );
10262 position.apply(&fill_btc.into());
10263
10264 assert_eq!(
10267 position.commissions(),
10268 vec![
10269 Money::from("1.5 USD"),
10270 Money::from("2.0 USDT"),
10271 Money::from("0.0001 BTC"),
10272 ]
10273 );
10274 }
10275
10276 #[rstest]
10277 fn test_fold_net_position_empty() {
10278 let (net_qty, net_px) = fold_net_position(&[]);
10279 assert_eq!(net_qty, Decimal::ZERO);
10280 assert_eq!(net_px, Decimal::ZERO);
10281 }
10282
10283 #[rstest]
10284 fn test_fold_net_position_single_long() {
10285 let legs = [(dec!(100), dec!(1.5), 1u64)];
10286 let (net_qty, net_px) = fold_net_position(&legs);
10287 assert_eq!(net_qty, dec!(100));
10288 assert_eq!(net_px, dec!(1.5));
10289 }
10290
10291 #[rstest]
10292 fn test_fold_net_position_single_short() {
10293 let legs = [(dec!(-100), dec!(1.5), 1u64)];
10294 let (net_qty, net_px) = fold_net_position(&legs);
10295 assert_eq!(net_qty, dec!(-100));
10296 assert_eq!(net_px, dec!(1.5));
10297 }
10298
10299 #[rstest]
10300 fn test_fold_net_position_same_side_weighted_average() {
10301 let legs = [(dec!(100), dec!(1.0), 1u64), (dec!(200), dec!(0.5), 2u64)];
10303 let (net_qty, net_px) = fold_net_position(&legs);
10304 assert_eq!(net_qty, dec!(300));
10305 assert_eq!(net_px, dec!(200) / dec!(300));
10307 }
10308
10309 #[rstest]
10310 fn test_fold_net_position_partial_close_preserves_avg() {
10311 let legs = [
10313 (dec!(300), dec!(0.80), 1u64),
10314 (dec!(-100), dec!(1.00), 2u64),
10315 ];
10316 let (net_qty, net_px) = fold_net_position(&legs);
10317 assert_eq!(net_qty, dec!(200));
10318 assert_eq!(net_px, dec!(0.80));
10319 }
10320
10321 #[rstest]
10322 fn test_fold_net_position_full_close() {
10323 let legs = [(dec!(100), dec!(1.0), 1u64), (dec!(-100), dec!(2.0), 2u64)];
10324 let (net_qty, net_px) = fold_net_position(&legs);
10325 assert_eq!(net_qty, Decimal::ZERO);
10326 assert_eq!(net_px, Decimal::ZERO);
10327 }
10328
10329 #[rstest]
10330 fn test_fold_net_position_single_flip_uses_flipping_price() {
10331 let legs = [
10333 (dec!(100), dec!(1.00), 1u64),
10334 (dec!(-50), dec!(2.00), 2u64),
10335 (dec!(-100), dec!(3.00), 3u64),
10336 ];
10337 let (net_qty, net_px) = fold_net_position(&legs);
10338 assert_eq!(net_qty, dec!(-50));
10339 assert_eq!(net_px, dec!(3.00));
10340 }
10341
10342 #[rstest]
10343 fn test_fold_net_position_double_flip() {
10344 let legs = [
10346 (dec!(50), dec!(1.00), 1u64),
10347 (dec!(-100), dec!(2.00), 2u64),
10348 (dec!(100), dec!(3.00), 3u64),
10349 ];
10350 let (net_qty, net_px) = fold_net_position(&legs);
10351 assert_eq!(net_qty, dec!(50));
10352 assert_eq!(net_px, dec!(3.00));
10353 }
10354
10355 #[rstest]
10356 fn test_fold_net_position_zero_quantity_legs_skipped() {
10357 let legs = [
10359 (dec!(100), dec!(1.0), 1u64),
10360 (Decimal::ZERO, dec!(99.0), 2u64),
10361 (dec!(50), dec!(2.0), 3u64),
10362 ];
10363 let (net_qty, net_px) = fold_net_position(&legs);
10364 assert_eq!(net_qty, dec!(150));
10365 assert_eq!(net_px, dec!(200) / dec!(150));
10367 }
10368
10369 #[rstest]
10370 fn test_fold_net_position_stable_sort_preserves_input_order_for_equal_ts() {
10371 let leg_a = (dec!(100), dec!(1.00), 1u64);
10373 let leg_b = (dec!(-100), dec!(2.00), 1u64);
10374
10375 let ab = [leg_a, leg_b];
10376 let ba = [leg_b, leg_a];
10377
10378 assert_eq!(fold_net_position(&ab), (Decimal::ZERO, Decimal::ZERO));
10380 assert_eq!(fold_net_position(&ba), (Decimal::ZERO, Decimal::ZERO));
10382
10383 let leg_c = (dec!(150), dec!(1.00), 1u64);
10385 let leg_d = (dec!(-100), dec!(2.00), 1u64);
10386 let cd = [leg_c, leg_d];
10387 let dc = [leg_d, leg_c];
10388 assert_eq!(fold_net_position(&cd), (dec!(50), dec!(1.00)));
10390 assert_eq!(fold_net_position(&dc), (dec!(50), dec!(1.00)));
10392 }
10393
10394 #[rstest]
10395 fn test_fold_net_position_close_then_reopen() {
10396 let legs = [
10398 (dec!(100), dec!(1.00), 1u64),
10399 (dec!(-100), dec!(1.50), 2u64),
10400 (dec!(50), dec!(3.00), 3u64),
10401 ];
10402 let (net_qty, net_px) = fold_net_position(&legs);
10403 assert_eq!(net_qty, dec!(50));
10404 assert_eq!(net_px, dec!(3.00));
10405 }
10406
10407 #[rstest]
10408 fn test_fold_net_position_orders_by_ts_opened() {
10409 let in_order = [
10411 (dec!(100), dec!(1.00), 1u64),
10412 (dec!(-50), dec!(2.00), 2u64),
10413 (dec!(-100), dec!(3.00), 3u64),
10414 ];
10415 let shuffled = [
10416 (dec!(-100), dec!(3.00), 3u64),
10417 (dec!(100), dec!(1.00), 1u64),
10418 (dec!(-50), dec!(2.00), 2u64),
10419 ];
10420 assert_eq!(fold_net_position(&in_order), fold_net_position(&shuffled));
10421 }
10422
10423 fn netting_reference(
10426 instrument: &InstrumentAny,
10427 fills: &[(OrderSide, u32, u32, u64)],
10428 ) -> (Decimal, Decimal) {
10429 let mut sorted_fills = fills.to_vec();
10430 sorted_fills.sort_by_key(|(_, _, _, ts)| *ts);
10431
10432 let mut position: Option<Position> = None;
10433
10434 for (idx, &(side, qty, px, ts)) in sorted_fills.iter().enumerate() {
10435 let order = OrderTestBuilder::new(OrderType::Market)
10436 .instrument_id(instrument.id())
10437 .side(side)
10438 .quantity(Quantity::from(qty))
10439 .build();
10440 let fill = TestOrderEventStubs::filled(
10441 &order,
10442 instrument,
10443 Some(TradeId::new(format!("T{idx}").as_str())),
10444 Some(PositionId::new("P-NET")),
10445 Some(Price::from(px.to_string().as_str())),
10446 None,
10447 None,
10448 Some(Money::new(0.0, instrument.quote_currency())),
10449 Some(UnixNanos::from(ts)),
10450 None,
10451 );
10452 let event: OrderFilled = fill.into();
10453 if let Some(p) = position.as_mut() {
10454 p.apply(&event);
10455 } else {
10456 position = Some(Position::new(instrument, event));
10457 }
10458 }
10459 let p = position.expect("at least one fill");
10460 (p.signed_qty, p.avg_px_open)
10461 }
10462
10463 fn hedging_legs(fills: &[(OrderSide, u32, u32, u64)]) -> Vec<(Decimal, Decimal, u64)> {
10465 fills
10466 .iter()
10467 .map(|&(side, qty, px, ts)| {
10468 let signed = if side == OrderSide::Buy {
10469 Decimal::from(qty)
10470 } else {
10471 -Decimal::from(qty)
10472 };
10473 (signed, Decimal::from(px), ts)
10474 })
10475 .collect()
10476 }
10477
10478 proptest! {
10479 #[rstest]
10485 fn prop_fold_matches_netting_replay(
10486 fills in proptest::collection::vec(
10487 (
10488 prop_oneof![Just(OrderSide::Buy), Just(OrderSide::Sell)],
10489 1u32..1_000u32,
10490 1u32..100u32,
10491 0u64..1_000_000u64,
10492 ),
10493 1..6,
10494 )
10495 ) {
10496 let mut seen_ts: AHashSet<u64> = AHashSet::new();
10499 for &(_, _, _, ts) in &fills {
10500 if !seen_ts.insert(ts) {
10501 prop_assume!(false);
10502 }
10503 }
10504
10505 let mut sorted_fills = fills.clone();
10509 sorted_fills.sort_by_key(|(_, _, _, ts)| *ts);
10510 let mut running: i64 = 0;
10511 let mut zero_mid = false;
10512
10513 for (idx, &(side, qty, _, _)) in sorted_fills.iter().enumerate() {
10514 let qty_i64 = i64::from(qty);
10515 let signed: i64 = if side == OrderSide::Buy {
10516 qty_i64
10517 } else {
10518 -qty_i64
10519 };
10520 running += signed;
10521 if idx + 1 < sorted_fills.len() && running == 0 {
10522 zero_mid = true;
10523 break;
10524 }
10525 }
10526 prop_assume!(!zero_mid);
10527
10528 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
10529 let (ref_qty, ref_px) = netting_reference(&instrument, &fills);
10530 let legs = hedging_legs(&fills);
10531 let (fold_qty, fold_px) = fold_net_position(&legs);
10532
10533 prop_assert_eq!(fold_qty, ref_qty);
10534
10535 if !ref_qty.is_zero() {
10539 let fold_px_f64 = fold_px.to_f64().unwrap_or(0.0);
10540 let ref_px_f64 = ref_px.to_f64().unwrap_or(0.0);
10541 let max_mag = fold_px_f64.abs().max(ref_px_f64.abs()).max(1.0);
10542 prop_assert!(
10543 (fold_px_f64 - ref_px_f64).abs() < 1e-9 * max_mag,
10544 "fold_px {fold_px_f64} vs ref_px {ref_px_f64}",
10545 );
10546 }
10547 }
10548 }
10549
10550 fn matching_fill_void(
10551 fill: &OrderFilled,
10552 voided_qty: Quantity,
10553 commission_voided: Option<Money>,
10554 ) -> OrderFillVoided {
10555 OrderFillVoidedSpec::builder()
10556 .trader_id(fill.trader_id)
10557 .strategy_id(fill.strategy_id)
10558 .instrument_id(fill.instrument_id)
10559 .client_order_id(fill.client_order_id)
10560 .venue_order_id(fill.venue_order_id)
10561 .account_id(fill.account_id)
10562 .trade_id(fill.trade_id)
10563 .voided_qty(voided_qty)
10564 .order_side(fill.order_side)
10565 .order_type(fill.order_type)
10566 .last_px(fill.last_px)
10567 .currency(fill.currency)
10568 .liquidity_side(fill.liquidity_side)
10569 .maybe_position_id(fill.position_id)
10570 .maybe_commission_voided(commission_voided)
10571 .build()
10572 }
10573
10574 #[rstest]
10575 #[case(0, 56, Some((0, 0)))]
10576 #[case(123_450_000, 7, Some((12345, 3)))]
10577 #[case(-123_450_000, 7, Some((-12345, 3)))]
10578 #[case(Decimal::MAX.mantissa() * 10, 1, Some((Decimal::MAX.mantissa(), 0)))]
10579 #[case(Decimal::MAX.mantissa() * 10 + 1, 1, None)]
10580 #[case(10, 29, Some((1, 28)))]
10581 #[case(1, 29, None)]
10582 fn test_raw_conversion_preserves_exact_bounds(
10583 #[case] raw: i128,
10584 #[case] scale: u32,
10585 #[case] expected: Option<(i128, u32)>,
10586 ) {
10587 let actual = from_raw(raw, scale);
10588
10589 match expected {
10590 Some(expected) => {
10591 let actual = actual.unwrap();
10592 assert_eq!((actual.mantissa(), actual.scale()), expected);
10593 }
10594 None => assert!(actual.is_err()),
10595 }
10596 }
10597
10598 proptest! {
10599 #[rstest]
10600 fn prop_raw_conversion_matches_exact_normalization(
10601 mut raw in prop_oneof![any::<i128>(), -Decimal::MAX.mantissa()..=Decimal::MAX.mantissa()],
10602 mut scale in 0_u32..=56,
10603 ) {
10604 let actual = from_raw(raw, scale);
10605 while scale > 0 && raw % 10 == 0 {
10606 raw /= 10;
10607 scale -= 1;
10608 }
10609 let expected = Decimal::try_from_i128_with_scale(raw, scale);
10610
10611 match expected {
10612 Ok(expected) => {
10613 let actual = actual.unwrap();
10614 prop_assert_eq!((actual.mantissa(), actual.scale()), (expected.mantissa(), expected.scale()));
10615 }
10616 Err(_) => prop_assert!(actual.is_err()),
10617 }
10618 }
10619
10620 #[rstest]
10621 fn prop_domain_fee_split_matches_exact_ratio(
10622 raw in -MoneyRaw::from(1_000_000_i32)..=MoneyRaw::from(1_000_000_i32),
10623 closing in QuantityRaw::from(1_u32)..=QuantityRaw::from(1_000_000_000_u32),
10624 remainder in QuantityRaw::from(1_u32)..=QuantityRaw::from(1_000_000_000_u32),
10625 ) {
10626 let fee = Money::from_raw(raw, Currency::USD());
10627 let closing = Quantity::from_raw(closing, FIXED_PRECISION);
10628 let total = Quantity::from_raw(closing.raw + remainder, FIXED_PRECISION);
10629 let expected = split_commission(fee, qty(closing).unwrap(), qty(total).unwrap()).unwrap();
10630 let actual = split_commission_qty(fee, closing, total).unwrap();
10631
10632 prop_assert_eq!(actual, expected);
10633 prop_assert_eq!(actual.0.checked_add(actual.1), Some(fee));
10634 }
10635 }
10636
10637 #[cfg(feature = "high-precision")]
10638 #[rstest]
10639 fn test_domain_subtraction_rejects_unsupported_input_before_cancellation() {
10640 let raw = Decimal::MAX.mantissa() as u128 + 1;
10641 let value = Quantity::from_raw(raw, FIXED_PRECISION);
10642
10643 assert!(quantity_sub(value, value, FIXED_PRECISION).is_err());
10644 assert!(money_from_qty(value, Currency::USD()).is_err());
10645 }
10646
10647 #[cfg(feature = "defi")]
10648 #[rstest]
10649 fn test_domain_operations_preserve_mixed_raw_scales() {
10650 let lhs = Quantity::from("1.000000000000000001");
10651 let rhs = Quantity::from("0.5000000000000000");
10652 let sum = quantity_add(lhs, rhs, 16).unwrap();
10653 let difference = quantity_sub(lhs, rhs, 16).unwrap();
10654 let fee = Money::from_raw(-7, Currency::USD());
10655 let split = split_commission_qty(fee, rhs, lhs).unwrap();
10656 let money = money_from_qty(lhs, Currency::USD()).unwrap();
10657
10658 assert_eq!((sum.raw, sum.precision), (1_500_000_000_000_000_001, 18));
10659 assert_eq!(
10660 (difference.raw, difference.precision),
10661 (500_000_000_000_000_001, 18)
10662 );
10663 assert_eq!(
10664 split,
10665 (
10666 Money::from_raw(-3, Currency::USD()),
10667 Money::from_raw(-4, Currency::USD())
10668 )
10669 );
10670 assert_eq!(money, Money::from("1 USD"));
10671 }
10672
10673 #[rstest]
10674 #[case("1.2300", "0.230", 2, "1.46", "1.00")]
10675 #[case("1.23", "0.004", 2, "1.234", "1.226")]
10676 #[case("2", "2", 3, "4.000", "0.000")]
10677 fn test_quantity_operations_preserve_value_and_precision(
10678 #[case] lhs: &str,
10679 #[case] rhs: &str,
10680 #[case] precision: u8,
10681 #[case] sum: &str,
10682 #[case] difference: &str,
10683 ) {
10684 let lhs = Quantity::from(lhs);
10685 let rhs = Quantity::from(rhs);
10686 let sum = Quantity::from(sum);
10687 let difference = Quantity::from(difference);
10688 let actual_sum = quantity_add(lhs, rhs, precision).unwrap();
10689 let actual_difference = quantity_sub(lhs, rhs, precision).unwrap();
10690
10691 assert_eq!(
10692 (actual_sum.raw, actual_sum.precision),
10693 (sum.raw, sum.precision)
10694 );
10695 assert_eq!(
10696 (actual_difference.raw, actual_difference.precision),
10697 (difference.raw, difference.precision),
10698 );
10699 }
10700
10701 #[rstest]
10702 fn test_domain_operations_preserve_fractional_raw_units() {
10703 let value = Quantity::from_raw(1, 2);
10704 let zero = Quantity::zero(2);
10705 let sum = quantity_add(value, zero, 2).unwrap();
10706 let difference = quantity_sub(value, zero, 2).unwrap();
10707 let amount = money_from_qty(value, Currency::USD()).unwrap();
10708
10709 assert_eq!((sum.raw, sum.precision), (1, FIXED_PRECISION));
10710 assert_eq!((difference.raw, difference.precision), (1, FIXED_PRECISION));
10711 assert_eq!(amount, Money::from_raw(1, Currency::USD()));
10712 }
10713
10714 #[rstest]
10715 #[case(5, 1, 4)]
10716 #[case(-5, -1, -4)]
10717 #[case(0, 0, 0)]
10718 fn test_domain_fee_split_conserves_raw_units(
10719 #[case] raw: MoneyRaw,
10720 #[case] closing: MoneyRaw,
10721 #[case] opening: MoneyRaw,
10722 ) {
10723 let fee = Money::from_raw(raw, Currency::USD());
10724 let actual = split_commission_qty(fee, Quantity::from("1"), Quantity::from("3")).unwrap();
10725
10726 assert_eq!(
10727 actual,
10728 (
10729 Money::from_raw(closing, Currency::USD()),
10730 Money::from_raw(opening, Currency::USD()),
10731 ),
10732 );
10733 assert_eq!(actual.0.checked_add(actual.1), Some(fee));
10734 }
10735
10736 #[rstest]
10737 fn test_domain_fee_split_preserves_ratio_after_intermediate_overflow() {
10738 let fee = Money::from("1000000 USD");
10739 let closing_qty = Quantity::from("1000000");
10740 let total_qty = Quantity::from("4000000");
10741 let actual = split_commission_qty(fee, closing_qty, total_qty).unwrap();
10742
10743 assert_eq!(
10744 actual,
10745 (Money::from("250000 USD"), Money::from("750000 USD"))
10746 );
10747 assert_eq!(actual.0.checked_add(actual.1), Some(fee));
10748 }
10749
10750 #[rstest]
10751 fn test_quantity_operations_reject_overflow_and_underflow() {
10752 let max = Quantity::from_raw(crate::types::quantity::QUANTITY_RAW_MAX, FIXED_PRECISION);
10753 let unit = Quantity::from_raw(1, FIXED_PRECISION);
10754
10755 assert!(quantity_add(max, unit, FIXED_PRECISION).is_err());
10756 assert!(quantity_sub(unit, max, FIXED_PRECISION).is_err());
10757 }
10758
10759 #[rstest]
10760 #[case(false)]
10761 #[case(true)]
10762 fn test_fee_allocation_uses_exact_integer_ratio(#[case] rebate: bool) {
10763 let fee =
10764 Money::from_decimal_exact(Decimal::new(1, u32::from(FIXED_PRECISION)), Currency::USD())
10765 .unwrap();
10766
10767 let fee = if rebate { -fee } else { fee };
10768 let (closing, opening) =
10769 split_commission(fee, Decimal::MAX - Decimal::ONE, Decimal::MAX).unwrap();
10770 assert_eq!(closing, Money::zero(Currency::USD()));
10771 assert_eq!(opening, fee);
10772 }
10773
10774 #[rstest]
10775 #[case(5, 0)]
10776 #[case(15, 2)]
10777 #[case(25, 2)]
10778 #[case(-5, 0)]
10779 #[case(-15, -2)]
10780 #[case(-25, -2)]
10781 fn test_money_rounds_native_midpoints_to_even(#[case] coefficient: i64, #[case] expected: i64) {
10782 let amount = Decimal::new(coefficient, u32::from(FIXED_PRECISION) + 1);
10783 let expected = Decimal::new(expected, u32::from(FIXED_PRECISION));
10784
10785 assert_eq!(
10786 money(amount, Currency::USD()).unwrap().as_decimal(),
10787 expected
10788 );
10789 }
10790
10791 #[rstest]
10792 fn test_proportion_preserves_exact_cancellation() {
10793 let allocated = proportion(dec!(11382), Decimal::ONE, dec!(271)).unwrap();
10794 assert_eq!(allocated, dec!(42));
10795 assert_eq!(sub(dec!(11382), allocated).unwrap(), dec!(11340));
10796 }
10797
10798 #[rstest]
10799 fn test_rounded_arithmetic_rejects_underflow() {
10800 assert!(div(dec!(0.0000000000000000000000000001), dec!(10)).is_err());
10801 assert!(mul_rounded(dec!(0.0000000000000001), dec!(0.0000000000000001)).is_err());
10802 assert!(add_rounded(Decimal::MAX, dec!(0.1)).is_err());
10803 assert!(add_rounded(dec!(0.1), Decimal::MAX).is_err());
10804 }
10805
10806 #[rstest]
10807 fn test_pnl_sum_bounds_lost_residual(
10808 #[values(false, true)] reverse: bool,
10809 #[values(Decimal::ONE, Decimal::NEGATIVE_ONE)] sign: Decimal,
10810 ) {
10811 let currency = Currency::USD();
10812 let unit = amount(Money::from_raw(1, currency)).unwrap() * sign;
10813 let residual = unit / dec!(10);
10814 let (lhs, rhs) = if reverse {
10815 (unit, Decimal::MAX)
10816 } else {
10817 (Decimal::MAX, unit)
10818 };
10819
10820 let (lhs_residual, rhs_residual) = if reverse {
10821 (residual, Decimal::MAX)
10822 } else {
10823 (Decimal::MAX, residual)
10824 };
10825
10826 assert!(add_pnl(lhs, rhs, currency).is_err());
10827 assert_eq!(
10828 add_pnl(lhs_residual, rhs_residual, currency).unwrap(),
10829 Decimal::MAX
10830 );
10831 assert_eq!(
10832 add_pnl(Decimal::ONE, residual, currency).unwrap(),
10833 Decimal::ONE + residual
10834 );
10835 }
10836
10837 #[rstest]
10838 fn test_checked_product_rejects_silent_underflow() {
10839 assert!(mul(dec!(0.0000000000000001), dec!(0.0000000000000001)).is_err());
10840 assert_eq!(mul(dec!(1.2345), dec!(0.01)).unwrap(), dec!(0.012345));
10841 }
10842
10843 #[rstest]
10844 fn test_checked_sum_rejects_lost_increment() {
10845 assert!(add(Decimal::MAX, dec!(0.1)).is_err());
10846 assert_eq!(sub(dec!(1.000001), Decimal::ONE).unwrap(), dec!(0.000001));
10847 assert_eq!(
10848 money(dec!(0.000001), Currency::USD()).unwrap().as_decimal(),
10849 dec!(0.000001)
10850 );
10851 assert_eq!(
10852 add(dec!(1000000000), dec!(0.000001)).unwrap(),
10853 dec!(1000000000.000001)
10854 );
10855 }
10856
10857 #[rstest]
10858 #[case(dec!(0.000002), dec!(0.5), dec!(0.000001), dec!(0.000001))]
10859 #[case(dec!(0.006000), dec!(0.9), dec!(0.005400), dec!(0.000600))]
10860 #[case(dec!(-0.006000), dec!(0.9), dec!(-0.005400), dec!(-0.000600))]
10861 fn test_fee_allocation(
10862 #[case] fee: Decimal,
10863 #[case] closing_qty: Decimal,
10864 #[case] closing: Decimal,
10865 #[case] opening: Decimal,
10866 ) {
10867 let fee = Money::from_decimal_exact(fee, Currency::USD()).unwrap();
10868 let (actual_closing, actual_opening) =
10869 split_commission(fee, closing_qty, Decimal::ONE).unwrap();
10870 assert_eq!(actual_closing.as_decimal(), closing);
10871 assert_eq!(actual_opening.as_decimal(), opening);
10872 assert_eq!(actual_closing.checked_add(actual_opening), Some(fee));
10873 }
10874}