1use std::{
22 fmt::Display,
23 hash::{Hash, Hasher},
24};
25
26use ahash::{AHashMap, AHashSet};
27use indexmap::IndexMap;
28use nautilus_core::{
29 DurationNanos, UUID4, UnixNanos,
30 correctness::{
31 CorrectnessError, CorrectnessResult, CorrectnessResultExt, FAILED, check_equal,
32 check_predicate_true,
33 },
34};
35use rust_decimal::{Decimal, prelude::ToPrimitive};
36use serde::{Deserialize, Serialize};
37
38use crate::{
39 data::InstrumentClose,
40 enums::{
41 InstrumentClass, InstrumentCloseType, OrderSide, PositionAdjustmentType, PositionSide,
42 },
43 events::{OrderFillVoided, OrderFilled, PositionAdjusted},
44 identifiers::{
45 AccountId, ClientOrderId, InstrumentId, PositionId, StrategyId, Symbol, TradeId, TraderId,
46 Venue, VenueOrderId,
47 },
48 instruments::{Instrument, InstrumentAny},
49 types::{Currency, Money, Price, Quantity, fixed::check_float_precision},
50};
51
52#[repr(C)]
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[cfg_attr(
61 feature = "python",
62 pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
63)]
64#[cfg_attr(
65 feature = "python",
66 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
67)]
68pub struct Position {
69 pub events: Vec<OrderFilled>,
70 pub adjustments: Vec<PositionAdjusted>,
71 #[serde(default)]
72 pub replay_events: Vec<PositionReplayEvent>,
73 #[serde(default)]
74 pub fill_voids: Vec<PositionFillVoid>,
75 pub trader_id: TraderId,
76 pub strategy_id: StrategyId,
77 pub instrument_id: InstrumentId,
78 pub id: PositionId,
79 pub account_id: AccountId,
80 pub opening_order_id: ClientOrderId,
81 pub closing_order_id: Option<ClientOrderId>,
82 pub entry: OrderSide,
83 pub side: PositionSide,
84 pub signed_qty: f64,
85 pub quantity: Quantity,
86 pub peak_qty: Quantity,
87 pub price_precision: u8,
88 pub size_precision: u8,
89 pub multiplier: Quantity,
90 pub is_inverse: bool,
91 pub is_currency_pair: bool,
92 pub instrument_class: InstrumentClass,
93 pub base_currency: Option<Currency>,
94 pub quote_currency: Currency,
95 pub settlement_currency: Currency,
96 pub ts_init: UnixNanos,
97 pub ts_opened: UnixNanos,
98 pub ts_last: UnixNanos,
99 pub ts_closed: Option<UnixNanos>,
100 pub duration_ns: DurationNanos,
101 pub avg_px_open: f64,
102 pub avg_px_close: Option<f64>,
103 pub realized_return: f64,
104 pub realized_pnl: Option<Money>,
105 #[serde(with = "nautilus_core::serialization::sorted_hashset")]
106 pub trade_ids: AHashSet<TradeId>,
107 pub buy_qty: Quantity,
109 pub sell_qty: Quantity,
111 pub commissions: IndexMap<Currency, Money>,
112}
113
114#[expect(clippy::large_enum_variant)]
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub enum PositionReplayEvent {
117 Filled(OrderFilled),
118 Adjusted(PositionAdjusted),
119 InstrumentClosed(InstrumentClose),
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct PositionFillVoid {
124 pub event: OrderFillVoided,
125 pub voided_qty: Quantity,
126 pub commission_voided: Option<Money>,
127}
128
129impl Position {
130 #[must_use]
136 #[allow(
137 clippy::needless_pass_by_value,
138 reason = "constructor takes the opening fill by value as the position's seed event"
139 )]
140 pub fn new(instrument: &InstrumentAny, fill: OrderFilled) -> Self {
141 Self::new_checked(instrument, fill).expect_display(FAILED)
142 }
143
144 #[allow(
153 clippy::needless_pass_by_value,
154 reason = "constructor takes the opening fill by value as the position's seed event"
155 )]
156 pub fn new_checked(instrument: &InstrumentAny, fill: OrderFilled) -> CorrectnessResult<Self> {
157 Self::check_fill_instrument(instrument.id(), "instrument.id()", &fill)?;
158 let position_id = Self::fill_position_id(&fill)?;
159 check_float_precision(fill.last_px.precision)?;
160
161 let mut item = Self {
162 events: Vec::<OrderFilled>::new(),
163 adjustments: Vec::<PositionAdjusted>::new(),
164 replay_events: Vec::new(),
165 fill_voids: Vec::new(),
166 trade_ids: AHashSet::<TradeId>::new(),
167 buy_qty: Quantity::zero(instrument.size_precision()),
168 sell_qty: Quantity::zero(instrument.size_precision()),
169 commissions: IndexMap::<Currency, Money>::new(),
170 trader_id: fill.trader_id,
171 strategy_id: fill.strategy_id,
172 instrument_id: fill.instrument_id,
173 id: position_id,
174 account_id: fill.account_id,
175 opening_order_id: fill.client_order_id,
176 closing_order_id: None,
177 entry: fill.order_side,
178 side: PositionSide::Flat,
179 signed_qty: 0.0,
180 quantity: fill.last_qty,
181 peak_qty: fill.last_qty,
182 price_precision: instrument.price_precision(),
183 size_precision: instrument.size_precision(),
184 multiplier: instrument.multiplier(),
185 is_inverse: instrument.is_inverse(),
186 is_currency_pair: matches!(instrument, InstrumentAny::CurrencyPair(_)),
187 instrument_class: instrument.instrument_class(),
188 base_currency: instrument.base_currency(),
189 quote_currency: instrument.quote_currency(),
190 settlement_currency: instrument.cost_currency(),
191 ts_init: fill.ts_init,
192 ts_opened: fill.ts_event,
193 ts_last: fill.ts_event,
194 ts_closed: None,
195 duration_ns: DurationNanos::default(),
196 avg_px_open: fill.last_px.as_f64(),
197 avg_px_close: None,
198 realized_return: 0.0,
199 realized_pnl: None,
200 };
201
202 item.check_state_float_precision()?;
203 item.check_fill_float_precision(&fill)?;
204 item.apply_fill(&fill, true)?;
205 Ok(item)
206 }
207
208 #[must_use]
215 pub fn clone_without_events(&self) -> Self {
216 Self {
217 events: Vec::new(),
218 adjustments: Vec::new(),
219 replay_events: Vec::new(),
220 fill_voids: Vec::new(),
221 trader_id: self.trader_id,
222 strategy_id: self.strategy_id,
223 instrument_id: self.instrument_id,
224 id: self.id,
225 account_id: self.account_id,
226 opening_order_id: self.opening_order_id,
227 closing_order_id: self.closing_order_id,
228 entry: self.entry,
229 side: self.side,
230 signed_qty: self.signed_qty,
231 quantity: self.quantity,
232 peak_qty: self.peak_qty,
233 price_precision: self.price_precision,
234 size_precision: self.size_precision,
235 multiplier: self.multiplier,
236 is_inverse: self.is_inverse,
237 is_currency_pair: self.is_currency_pair,
238 instrument_class: self.instrument_class,
239 base_currency: self.base_currency,
240 quote_currency: self.quote_currency,
241 settlement_currency: self.settlement_currency,
242 ts_init: self.ts_init,
243 ts_opened: self.ts_opened,
244 ts_last: self.ts_last,
245 ts_closed: self.ts_closed,
246 duration_ns: self.duration_ns,
247 avg_px_open: self.avg_px_open,
248 avg_px_close: self.avg_px_close,
249 realized_return: self.realized_return,
250 realized_pnl: self.realized_pnl,
251 trade_ids: AHashSet::new(),
252 buy_qty: self.buy_qty,
253 sell_qty: self.sell_qty,
254 commissions: self.commissions.clone(),
255 }
256 }
257
258 #[must_use]
260 pub fn clone_for_snapshot(&self) -> Self {
261 let mut snapshot = self.clone_without_events();
262 snapshot.events.clone_from(&self.events);
263 snapshot.adjustments.clone_from(&self.adjustments);
264 snapshot.trade_ids.clone_from(&self.trade_ids);
265 snapshot
266 }
267
268 pub fn transfer_replay_state_from(&mut self, prior: &mut Self) {
270 let current_replay = std::mem::take(&mut self.replay_events);
271 self.replay_events = std::mem::take(&mut prior.replay_events);
272 self.fill_voids = std::mem::take(&mut prior.fill_voids);
273 self.replay_events.extend(current_replay);
274 }
275
276 pub fn purge_events_for_order(&mut self, client_order_id: ClientOrderId) {
287 self.replay_events.retain(|event| {
288 !matches!(event, PositionReplayEvent::Filled(fill) if fill.client_order_id == client_order_id)
289 });
290 self.fill_voids
291 .retain(|record| record.event.client_order_id != client_order_id);
292
293 let filtered_events: Vec<OrderFilled> = self
294 .events
295 .iter()
296 .filter(|e| e.client_order_id != client_order_id)
297 .cloned()
298 .collect();
299
300 let preserved_adjustments: Vec<PositionAdjusted> = self
303 .adjustments
304 .iter()
305 .filter(|adj| {
306 adj.adjustment_type != PositionAdjustmentType::Commission
309 })
310 .copied()
311 .collect();
312
313 if filtered_events.is_empty() {
315 log::warn!(
316 "Position {} has no fills remaining after purging order {}; consider closing the position instead",
317 self.id,
318 client_order_id
319 );
320 self.events.clear();
321 self.trade_ids.clear();
322 self.adjustments.clear();
323 self.buy_qty = Quantity::zero(self.size_precision);
324 self.sell_qty = Quantity::zero(self.size_precision);
325 self.commissions.clear();
326 self.signed_qty = 0.0;
327 self.quantity = Quantity::zero(self.size_precision);
328 self.side = PositionSide::Flat;
329 self.avg_px_close = None;
330 self.realized_pnl = None;
331 self.realized_return = 0.0;
332 self.ts_opened = UnixNanos::default();
333 self.ts_last = UnixNanos::default();
334 self.ts_closed = Some(UnixNanos::default());
335 self.duration_ns = DurationNanos::default();
336 return;
337 }
338
339 let position_id = self.id;
341 self.reset_derived_state();
342
343 let first_event = &filtered_events[0];
345 self.entry = first_event.order_side;
346 self.opening_order_id = first_event.client_order_id;
347 self.ts_opened = first_event.ts_event;
348 self.ts_init = first_event.ts_init;
349 self.closing_order_id = None;
350 self.ts_closed = None;
351 self.duration_ns = DurationNanos::default();
352
353 let restatement_indexes = Self::price_restatement_indexes(&self.replay_events);
354
355 let start = self.replay_events.iter().position(|event| {
356 matches!(
357 event,
358 PositionReplayEvent::Filled(fill) if fill.event_id == first_event.event_id
359 )
360 });
361
362 let remaining_orders: AHashSet<_> = filtered_events
363 .iter()
364 .map(|event| event.client_order_id)
365 .collect();
366 let effective_by_id: AHashMap<_, _> = filtered_events
367 .iter()
368 .map(|event| (event.event_id, event))
369 .collect();
370
371 if let Some(start) = start {
372 let replay = self.replay_events.clone();
373 for (index, event) in replay.iter().enumerate().skip(start) {
374 let PositionReplayEvent::Filled(fill) = event else {
375 continue;
376 };
377
378 if !remaining_orders.contains(&fill.client_order_id) {
379 continue;
380 }
381
382 if restatement_indexes.contains(&index) {
383 self.apply_price_restatement(fill, false);
384 } else if let Some(effective) = effective_by_id.get(&fill.event_id) {
385 self.apply_fill(effective, false).expect_display(FAILED);
386 }
387 }
388 } else {
389 for event in &filtered_events {
390 self.apply_fill(event, false).expect_display(FAILED);
391 }
392 }
393
394 for adjustment in preserved_adjustments {
396 self.apply_adjustment_state(adjustment, false);
397 }
398
399 log::info!(
400 "Purged fills for order {} from position {}; recalculated state: qty={}, signed_qty={}, side={:?}",
401 client_order_id,
402 position_id,
403 self.quantity,
404 self.signed_qty,
405 self.side
406 );
407 }
408
409 pub fn apply(&mut self, fill: &OrderFilled) {
423 self.apply_fill(fill, true).expect_display(FAILED);
424 }
425
426 pub fn try_apply(&mut self, fill: &OrderFilled) -> CorrectnessResult<()> {
443 Self::check_fill_instrument(self.instrument_id, "self.instrument_id", fill)?;
444 let position_id = Self::fill_position_id(fill)?;
445 check_equal(&self.id, &position_id, "self.id", "fill.position_id")?;
446 self.check_fill_float_precision(fill)?;
447 self.apply_fill(fill, true)
448 }
449
450 fn replay_has_event(&self, event_id: UUID4) -> bool {
451 self.replay_events.iter().any(|event| {
452 matches!(
453 event,
454 PositionReplayEvent::Filled(existing) if existing.event_id == event_id
455 )
456 })
457 }
458
459 fn check_fill_instrument(
460 instrument_id: InstrumentId,
461 instrument_param: &str,
462 fill: &OrderFilled,
463 ) -> CorrectnessResult<()> {
464 check_equal(
465 &instrument_id,
466 &fill.instrument_id,
467 instrument_param,
468 "fill.instrument_id",
469 )
470 }
471
472 fn fill_position_id(fill: &OrderFilled) -> CorrectnessResult<PositionId> {
473 fill.position_id
474 .ok_or_else(|| CorrectnessError::PredicateViolation {
475 message: "`fill.position_id` was None".to_string(),
476 })
477 }
478
479 fn check_fill_float_precision(&self, fill: &OrderFilled) -> CorrectnessResult<()> {
480 check_float_precision(fill.last_px.precision)?;
481 check_float_precision(fill.last_qty.precision)?;
482
483 if let Some(commission) = fill.commission
485 && (commission.currency == self.settlement_currency
486 || (self.is_currency_pair && self.base_currency == Some(commission.currency)))
487 {
488 check_float_precision(commission.currency.precision)?;
489 }
490
491 Ok(())
492 }
493
494 pub(crate) fn check_state_float_precision(&self) -> CorrectnessResult<()> {
495 check_float_precision(self.size_precision)?;
496 check_float_precision(self.multiplier.precision)?;
497 check_float_precision(self.settlement_currency.precision)?;
498 check_float_precision(self.quantity.precision)?;
499 check_float_precision(self.buy_qty.precision)?;
500 check_float_precision(self.sell_qty.precision)?;
501
502 if let Some(realized_pnl) = self.realized_pnl {
503 check_float_precision(realized_pnl.currency.precision)?;
504 }
505
506 for fill in &self.events {
507 self.check_fill_float_precision(fill)?;
508 }
509
510 for event in &self.replay_events {
511 match event {
512 PositionReplayEvent::Filled(fill) => self.check_fill_float_precision(fill)?,
513 PositionReplayEvent::InstrumentClosed(close) => {
514 check_float_precision(close.close_price.precision)?;
515 }
516 PositionReplayEvent::Adjusted(_) => {}
517 }
518 }
519
520 Ok(())
521 }
522
523 fn apply_fill(&mut self, fill: &OrderFilled, record_replay: bool) -> CorrectnessResult<()> {
524 if record_replay
525 && self.trade_ids.contains(&fill.trade_id)
526 && self.replay_has_event(fill.event_id)
527 {
528 return Ok(());
529 }
530
531 if self.is_price_restatement(fill) {
532 self.apply_price_restatement(fill, record_replay);
533 return Ok(());
534 }
535
536 if record_replay
537 && (self.side == PositionSide::Flat || !self.trade_ids.contains(&fill.trade_id))
538 && self.is_duplicate_replay_fill(fill)
539 {
540 log::warn!(
541 "Ignoring historical duplicate fill {} for position {}; durable replay already contains this trade",
542 fill.trade_id,
543 self.id,
544 );
545 return Ok(());
546 }
547
548 if fill.ts_event < self.ts_opened {
549 log::warn!(
550 "Fill ts_event {} for {} is before position ts_opened {}",
551 fill.ts_event,
552 self.id,
553 self.ts_opened,
554 );
555 }
556
557 if self.side == PositionSide::Flat {
558 self.reset_cycle(fill);
559 }
560
561 if record_replay {
562 check_predicate_true(
563 !self.trade_ids.contains(&fill.trade_id),
564 "`fill.trade_id` already contained in `trade_ids`",
565 )?;
566 self.replay_events
567 .push(PositionReplayEvent::Filled(fill.clone()));
568 }
569
570 self.events.push(fill.clone());
571 self.trade_ids.insert(fill.trade_id);
572
573 if let Some(commission) = fill.commission {
575 self.commissions
576 .entry(commission.currency)
577 .and_modify(|total| *total = *total + commission)
578 .or_insert(commission);
579 }
580
581 match fill.order_side {
583 OrderSide::Buy => {
584 self.handle_buy_order_fill(fill);
585 }
586 OrderSide::Sell => {
587 self.handle_sell_order_fill(fill);
588 }
589 }
590
591 if self.is_currency_pair
593 && let Some(commission) = fill.commission
594 && let Some(base_currency) = self.base_currency
595 && commission.currency == base_currency
596 {
597 self.apply_base_commission_adjustment(fill, commission);
598 }
599
600 self.quantity = Quantity::new(self.signed_qty.abs(), self.size_precision);
602 if self.quantity > self.peak_qty {
603 self.peak_qty = self.quantity;
604 }
605
606 if self.quantity.is_zero() {
607 self.side = PositionSide::Flat;
608 self.signed_qty = 0.0; self.closing_order_id = Some(fill.client_order_id);
610 self.ts_closed = Some(fill.ts_event);
611 self.duration_ns = fill.ts_event.saturating_duration_since(self.ts_opened);
612 } else if self.signed_qty > 0.0 {
613 self.entry = OrderSide::Buy;
614 self.side = PositionSide::Long;
615 } else {
616 self.entry = OrderSide::Sell;
617 self.side = PositionSide::Short;
618 }
619
620 self.ts_last = fill.ts_event;
621
622 self.debug_assert_invariants();
623
624 Ok(())
625 }
626
627 fn reset_cycle(&mut self, fill: &OrderFilled) {
628 self.events.clear();
629 self.trade_ids.clear();
630 self.adjustments.clear();
631 self.buy_qty = Quantity::zero(self.size_precision);
632 self.sell_qty = Quantity::zero(self.size_precision);
633 self.commissions.clear();
634 self.opening_order_id = fill.client_order_id;
635 self.closing_order_id = None;
636 self.peak_qty = Quantity::zero(self.size_precision);
637 self.ts_init = fill.ts_init;
638 self.ts_opened = fill.ts_event;
639 self.ts_closed = None;
640 self.duration_ns = DurationNanos::default();
641 self.avg_px_open = fill.last_px.as_f64();
642 self.avg_px_close = None;
643 self.realized_return = 0.0;
644 self.realized_pnl = None;
645 }
646
647 #[must_use]
649 pub fn has_replay_trade_id(&self, trade_id: TradeId) -> bool {
650 self.replay_events.iter().any(
651 |event| matches!(event, PositionReplayEvent::Filled(fill) if fill.trade_id == trade_id),
652 )
653 }
654
655 fn is_duplicate_replay_fill(&self, fill: &OrderFilled) -> bool {
656 let continues_latest_fill = fill.causation_id.is_some_and(|source_id| {
657 self.events.last().is_some_and(|latest| {
658 latest.trade_id == fill.trade_id && latest.event_id == source_id
659 })
660 });
661
662 if self.trade_ids.contains(&fill.trade_id) {
663 return !continues_latest_fill
664 || self.replay_events.iter().any(|event| {
665 matches!(
666 event,
667 PositionReplayEvent::Filled(replayed)
668 if replayed.trade_id == fill.trade_id
669 && replayed.causation_id == fill.causation_id
670 )
671 });
672 }
673
674 let replay_starts_current_cycle = self.replay_events.is_empty()
675 || matches!(
676 (self.replay_events.first(), self.events.first()),
677 (
678 Some(PositionReplayEvent::Filled(replayed)),
679 Some(current),
680 ) if replayed.event_id == current.event_id
681 );
682 let corrected_trade = self
683 .fill_voids
684 .iter()
685 .any(|record| record.event.trade_id == fill.trade_id);
686 let current_cycle_only = replay_starts_current_cycle && !corrected_trade;
687 if current_cycle_only {
688 return false;
689 }
690
691 self.has_replay_trade_id(fill.trade_id)
692 }
693
694 fn is_price_restatement(&self, fill: &OrderFilled) -> bool {
695 if self.side == PositionSide::Flat
696 || self.quantity.is_zero()
697 || !self.trade_ids.contains(&fill.trade_id)
698 {
699 return false;
700 }
701
702 let mut latest = None;
703
704 for event in &self.replay_events {
705 let PositionReplayEvent::Filled(existing) = event else {
706 continue;
707 };
708
709 if existing.trade_id != fill.trade_id
710 || existing.client_order_id != fill.client_order_id
711 {
712 continue;
713 }
714
715 if existing.event_id == fill.event_id {
716 return latest.is_some_and(|price| price != fill.last_px);
717 }
718
719 latest = Some(existing.last_px);
720 }
721
722 latest.is_some_and(|price| price != fill.last_px)
723 }
724
725 fn apply_price_restatement(&mut self, fill: &OrderFilled, record_replay: bool) {
726 if self.side != PositionSide::Flat && !self.quantity.is_zero() {
727 self.restate_open_average(fill);
728 }
729
730 self.ts_last = fill.ts_event;
731
732 if record_replay {
733 self.replay_events
734 .push(PositionReplayEvent::Filled(fill.clone()));
735 }
736 }
737
738 fn has_replay_price_restatement(&self) -> bool {
739 let mut seen = AHashMap::new();
740
741 for event in &self.replay_events {
742 let PositionReplayEvent::Filled(fill) = event else {
743 continue;
744 };
745
746 if let Some(price) = seen.get(&fill.trade_id) {
747 if *price != fill.last_px {
748 return true;
749 }
750 } else {
751 seen.insert(fill.trade_id, fill.last_px);
752 }
753 }
754
755 false
756 }
757
758 fn restate_open_average(&mut self, fill: &OrderFilled) {
759 if !self.trade_ids.contains(&fill.trade_id)
760 || fill.order_side != self.entry
761 || !self.extends_current_cycle(fill)
762 {
763 return;
764 }
765
766 let new_px = fill.last_px.as_f64();
767 if self.trade_ids.len() <= 1 {
768 self.avg_px_open = new_px;
769 return;
770 }
771
772 let Some(previous_px) = self.accounting_px_before(fill) else {
773 return;
774 };
775
776 let open_qty = self.quantity.as_f64();
777 if open_qty <= 0.0 {
778 return;
779 }
780
781 let trade_qty = self.cycle_open_qty_for_trade(fill.trade_id).min(open_qty);
782 if trade_qty <= 0.0 {
783 return;
784 }
785
786 let notional = self.avg_px_open * open_qty - previous_px * trade_qty + new_px * trade_qty;
787 self.avg_px_open = notional / open_qty;
788 }
789
790 fn extends_current_cycle(&self, fill: &OrderFilled) -> bool {
791 let long = self.entry == OrderSide::Buy;
792 let mut signed = 0.0_f64;
793
794 for event in &self.events {
795 let delta = if event.order_side == OrderSide::Buy {
796 event.last_qty.as_f64()
797 } else {
798 -event.last_qty.as_f64()
799 };
800
801 if event.trade_id == fill.trade_id && event.client_order_id == fill.client_order_id {
802 let after = signed + delta;
803 return (signed == 0.0 || (signed > 0.0) == long) && ((after > 0.0) == long);
804 }
805
806 signed += delta;
807 }
808
809 false
810 }
811
812 fn cycle_open_qty_for_trade(&self, trade_id: TradeId) -> f64 {
813 let long = self.entry == OrderSide::Buy;
814 let mut remaining = Vec::new();
815 let mut signed = 0.0_f64;
816
817 for fill in &self.events {
818 let qty = fill.last_qty.as_f64();
819 let delta = if fill.order_side == OrderSide::Buy {
820 qty
821 } else {
822 -qty
823 };
824
825 let after = signed + delta;
826
827 if signed != 0.0 && (after > 0.0) != (signed > 0.0) {
828 remaining.clear();
831 if after != 0.0 && (after > 0.0) == long {
832 remaining.push((fill.trade_id, after.abs()));
833 }
834
835 signed = after;
836 continue;
837 }
838
839 if fill.order_side == self.entry {
840 remaining.push((fill.trade_id, qty));
841 signed = after;
842 continue;
843 }
844
845 let open_qty: f64 = remaining.iter().map(|(_, qty)| *qty).sum();
846 if open_qty <= 0.0 {
847 signed = after;
848 continue;
849 }
850
851 let keep = (open_qty - qty.min(open_qty)) / open_qty;
852 for lot in &mut remaining {
853 lot.1 *= keep;
854 }
855
856 signed = after;
857 }
858
859 remaining
860 .into_iter()
861 .filter(|(id, _)| *id == trade_id)
862 .map(|(_, qty)| qty)
863 .sum()
864 }
865
866 fn accounting_px_before(&self, fill: &OrderFilled) -> Option<f64> {
867 let mut price = None;
868
869 for event in &self.replay_events {
870 let PositionReplayEvent::Filled(existing) = event else {
871 continue;
872 };
873
874 if existing.trade_id != fill.trade_id {
875 continue;
876 }
877
878 if existing.event_id == fill.event_id {
879 break;
880 }
881
882 price = Some(existing.last_px.as_f64());
883 }
884
885 price
886 }
887
888 fn voidable_replay_fill<'a>(
889 restatement_indexes: &AHashSet<usize>,
890 index: usize,
891 replay_event: &'a PositionReplayEvent,
892 ) -> Option<&'a OrderFilled> {
893 let PositionReplayEvent::Filled(fill) = replay_event else {
894 return None;
895 };
896
897 if restatement_indexes.contains(&index) {
898 return None;
899 }
900
901 Some(fill)
902 }
903
904 fn price_restatement_indexes(replay_events: &[PositionReplayEvent]) -> AHashSet<usize> {
905 let mut latest_px = AHashMap::new();
906 let mut indexes = AHashSet::new();
907
908 for (index, event) in replay_events.iter().enumerate() {
909 let PositionReplayEvent::Filled(fill) = event else {
910 continue;
911 };
912
913 let key = (fill.trade_id, fill.client_order_id);
914 if let Some(price) = latest_px.get(&key)
915 && *price != fill.last_px
916 {
917 indexes.insert(index);
918 }
919
920 latest_px.insert(key, fill.last_px);
921 }
922
923 indexes
924 }
925
926 fn handle_buy_order_fill(&mut self, fill: &OrderFilled) {
927 let mut realized_pnl = if let Some(commission) = fill.commission {
929 if commission.currency == self.settlement_currency {
930 -commission.as_f64()
931 } else {
932 0.0
933 }
934 } else {
935 0.0
936 };
937
938 let last_px = fill.last_px.as_f64();
939 let last_qty = fill.last_qty.as_f64();
940 let last_qty_object = fill.last_qty;
941 let was_short = self.signed_qty < 0.0;
942 let is_reversal = was_short && last_qty_object > self.quantity;
943 let closing_qty_object = if is_reversal {
944 self.quantity
945 } else {
946 last_qty_object
947 };
948 let opening_qty_object = last_qty_object - closing_qty_object;
949 let closing_qty = closing_qty_object.as_f64();
950
951 if self.signed_qty > 0.0 {
952 self.avg_px_open = self.calculate_avg_px_open_px(last_px, last_qty);
953 } else if was_short {
954 let avg_px_close = self.calculate_avg_px_close_px(last_px, closing_qty);
956 self.avg_px_close = Some(avg_px_close);
957 self.realized_return = self
958 .calculate_return(self.avg_px_open, avg_px_close)
959 .unwrap_or_else(|e| {
960 log::error!("Error calculating return: {e}");
961 0.0
962 });
963 realized_pnl += self
964 .calculate_pnl_raw(self.avg_px_open, last_px, closing_qty)
965 .unwrap_or_else(|e| {
966 log::error!("Error calculating PnL: {e}");
967 0.0
968 });
969 }
970
971 let current_pnl = self.realized_pnl.map_or(0.0, |p| p.as_f64());
972 self.realized_pnl = Some(Money::new(
973 current_pnl + realized_pnl,
974 self.settlement_currency,
975 ));
976
977 self.signed_qty += last_qty;
978 self.buy_qty = self.buy_qty + last_qty_object;
979
980 if is_reversal {
982 self.avg_px_open = last_px;
983 self.avg_px_close = None;
984 self.realized_return = 0.0;
985 self.buy_qty = opening_qty_object;
986 self.sell_qty = Quantity::zero(self.size_precision);
987 }
988 }
989
990 fn handle_sell_order_fill(&mut self, fill: &OrderFilled) {
991 let mut realized_pnl = if let Some(commission) = fill.commission {
993 if commission.currency == self.settlement_currency {
994 -commission.as_f64()
995 } else {
996 0.0
997 }
998 } else {
999 0.0
1000 };
1001
1002 let last_px = fill.last_px.as_f64();
1003 let last_qty = fill.last_qty.as_f64();
1004 let last_qty_object = fill.last_qty;
1005 let was_long = self.signed_qty > 0.0;
1006 let is_reversal = was_long && last_qty_object > self.quantity;
1007 let closing_qty_object = if is_reversal {
1008 self.quantity
1009 } else {
1010 last_qty_object
1011 };
1012 let opening_qty_object = last_qty_object - closing_qty_object;
1013 let closing_qty = closing_qty_object.as_f64();
1014
1015 if self.signed_qty < 0.0 {
1016 self.avg_px_open = self.calculate_avg_px_open_px(last_px, last_qty);
1017 } else if was_long {
1018 let avg_px_close = self.calculate_avg_px_close_px(last_px, closing_qty);
1020 self.avg_px_close = Some(avg_px_close);
1021 self.realized_return = self
1022 .calculate_return(self.avg_px_open, avg_px_close)
1023 .unwrap_or_else(|e| {
1024 log::error!("Error calculating return: {e}");
1025 0.0
1026 });
1027 realized_pnl += self
1028 .calculate_pnl_raw(self.avg_px_open, last_px, closing_qty)
1029 .unwrap_or_else(|e| {
1030 log::error!("Error calculating PnL: {e}");
1031 0.0
1032 });
1033 }
1034
1035 let current_pnl = self.realized_pnl.map_or(0.0, |p| p.as_f64());
1036 self.realized_pnl = Some(Money::new(
1037 current_pnl + realized_pnl,
1038 self.settlement_currency,
1039 ));
1040
1041 self.signed_qty -= last_qty;
1042 self.sell_qty = self.sell_qty + last_qty_object;
1043
1044 if is_reversal {
1046 self.avg_px_open = last_px;
1047 self.avg_px_close = None;
1048 self.realized_return = 0.0;
1049 self.buy_qty = Quantity::zero(self.size_precision);
1050 self.sell_qty = opening_qty_object;
1051 }
1052 }
1053
1054 pub fn apply_adjustment(&mut self, adjustment: PositionAdjusted) {
1067 self.apply_adjustment_state(adjustment, true);
1068 }
1069
1070 pub fn revert_last_adjustment(&mut self, prior: &Self) -> CorrectnessResult<()> {
1090 check_equal(&self.id, &prior.id, "self.id", "prior.id")?;
1091 check_equal(
1092 &self.size_precision,
1093 &prior.size_precision,
1094 "self.size_precision",
1095 "prior.size_precision",
1096 )?;
1097 check_equal(
1098 &self.settlement_currency,
1099 &prior.settlement_currency,
1100 "self.settlement_currency",
1101 "prior.settlement_currency",
1102 )?;
1103
1104 let adjustment = match (self.replay_events.last(), self.adjustments.last()) {
1105 (Some(PositionReplayEvent::Adjusted(replayed)), Some(latest)) if replayed == latest => {
1106 *latest
1107 }
1108 _ => {
1109 return Err(CorrectnessError::PredicateViolation {
1110 message: format!(
1111 "cannot revert adjustment for position {}: the last replay event is not its latest adjustment",
1112 self.id
1113 ),
1114 });
1115 }
1116 };
1117
1118 if let Some(earlier) = self.adjustments.iter().rev().nth(1)
1119 && prior.ts_last < earlier.ts_event
1120 {
1121 return Err(CorrectnessError::PredicateViolation {
1122 message: format!(
1123 "cannot revert adjustment {} for position {}: `prior` at {} predates adjustment {} at {}",
1124 adjustment.event_id, self.id, prior.ts_last, earlier.event_id, earlier.ts_event
1125 ),
1126 });
1127 }
1128
1129 if let (Some(realized_pnl), Some(pnl_change)) = (prior.realized_pnl, adjustment.pnl_change)
1131 && (realized_pnl.currency != pnl_change.currency
1132 || realized_pnl.checked_add(pnl_change).is_none())
1133 {
1134 return Err(CorrectnessError::PredicateViolation {
1135 message: format!(
1136 "cannot revert adjustment {} for position {}: `prior` realized PnL {realized_pnl} cannot take the PnL change {pnl_change}",
1137 adjustment.event_id, self.id
1138 ),
1139 });
1140 }
1141
1142 let mut expected = prior.clone_without_events();
1143 expected.apply_adjustment_state(adjustment, false);
1144
1145 if expected.signed_qty != self.signed_qty
1146 || expected.quantity != self.quantity
1147 || expected.peak_qty != self.peak_qty
1148 || expected.side != self.side
1149 || expected.realized_pnl != self.realized_pnl
1150 || expected.ts_last != self.ts_last
1151 {
1152 return Err(CorrectnessError::PredicateViolation {
1153 message: format!(
1154 "cannot revert adjustment {} for position {}: `prior` is not the state before it",
1155 adjustment.event_id, self.id
1156 ),
1157 });
1158 }
1159
1160 self.adjustments.pop();
1161 self.replay_events.pop();
1162 self.signed_qty = prior.signed_qty;
1163 self.quantity = prior.quantity;
1164 self.peak_qty = prior.peak_qty;
1165 self.side = prior.side;
1166 self.realized_pnl = prior.realized_pnl;
1167 self.ts_last = prior.ts_last;
1168
1169 self.debug_assert_invariants();
1170 Ok(())
1171 }
1172
1173 fn apply_adjustment_state(&mut self, adjustment: PositionAdjusted, record_replay: bool) {
1174 if record_replay {
1175 self.replay_events
1176 .push(PositionReplayEvent::Adjusted(adjustment));
1177 }
1178
1179 if let Some(quantity_change) = adjustment.quantity_change {
1181 self.signed_qty += quantity_change
1182 .to_f64()
1183 .expect("Failed to convert Decimal to f64");
1184
1185 self.quantity = Quantity::new(self.signed_qty.abs(), self.size_precision);
1186
1187 if self.quantity > self.peak_qty {
1188 self.peak_qty = self.quantity;
1189 }
1190 }
1191
1192 if let Some(pnl_change) = adjustment.pnl_change {
1194 self.realized_pnl = Some(match self.realized_pnl {
1195 Some(current) => current + pnl_change,
1196 None => pnl_change,
1197 });
1198 }
1199
1200 if self.quantity.is_zero() {
1203 self.side = PositionSide::Flat;
1204 self.signed_qty = 0.0; } else if self.signed_qty > 0.0 {
1206 self.side = PositionSide::Long;
1207 } else {
1208 self.side = PositionSide::Short;
1209 }
1210
1211 self.adjustments.push(adjustment);
1212 self.ts_last = adjustment.ts_event;
1213
1214 self.debug_assert_invariants();
1215 }
1216
1217 pub fn apply_instrument_close(&mut self, close: InstrumentClose) -> anyhow::Result<()> {
1230 anyhow::ensure!(
1231 close.instrument_id == self.instrument_id,
1232 "instrument close {} does not match position instrument {}",
1233 close.instrument_id,
1234 self.instrument_id,
1235 );
1236 anyhow::ensure!(
1237 close.close_type == InstrumentCloseType::ContractExpired,
1238 "instrument close for {} is not a contract expiration",
1239 close.instrument_id,
1240 );
1241 anyhow::ensure!(
1242 self.is_open(),
1243 "cannot settle position {} which is not open",
1244 self.id,
1245 );
1246
1247 self.apply_instrument_close_state(close, true)
1248 }
1249
1250 fn apply_instrument_close_state(
1251 &mut self,
1252 close: InstrumentClose,
1253 record_replay: bool,
1254 ) -> anyhow::Result<()> {
1255 if self.side == PositionSide::Flat {
1257 return Ok(());
1258 }
1259
1260 let last_qty = self.quantity;
1261 let last_px = close.close_price.as_f64();
1262 let avg_px_close = self.calculate_avg_px_close_px(last_px, last_qty.as_f64());
1263
1264 let realized_return = self
1265 .calculate_return(self.avg_px_open, avg_px_close)
1266 .unwrap_or_else(|e| {
1267 log::error!("Error calculating return: {e}");
1268 0.0
1269 });
1270
1271 let settlement_pnl = self.try_calculate_pnl(self.avg_px_open, last_px, last_qty)?;
1272
1273 let realized_pnl = match self.realized_pnl {
1274 Some(current) => current.checked_add(settlement_pnl).ok_or_else(|| {
1275 anyhow::anyhow!("settlement PnL overflow for position {}", self.id)
1276 })?,
1277 None => settlement_pnl,
1278 };
1279
1280 if record_replay {
1281 self.replay_events
1282 .push(PositionReplayEvent::InstrumentClosed(close));
1283 }
1284
1285 self.avg_px_close = Some(avg_px_close);
1286 self.realized_return = realized_return;
1287 self.realized_pnl = Some(realized_pnl);
1288 self.side = PositionSide::Flat;
1289 self.signed_qty = 0.0;
1290 self.quantity = Quantity::zero(self.size_precision);
1291 self.closing_order_id = None;
1292 self.ts_last = close.ts_event;
1293 self.ts_closed = Some(close.ts_event);
1294 self.duration_ns = close.ts_event.saturating_duration_since(self.ts_opened);
1295
1296 self.debug_assert_invariants();
1297
1298 Ok(())
1299 }
1300
1301 fn debug_assert_invariants(&self) {
1302 debug_assert!(
1303 match self.side {
1304 PositionSide::Long => self.signed_qty > 0.0,
1305 PositionSide::Short => self.signed_qty < 0.0,
1306 PositionSide::Flat => self.signed_qty == 0.0,
1307 },
1308 "Invariant: position side must match signed_qty sign (side={:?}, signed_qty={})",
1309 self.side,
1310 self.signed_qty,
1311 );
1312 debug_assert!(
1313 self.peak_qty >= self.quantity,
1314 "Invariant: peak_qty must not be less than current quantity (peak={}, quantity={})",
1315 self.peak_qty,
1316 self.quantity,
1317 );
1318 }
1319
1320 pub fn apply_fill_void(
1333 &mut self,
1334 event: OrderFillVoided,
1335 voided_qty: Quantity,
1336 commission_voided: Option<Money>,
1337 ) -> anyhow::Result<Option<Money>> {
1338 let fragments = self.fill_fragments(event.client_order_id, event.trade_id);
1339
1340 let fragment_qty = fragments
1341 .iter()
1342 .fold(Quantity::zero(self.size_precision), |total, fill| {
1343 total + fill.last_qty
1344 });
1345 anyhow::ensure!(
1346 !voided_qty.is_zero() && voided_qty <= fragment_qty,
1347 "position fill void exceeds known fragments for {}",
1348 event.trade_id,
1349 );
1350
1351 if let Some(commission_voided) = commission_voided {
1352 for commission in fragments.iter().filter_map(|fill| fill.commission) {
1353 anyhow::ensure!(
1354 commission.currency == commission_voided.currency,
1355 "position commission currency differs for fill {}",
1356 event.trade_id,
1357 );
1358 }
1359 }
1360
1361 if let Some(previous) = self.fill_voids.iter().rev().find(|record| {
1362 record.event.client_order_id == event.client_order_id
1363 && record.event.trade_id == event.trade_id
1364 }) {
1365 anyhow::ensure!(
1366 voided_qty >= previous.voided_qty,
1367 "stale position fill void for {}",
1368 event.trade_id,
1369 );
1370 anyhow::ensure!(
1371 voided_qty != previous.voided_qty
1372 || commission_voided != previous.commission_voided,
1373 "duplicate position fill void for {}",
1374 event.trade_id,
1375 );
1376 }
1377
1378 self.fill_voids.push(PositionFillVoid {
1379 event,
1380 voided_qty,
1381 commission_voided,
1382 });
1383
1384 Ok(self.rebuild_from_replay())
1385 }
1386
1387 #[must_use]
1389 pub fn fill_fragments(
1390 &self,
1391 client_order_id: ClientOrderId,
1392 trade_id: TradeId,
1393 ) -> Vec<&OrderFilled> {
1394 let restatement_indexes = Self::price_restatement_indexes(&self.replay_events);
1395 self.replay_events
1396 .iter()
1397 .enumerate()
1398 .filter_map(|(index, event)| match event {
1399 PositionReplayEvent::Filled(fill)
1400 if fill.client_order_id == client_order_id
1401 && fill.trade_id == trade_id
1402 && !restatement_indexes.contains(&index) =>
1403 {
1404 Some(fill)
1405 }
1406 _ => None,
1407 })
1408 .collect()
1409 }
1410
1411 fn rebuild_from_replay(&mut self) -> Option<Money> {
1415 let replay_events = self.replay_events.clone();
1416 let restatement_indexes = Self::price_restatement_indexes(&replay_events);
1417 let mut quantity_removed = AHashMap::<usize, Quantity>::new();
1418 let mut commission_removed = AHashMap::<usize, Money>::new();
1419
1420 for correction in self.latest_fill_voids() {
1421 let mut remaining_qty = correction.voided_qty;
1422 let mut remaining_commission = correction.commission_voided;
1423
1424 for (index, replay_event) in replay_events.iter().enumerate().rev() {
1425 let Some(fill) =
1426 Self::voidable_replay_fill(&restatement_indexes, index, replay_event)
1427 else {
1428 continue;
1429 };
1430
1431 if fill.client_order_id != correction.event.client_order_id
1432 || fill.trade_id != correction.event.trade_id
1433 {
1434 continue;
1435 }
1436
1437 if !remaining_qty.is_zero() {
1438 let removed = remaining_qty.min(fill.last_qty);
1439 quantity_removed.insert(index, removed);
1440 remaining_qty = remaining_qty - removed;
1441 }
1442
1443 if let (Some(remaining), Some(commission)) = (remaining_commission, fill.commission)
1444 {
1445 let magnitude = remaining.abs().min(commission.abs());
1446
1447 let removed = if remaining.is_negative() {
1448 -magnitude
1449 } else {
1450 magnitude
1451 };
1452
1453 commission_removed.insert(index, removed);
1454 let next = remaining - removed;
1455 remaining_commission = (!next.is_zero()).then_some(next);
1456 }
1457 }
1458 }
1459
1460 self.reset_derived_state();
1461
1462 let mut closed_cycles_pnl: Option<Money> = None;
1463
1464 for (index, replay_event) in replay_events.iter().enumerate() {
1465 match replay_event {
1466 PositionReplayEvent::Filled(fill) => {
1467 if restatement_indexes.contains(&index) {
1468 self.apply_price_restatement(fill, false);
1469 continue;
1470 }
1471
1472 let removed = quantity_removed
1473 .get(&index)
1474 .copied()
1475 .unwrap_or_else(|| Quantity::zero(fill.last_qty.precision));
1476 let effective_qty = fill.last_qty - removed;
1477 let effective_commission =
1478 match (fill.commission, commission_removed.get(&index).copied()) {
1479 (Some(commission), Some(removed)) => Some(commission - removed),
1480 (commission, None) => commission,
1481 (None, Some(_)) => None,
1482 };
1483
1484 if effective_qty.is_zero() {
1485 if let Some(commission) =
1486 effective_commission.filter(|commission| !commission.is_zero())
1487 && let Some(realized_pnl) =
1488 self.apply_surviving_fill_commission(fill, commission)
1489 {
1490 closed_cycles_pnl = Some(
1491 closed_cycles_pnl
1492 .map_or(realized_pnl, |total| total + realized_pnl),
1493 );
1494 }
1495 continue;
1496 }
1497
1498 if self.side == PositionSide::Flat
1501 && let Some(realized_pnl) = self.realized_pnl
1502 {
1503 closed_cycles_pnl = Some(
1504 closed_cycles_pnl.map_or(realized_pnl, |total| total + realized_pnl),
1505 );
1506 }
1507
1508 let mut effective = fill.clone();
1509 effective.last_qty = effective_qty;
1510 effective.commission = effective_commission;
1511 self.apply_fill(&effective, false).expect_display(FAILED);
1512 }
1513 PositionReplayEvent::Adjusted(adjustment) => {
1514 self.apply_adjustment_state(*adjustment, false);
1515 }
1516 PositionReplayEvent::InstrumentClosed(close) => {
1517 self.apply_instrument_close_state(*close, false)
1518 .expect("replaying a validated instrument close must succeed");
1519 }
1520 }
1521 }
1522
1523 closed_cycles_pnl
1524 }
1525
1526 fn apply_surviving_fill_commission(
1527 &mut self,
1528 fill: &OrderFilled,
1529 commission: Money,
1530 ) -> Option<Money> {
1531 let is_base_commission =
1532 self.is_currency_pair && self.base_currency == Some(commission.currency);
1533 let reopens = self.side == PositionSide::Flat
1534 && is_base_commission
1535 && !Quantity::new(commission.as_f64().abs(), self.size_precision).is_zero();
1536 let closed_cycles_pnl_previous = reopens.then_some(self.realized_pnl).flatten();
1537
1538 if reopens {
1539 self.reset_cycle(fill);
1540 }
1541
1542 self.commissions
1543 .entry(commission.currency)
1544 .and_modify(|total| *total = *total + commission)
1545 .or_insert(commission);
1546
1547 if commission.currency == self.settlement_currency {
1548 let pnl_change = Money::zero(self.settlement_currency) - commission;
1549 self.realized_pnl = Some(match self.realized_pnl {
1550 Some(current) => current + pnl_change,
1551 None => pnl_change,
1552 });
1553 }
1554
1555 if is_base_commission {
1556 let previous_side = self.side;
1557 self.apply_base_commission_adjustment(fill, commission);
1558 self.finalize_surviving_base_commission(fill, previous_side);
1559 } else {
1560 self.ts_last = fill.ts_event;
1561 }
1562
1563 closed_cycles_pnl_previous
1564 }
1565
1566 fn finalize_surviving_base_commission(
1567 &mut self,
1568 fill: &OrderFilled,
1569 previous_side: PositionSide,
1570 ) {
1571 if self.side == PositionSide::Flat {
1572 if previous_side != PositionSide::Flat {
1573 self.closing_order_id = Some(fill.client_order_id);
1574 self.ts_closed = Some(fill.ts_event);
1575 self.duration_ns = fill.ts_event.saturating_duration_since(self.ts_opened);
1576 }
1577 } else {
1578 self.entry = match self.side {
1579 PositionSide::Long => OrderSide::Buy,
1580 PositionSide::Short => OrderSide::Sell,
1581 PositionSide::Flat => unreachable!(),
1582 };
1583
1584 if previous_side != PositionSide::Flat && previous_side != self.side {
1585 self.avg_px_open = fill.last_px.as_f64();
1586 }
1587 }
1588 }
1589
1590 fn apply_base_commission_adjustment(&mut self, fill: &OrderFilled, commission: Money) {
1591 let mut adjustment_id = fill.event_id.as_bytes();
1592 adjustment_id[15] ^= 0x01;
1593 self.apply_adjustment_state(
1594 PositionAdjusted::new(
1595 self.trader_id,
1596 self.strategy_id,
1597 self.instrument_id,
1598 self.id,
1599 self.account_id,
1600 PositionAdjustmentType::Commission,
1601 Some(-commission.as_decimal()),
1602 None,
1603 Some(fill.client_order_id.inner()),
1604 UUID4::from_bytes(adjustment_id),
1605 fill.ts_event,
1606 fill.ts_init,
1607 ),
1608 false,
1609 );
1610 }
1611
1612 fn latest_fill_voids(&self) -> Vec<&PositionFillVoid> {
1613 let mut latest = IndexMap::<(ClientOrderId, TradeId), &PositionFillVoid>::new();
1614 for correction in &self.fill_voids {
1615 latest.insert(
1616 (correction.event.client_order_id, correction.event.trade_id),
1617 correction,
1618 );
1619 }
1620 latest.into_values().collect()
1621 }
1622
1623 fn reset_derived_state(&mut self) {
1624 self.events.clear();
1625 self.adjustments.clear();
1626 self.trade_ids.clear();
1627 self.buy_qty = Quantity::zero(self.size_precision);
1628 self.sell_qty = Quantity::zero(self.size_precision);
1629 self.commissions.clear();
1630 self.signed_qty = 0.0;
1631 self.quantity = Quantity::zero(self.size_precision);
1632 self.peak_qty = Quantity::zero(self.size_precision);
1633 self.side = PositionSide::Flat;
1634 self.closing_order_id = None;
1635 self.ts_opened = UnixNanos::default();
1636 self.ts_last = UnixNanos::default();
1637 self.ts_closed = Some(UnixNanos::default());
1638 self.duration_ns = DurationNanos::default();
1639 self.avg_px_open = 0.0;
1640 self.avg_px_close = None;
1641 self.realized_pnl = None;
1642 self.realized_return = 0.0;
1643 }
1644
1645 fn calculate_avg_px(
1687 &self,
1688 qty: f64,
1689 avg_pg: f64,
1690 last_px: f64,
1691 last_qty: f64,
1692 ) -> anyhow::Result<f64> {
1693 debug_assert!(
1696 qty >= 0.0 && last_qty >= 0.0,
1697 "Invariant: average price calc requires non-negative quantities \
1698 (qty={qty}, last_qty={last_qty})"
1699 );
1700
1701 if qty == 0.0 && last_qty == 0.0 {
1702 anyhow::bail!("Cannot calculate average price: both quantities are zero");
1703 }
1704
1705 if last_qty == 0.0 {
1706 anyhow::bail!("Cannot calculate average price: fill quantity is zero");
1707 }
1708
1709 if qty == 0.0 {
1710 return Ok(last_px);
1711 }
1712
1713 let start_cost = avg_pg * qty;
1714 let event_cost = last_px * last_qty;
1715 let total_qty = qty + last_qty;
1716
1717 if total_qty <= 0.0 {
1719 anyhow::bail!(
1720 "Total quantity unexpectedly zero or negative in average price calculation: qty={qty}, last_qty={last_qty}, total_qty={total_qty}"
1721 );
1722 }
1723
1724 Ok((start_cost + event_cost) / total_qty)
1725 }
1726
1727 fn calculate_avg_px_open_px(&self, last_px: f64, last_qty: f64) -> f64 {
1728 self.calculate_avg_px(self.quantity.as_f64(), self.avg_px_open, last_px, last_qty)
1729 .unwrap_or_else(|e| {
1730 log::error!("Error calculating average open price: {e}");
1731 last_px
1732 })
1733 }
1734
1735 fn calculate_avg_px_close_px(&self, last_px: f64, last_qty: f64) -> f64 {
1736 let Some(avg_px_close) = self.avg_px_close else {
1737 return last_px;
1738 };
1739 let closing_qty = if self.side == PositionSide::Long {
1740 self.sell_qty
1741 } else {
1742 self.buy_qty
1743 };
1744 self.calculate_avg_px(closing_qty.as_f64(), avg_px_close, last_px, last_qty)
1745 .unwrap_or_else(|e| {
1746 log::error!("Error calculating average close price: {e}");
1747 last_px
1748 })
1749 }
1750
1751 fn calculate_points(&self, avg_px_open: f64, avg_px_close: f64) -> f64 {
1752 match self.side {
1753 PositionSide::Long => avg_px_close - avg_px_open,
1754 PositionSide::Short => avg_px_open - avg_px_close,
1755 PositionSide::Flat => 0.0,
1756 }
1757 }
1758
1759 fn calculate_points_inverse(&self, avg_px_open: f64, avg_px_close: f64) -> anyhow::Result<f64> {
1760 const EPSILON: f64 = 1e-15;
1762
1763 if avg_px_open <= 0.0 || avg_px_open.abs() < EPSILON {
1764 anyhow::bail!(
1765 "Cannot calculate inverse points: open price is not positive or is too small ({avg_px_open})"
1766 );
1767 }
1768
1769 if avg_px_close <= 0.0 || avg_px_close.abs() < EPSILON {
1770 anyhow::bail!(
1771 "Cannot calculate inverse points: close price is not positive or is too small ({avg_px_close})"
1772 );
1773 }
1774
1775 let inverse_open = 1.0 / avg_px_open;
1776 let inverse_close = 1.0 / avg_px_close;
1777 let result = match self.side {
1778 PositionSide::Long => inverse_open - inverse_close,
1779 PositionSide::Short => inverse_close - inverse_open,
1780 PositionSide::Flat => 0.0,
1781 };
1782 Ok(result)
1783 }
1784
1785 fn calculate_return(&self, avg_px_open: f64, avg_px_close: f64) -> anyhow::Result<f64> {
1786 if avg_px_open == 0.0 {
1788 anyhow::bail!(
1789 "Cannot calculate return: open price is zero (close price: {avg_px_close})"
1790 );
1791 }
1792 Ok(self.calculate_points(avg_px_open, avg_px_close) / avg_px_open)
1793 }
1794
1795 fn calculate_pnl_raw(
1796 &self,
1797 avg_px_open: f64,
1798 avg_px_close: f64,
1799 quantity: f64,
1800 ) -> anyhow::Result<f64> {
1801 let quantity = quantity.min(self.signed_qty.abs());
1802
1803 let result = if self
1804 .instrument_class
1805 .divides_notional_by_price(self.is_inverse)
1806 {
1807 anyhow::ensure!(
1808 self.base_currency.is_some(),
1809 "inverse position {} has no base currency",
1810 self.instrument_id
1811 );
1812 let points = self.calculate_points_inverse(avg_px_open, avg_px_close)?;
1813 quantity * self.multiplier.as_f64() * points
1814 } else {
1815 quantity * self.multiplier.as_f64() * self.calculate_points(avg_px_open, avg_px_close)
1816 };
1817 Ok(result)
1818 }
1819
1820 pub fn try_calculate_pnl(
1828 &self,
1829 avg_px_open: f64,
1830 avg_px_close: f64,
1831 quantity: Quantity,
1832 ) -> anyhow::Result<Money> {
1833 check_float_precision(quantity.precision)?;
1834 let pnl_raw = self.calculate_pnl_raw(avg_px_open, avg_px_close, quantity.as_f64())?;
1835 Money::new_checked(pnl_raw, self.settlement_currency).map_err(Into::into)
1836 }
1837
1838 #[must_use]
1840 pub fn calculate_pnl(&self, avg_px_open: f64, avg_px_close: f64, quantity: Quantity) -> Money {
1841 self.try_calculate_pnl(avg_px_open, avg_px_close, quantity)
1842 .unwrap_or_else(|e| {
1843 log::error!("Error calculating PnL: {e}");
1844 Money::zero(self.settlement_currency)
1845 })
1846 }
1847
1848 pub fn try_total_pnl(&self, last: Price) -> anyhow::Result<Money> {
1855 let unrealized = self.try_unrealized_pnl(last)?;
1856
1857 match self.realized_pnl {
1858 Some(realized) => {
1859 anyhow::ensure!(
1860 realized.currency == unrealized.currency,
1861 "realized and unrealized PnL currencies differ"
1862 );
1863 realized
1864 .checked_add(unrealized)
1865 .ok_or_else(|| anyhow::anyhow!("total PnL overflow"))
1866 }
1867 None => Ok(unrealized),
1868 }
1869 }
1870
1871 #[must_use]
1873 pub fn total_pnl(&self, last: Price) -> Money {
1874 self.try_total_pnl(last).unwrap_or_else(|e| {
1875 log::error!("Error calculating total PnL: {e}");
1876 Money::zero(self.settlement_currency)
1877 })
1878 }
1879
1880 pub fn try_unrealized_pnl(&self, last: Price) -> anyhow::Result<Money> {
1888 if self.side == PositionSide::Flat {
1889 Ok(Money::zero(self.settlement_currency))
1890 } else {
1891 check_float_precision(last.precision)?;
1892 let pnl =
1893 self.calculate_pnl_raw(self.avg_px_open, last.as_f64(), self.quantity.as_f64())?;
1894 Money::new_checked(pnl, self.settlement_currency).map_err(Into::into)
1895 }
1896 }
1897
1898 #[must_use]
1900 pub fn unrealized_pnl(&self, last: Price) -> Money {
1901 self.try_unrealized_pnl(last).unwrap_or_else(|e| {
1902 log::error!("Error calculating unrealized PnL: {e}");
1903 Money::zero(self.settlement_currency)
1904 })
1905 }
1906
1907 #[must_use]
1909 pub fn closing_order_side(&self) -> Option<OrderSide> {
1910 match self.side {
1911 PositionSide::Long => Some(OrderSide::Sell),
1912 PositionSide::Short => Some(OrderSide::Buy),
1913 PositionSide::Flat => None,
1914 }
1915 }
1916
1917 #[must_use]
1919 pub fn is_opposite_side(&self, side: OrderSide) -> bool {
1920 self.entry != side
1921 }
1922
1923 #[must_use]
1925 pub fn symbol(&self) -> Symbol {
1926 self.instrument_id.symbol
1927 }
1928
1929 #[must_use]
1931 pub fn venue(&self) -> Venue {
1932 self.instrument_id.venue
1933 }
1934
1935 #[must_use]
1937 pub fn event_count(&self) -> usize {
1938 self.events.len()
1939 }
1940
1941 #[must_use]
1943 pub fn client_order_ids(&self) -> Vec<ClientOrderId> {
1944 let mut result = self
1946 .events
1947 .iter()
1948 .map(|event| event.client_order_id)
1949 .collect::<AHashSet<ClientOrderId>>()
1950 .into_iter()
1951 .collect::<Vec<ClientOrderId>>();
1952 result.sort_unstable();
1953 result
1954 }
1955
1956 #[must_use]
1958 pub fn venue_order_ids(&self) -> Vec<VenueOrderId> {
1959 let mut result = self
1961 .events
1962 .iter()
1963 .map(|event| event.venue_order_id)
1964 .collect::<AHashSet<VenueOrderId>>()
1965 .into_iter()
1966 .collect::<Vec<VenueOrderId>>();
1967 result.sort_unstable();
1968 result
1969 }
1970
1971 #[must_use]
1973 pub fn trade_ids(&self) -> Vec<TradeId> {
1974 let mut result = self
1975 .events
1976 .iter()
1977 .map(|event| event.trade_id)
1978 .collect::<AHashSet<TradeId>>()
1979 .into_iter()
1980 .collect::<Vec<TradeId>>();
1981 result.sort_unstable();
1982 result
1983 }
1984
1985 pub fn try_notional_value(&self, last: Price) -> anyhow::Result<Money> {
1993 let currency = if self.is_inverse {
1994 self.base_currency.ok_or_else(|| {
1995 anyhow::anyhow!(
1996 "inverse position {} has no base currency",
1997 self.instrument_id
1998 )
1999 })?
2000 } else {
2001 self.settlement_currency
2002 };
2003
2004 crate::instruments::try_notional_value(
2005 self.quantity,
2006 last,
2007 self.multiplier,
2008 self.instrument_class,
2009 self.is_inverse,
2010 false,
2011 currency,
2012 )
2013 }
2014
2015 #[must_use]
2021 pub fn notional_value(&self, last: Price) -> Money {
2022 self.try_notional_value(last)
2023 .expect("invalid notional value")
2024 }
2025
2026 #[must_use]
2028 pub fn last_event(&self) -> Option<OrderFilled> {
2029 self.events.last().cloned()
2030 }
2031
2032 #[must_use]
2034 pub fn last_trade_id(&self) -> Option<TradeId> {
2035 self.events.last().map(|e| e.trade_id)
2036 }
2037
2038 #[must_use]
2040 pub fn is_long(&self) -> bool {
2041 self.side == PositionSide::Long
2042 }
2043
2044 #[must_use]
2046 pub fn is_short(&self) -> bool {
2047 self.side == PositionSide::Short
2048 }
2049
2050 #[must_use]
2052 pub fn is_open(&self) -> bool {
2053 self.side != PositionSide::Flat && self.ts_closed.is_none()
2054 }
2055
2056 #[must_use]
2058 pub fn is_closed(&self) -> bool {
2059 self.side == PositionSide::Flat && self.ts_closed.is_some()
2060 }
2061
2062 #[must_use]
2067 pub fn is_settled(&self) -> bool {
2068 matches!(
2069 self.replay_events.last(),
2070 Some(PositionReplayEvent::InstrumentClosed(_))
2071 )
2072 }
2073
2074 #[must_use]
2080 pub fn requires_replay_state(&self) -> bool {
2081 !self.fill_voids.is_empty()
2083 || self.is_settled()
2084 || (self.replay_events.len() > self.events.len() && self.has_replay_price_restatement())
2085 || (self.id.is_virtual() && self.replay_events.len() > self.events.len())
2086 }
2087
2088 #[must_use]
2093 pub fn signed_decimal_qty(&self) -> Decimal {
2094 Decimal::try_from(self.signed_qty).unwrap_or(Decimal::ZERO)
2095 }
2096
2097 #[must_use]
2099 pub fn commissions(&self) -> Vec<Money> {
2100 self.commissions.values().copied().collect()
2101 }
2102}
2103
2104impl PartialEq<Self> for Position {
2105 fn eq(&self, other: &Self) -> bool {
2106 self.id == other.id
2107 }
2108}
2109
2110impl Eq for Position {}
2111
2112impl Hash for Position {
2113 fn hash<H: Hasher>(&self, state: &mut H) {
2114 self.id.hash(state);
2115 }
2116}
2117
2118impl Display for Position {
2119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2120 let quantity_str = if self.quantity == Quantity::zero(self.size_precision) {
2121 String::new()
2122 } else {
2123 self.quantity.to_formatted_string() + " "
2124 };
2125 write!(
2126 f,
2127 "Position({} {}{}, id={})",
2128 self.side, quantity_str, self.instrument_id, self.id
2129 )
2130 }
2131}
2132
2133#[must_use]
2145pub fn fold_net_position(legs: &[(Decimal, Decimal, u64)]) -> (Decimal, Decimal) {
2146 let mut sorted: Vec<&(Decimal, Decimal, u64)> =
2147 legs.iter().filter(|(qty, _, _)| !qty.is_zero()).collect();
2148 sorted.sort_by_key(|(_, _, ts_opened)| *ts_opened);
2149
2150 let mut net_signed_qty = Decimal::ZERO;
2151 let mut net_avg_px = Decimal::ZERO;
2152
2153 for &(p_qty, p_px, _) in sorted {
2154 if net_signed_qty.is_zero() {
2155 net_signed_qty = p_qty;
2156 net_avg_px = p_px;
2157 continue;
2158 }
2159
2160 let same_side = net_signed_qty.is_sign_negative() == p_qty.is_sign_negative();
2161 let new_net = net_signed_qty + p_qty;
2162
2163 if same_side {
2164 let total_abs = net_signed_qty.abs() + p_qty.abs();
2165 net_avg_px = (net_signed_qty.abs() * net_avg_px + p_qty.abs() * p_px) / total_abs;
2166 net_signed_qty = new_net;
2167 } else if new_net.is_zero()
2168 || new_net.is_sign_negative() == net_signed_qty.is_sign_negative()
2169 {
2170 net_signed_qty = new_net;
2171 if new_net.is_zero() {
2172 net_avg_px = Decimal::ZERO;
2173 }
2174 } else {
2175 net_signed_qty = new_net;
2176 net_avg_px = p_px;
2177 }
2178 }
2179
2180 (net_signed_qty, net_avg_px)
2181}
2182
2183#[cfg(test)]
2184mod tests {
2185 use std::str::FromStr;
2186
2187 use ahash::AHashSet;
2188 use nautilus_core::{DurationNanos, UUID4, UnixNanos, correctness::CorrectnessError};
2189 use proptest::prelude::*;
2190 use rstest::rstest;
2191 use rust_decimal::{Decimal, prelude::ToPrimitive};
2192 use rust_decimal_macros::dec;
2193
2194 #[cfg(feature = "defi")]
2195 use crate::enums::CurrencyType;
2196 use crate::{
2197 data::InstrumentClose,
2198 enums::{InstrumentCloseType, OrderSide, OrderType, PositionAdjustmentType, PositionSide},
2199 events::{
2200 OrderEventAny, OrderFillVoided, OrderFilled, PositionAdjusted, PositionSnapshot,
2201 order::spec::{OrderFillVoidedSpec, OrderFilledSpec},
2202 },
2203 identifiers::{
2204 AccountId, ClientOrderId, InstrumentId, PositionId, StrategyId, TradeId, VenueOrderId,
2205 stubs::uuid4,
2206 },
2207 instruments::{
2208 BinaryOption, CryptoFuture, CryptoOption, CryptoPerpetual, CurrencyPair, Instrument,
2209 InstrumentAny, stubs::*,
2210 },
2211 orders::{Order, builder::OrderTestBuilder, stubs::TestOrderEventStubs},
2212 position::{Position, PositionFillVoid, PositionReplayEvent, fold_net_position},
2213 stubs::*,
2214 types::{Currency, Money, Price, Quantity, money::MONEY_RAW_MAX},
2215 };
2216
2217 #[rstest]
2218 fn test_same_trade_id_new_price_restates_position_average(audusd_sim: CurrencyPair) {
2219 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2220 let position_id = PositionId::from("P-RESTATE");
2221 let trade_id = TradeId::from("T-1");
2222 let opening = OrderFilledSpec::builder()
2223 .instrument_id(instrument.id())
2224 .trade_id(trade_id)
2225 .order_side(OrderSide::Buy)
2226 .last_qty(Quantity::from(2))
2227 .last_px(Price::from("12"))
2228 .position_id(position_id)
2229 .build();
2230 let restatement = OrderFilledSpec::builder()
2231 .instrument_id(instrument.id())
2232 .trade_id(trade_id)
2233 .order_side(OrderSide::Buy)
2234 .last_qty(Quantity::from(2))
2235 .last_px(Price::from("9.47"))
2236 .position_id(position_id)
2237 .event_id(UUID4::new())
2238 .build();
2239 let mut position = Position::new(&instrument, opening);
2240 position.apply(&restatement);
2241
2242 assert_eq!(position.quantity.as_decimal(), Decimal::from(2));
2243 assert_eq!(position.side, PositionSide::Long);
2244 assert_eq!(position.avg_px_open, Price::from("9.47").as_f64());
2245 assert_eq!(position.events[0].last_px, Price::from("12"));
2246
2247 let voided = OrderFillVoidedSpec::builder()
2248 .instrument_id(instrument.id())
2249 .trade_id(trade_id)
2250 .voided_qty(Quantity::from(1))
2251 .last_px(Price::from("12"))
2252 .order_side(OrderSide::Buy)
2253 .position_id(position_id)
2254 .build();
2255 position
2256 .apply_fill_void(voided, Quantity::from(1), None)
2257 .unwrap();
2258
2259 assert_eq!(position.quantity.as_decimal(), Decimal::from(1));
2260 assert_eq!(position.avg_px_open, Price::from("9.47").as_f64());
2261 assert_eq!(position.events[0].last_px, Price::from("12"));
2262 assert!(position.requires_replay_state());
2263 }
2264
2265 #[rstest]
2266 fn test_price_restatement_of_closing_fill_keeps_open_average(audusd_sim: CurrencyPair) {
2267 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2268 let position_id = PositionId::from("P-CLOSE-RESTATE");
2269 let opening = OrderFilledSpec::builder()
2270 .instrument_id(instrument.id())
2271 .trade_id(TradeId::from("T-OPEN"))
2272 .order_side(OrderSide::Buy)
2273 .last_qty(Quantity::from(2))
2274 .last_px(Price::from("12"))
2275 .position_id(position_id)
2276 .build();
2277 let closing = OrderFilledSpec::builder()
2278 .instrument_id(instrument.id())
2279 .trade_id(TradeId::from("T-CLOSE"))
2280 .order_side(OrderSide::Sell)
2281 .last_qty(Quantity::from(1))
2282 .last_px(Price::from("15"))
2283 .position_id(position_id)
2284 .event_id(UUID4::new())
2285 .build();
2286 let restatement = OrderFilledSpec::builder()
2287 .instrument_id(instrument.id())
2288 .trade_id(TradeId::from("T-CLOSE"))
2289 .order_side(OrderSide::Sell)
2290 .last_qty(Quantity::from(1))
2291 .last_px(Price::from("12.75"))
2292 .position_id(position_id)
2293 .event_id(UUID4::new())
2294 .build();
2295 let mut position = Position::new(&instrument, opening);
2296 position.apply(&closing);
2297 position.apply(&restatement);
2298
2299 assert_eq!(position.quantity.as_decimal(), Decimal::from(1));
2300 assert_eq!(position.side, PositionSide::Long);
2301 assert_eq!(position.avg_px_open, Price::from("12").as_f64());
2302 assert_eq!(position.avg_px_close, Some(Price::from("15").as_f64()));
2303 assert_eq!(
2304 position.realized_pnl.map(|pnl| pnl.as_decimal()),
2305 Some(dec!(3))
2306 );
2307 assert_eq!(position.events[1].last_px, Price::from("15"));
2308 }
2309
2310 #[rstest]
2311 fn test_restatement_of_reversal_fill_keeps_flipped_average(audusd_sim: CurrencyPair) {
2312 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2313 let position_id = PositionId::from("P-REVERSAL-RESTATE");
2314 let opening = OrderFilledSpec::builder()
2315 .instrument_id(instrument.id())
2316 .trade_id(TradeId::from("T-OPEN"))
2317 .order_side(OrderSide::Buy)
2318 .last_qty(Quantity::from(2))
2319 .last_px(Price::from("12"))
2320 .position_id(position_id)
2321 .build();
2322 let reversal = OrderFilledSpec::builder()
2323 .instrument_id(instrument.id())
2324 .trade_id(TradeId::from("T-REVERSE"))
2325 .order_side(OrderSide::Sell)
2326 .last_qty(Quantity::from(3))
2327 .last_px(Price::from("10"))
2328 .position_id(position_id)
2329 .event_id(UUID4::new())
2330 .build();
2331 let restatement = OrderFilledSpec::builder()
2332 .instrument_id(instrument.id())
2333 .trade_id(TradeId::from("T-REVERSE"))
2334 .order_side(OrderSide::Sell)
2335 .last_qty(Quantity::from(3))
2336 .last_px(Price::from("9"))
2337 .position_id(position_id)
2338 .event_id(UUID4::new())
2339 .build();
2340 let mut position = Position::new(&instrument, opening);
2341 position.apply(&reversal);
2342 position.apply(&restatement);
2343
2344 assert_eq!(position.side, PositionSide::Short);
2345 assert_eq!(position.quantity.as_decimal(), Decimal::from(1));
2346 assert_eq!(position.avg_px_open, Price::from("10").as_f64());
2347 }
2348
2349 #[rstest]
2350 fn test_restatement_share_excludes_reversal_closing_quantity(audusd_sim: CurrencyPair) {
2351 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2352 let position_id = PositionId::from("P-REVERSAL-SHARE");
2353 let trade_id = TradeId::from("T-RESTATE");
2354
2355 let mut position = Position::new(
2356 &instrument,
2357 OrderFilledSpec::builder()
2358 .instrument_id(instrument.id())
2359 .trade_id(TradeId::from("T-OPEN"))
2360 .order_side(OrderSide::Buy)
2361 .last_qty(Quantity::from(2))
2362 .last_px(Price::from("12"))
2363 .position_id(position_id)
2364 .build(),
2365 );
2366
2367 for (trade, side, qty, px) in [
2368 ("T-REVERSE", OrderSide::Sell, 3, "10"),
2369 (trade_id.as_str(), OrderSide::Sell, 1, "8"),
2370 ("T-CLOSE", OrderSide::Buy, 1, "9"),
2371 ] {
2372 position.apply(
2373 &OrderFilledSpec::builder()
2374 .instrument_id(instrument.id())
2375 .trade_id(TradeId::from(trade))
2376 .order_side(side)
2377 .last_qty(Quantity::from(qty))
2378 .last_px(Price::from(px))
2379 .position_id(position_id)
2380 .event_id(UUID4::new())
2381 .build(),
2382 );
2383 }
2384
2385 assert_eq!(position.side, PositionSide::Short);
2386 assert_eq!(position.quantity.as_decimal(), Decimal::from(1));
2387 assert_eq!(position.avg_px_open, Price::from("9").as_f64());
2388
2389 position.apply(
2390 &OrderFilledSpec::builder()
2391 .instrument_id(instrument.id())
2392 .trade_id(trade_id)
2393 .order_side(OrderSide::Sell)
2394 .last_qty(Quantity::from(1))
2395 .last_px(Price::from("6"))
2396 .position_id(position_id)
2397 .event_id(UUID4::new())
2398 .build(),
2399 );
2400
2401 assert_eq!(position.avg_px_open, Price::from("8").as_f64());
2402 }
2403
2404 #[rstest]
2405 fn test_price_restatement_uses_surviving_quantity(audusd_sim: CurrencyPair) {
2406 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2407 let position_id = PositionId::from("P-SURVIVING");
2408 let opening = OrderFilledSpec::builder()
2409 .instrument_id(instrument.id())
2410 .trade_id(TradeId::from("T-1"))
2411 .order_side(OrderSide::Buy)
2412 .last_qty(Quantity::from(2))
2413 .last_px(Price::from("12"))
2414 .position_id(position_id)
2415 .build();
2416 let later = OrderFilledSpec::builder()
2417 .instrument_id(instrument.id())
2418 .trade_id(TradeId::from("T-2"))
2419 .order_side(OrderSide::Buy)
2420 .last_qty(Quantity::from(1))
2421 .last_px(Price::from("10"))
2422 .position_id(position_id)
2423 .event_id(UUID4::new())
2424 .build();
2425 let restatement = OrderFilledSpec::builder()
2426 .instrument_id(instrument.id())
2427 .trade_id(TradeId::from("T-1"))
2428 .order_side(OrderSide::Buy)
2429 .last_qty(Quantity::from(1))
2430 .last_px(Price::from("9.47"))
2431 .position_id(position_id)
2432 .event_id(UUID4::new())
2433 .build();
2434 let mut position = Position::new(&instrument, opening);
2435 let voided = OrderFillVoidedSpec::builder()
2436 .instrument_id(instrument.id())
2437 .trade_id(TradeId::from("T-1"))
2438 .voided_qty(Quantity::from(1))
2439 .last_px(Price::from("12"))
2440 .order_side(OrderSide::Buy)
2441 .position_id(position_id)
2442 .build();
2443 position
2444 .apply_fill_void(voided, Quantity::from(1), None)
2445 .unwrap();
2446 position.apply(&later);
2447 position.apply(&restatement);
2448
2449 let expected = f64::midpoint(11.0 * 2.0 - 12.0, Price::from("9.47").as_f64());
2450 assert_eq!(position.quantity.as_decimal(), Decimal::from(2));
2451 assert_eq!(position.avg_px_open, expected);
2452 }
2453
2454 #[rstest]
2455 fn test_reapplying_same_price_restatement_is_idempotent(audusd_sim: CurrencyPair) {
2456 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2457 let position_id = PositionId::from("P-IDEMPOTENT");
2458 let trade_id = TradeId::from("T-1");
2459 let opening = OrderFilledSpec::builder()
2460 .instrument_id(instrument.id())
2461 .trade_id(trade_id)
2462 .order_side(OrderSide::Buy)
2463 .last_qty(Quantity::from(2))
2464 .last_px(Price::from("12"))
2465 .position_id(position_id)
2466 .build();
2467 let restatement = OrderFilledSpec::builder()
2468 .instrument_id(instrument.id())
2469 .trade_id(trade_id)
2470 .order_side(OrderSide::Buy)
2471 .last_qty(Quantity::from(2))
2472 .last_px(Price::from("9.47"))
2473 .position_id(position_id)
2474 .event_id(UUID4::new())
2475 .build();
2476 let mut position = Position::new(&instrument, opening);
2477 position.apply(&restatement);
2478 let replay_len = position.replay_events.len();
2479 position.apply(&restatement);
2480
2481 assert_eq!(position.avg_px_open, Price::from("9.47").as_f64());
2482 assert_eq!(position.replay_events.len(), replay_len);
2483 }
2484
2485 #[rstest]
2486 fn test_purge_keeps_price_restatement_on_surviving_order(audusd_sim: CurrencyPair) {
2487 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2488 let position_id = PositionId::from("P-PURGE-RESTATE");
2489 let opening = OrderFilledSpec::builder()
2490 .client_order_id(ClientOrderId::from("O-KEEP"))
2491 .instrument_id(instrument.id())
2492 .trade_id(TradeId::from("T-KEEP"))
2493 .order_side(OrderSide::Buy)
2494 .last_qty(Quantity::from(2))
2495 .last_px(Price::from("12"))
2496 .position_id(position_id)
2497 .build();
2498 let other = OrderFilledSpec::builder()
2499 .client_order_id(ClientOrderId::from("O-DROP"))
2500 .instrument_id(instrument.id())
2501 .trade_id(TradeId::from("T-DROP"))
2502 .order_side(OrderSide::Buy)
2503 .last_qty(Quantity::from(1))
2504 .last_px(Price::from("10"))
2505 .position_id(position_id)
2506 .event_id(UUID4::new())
2507 .build();
2508 let restatement = OrderFilledSpec::builder()
2509 .client_order_id(ClientOrderId::from("O-KEEP"))
2510 .instrument_id(instrument.id())
2511 .trade_id(TradeId::from("T-KEEP"))
2512 .order_side(OrderSide::Buy)
2513 .last_qty(Quantity::from(2))
2514 .last_px(Price::from("9.47"))
2515 .position_id(position_id)
2516 .event_id(UUID4::new())
2517 .build();
2518 let mut position = Position::new(&instrument, opening);
2519 position.apply(&other);
2520 position.apply(&restatement);
2521 position.purge_events_for_order(ClientOrderId::from("O-DROP"));
2522
2523 assert_eq!(position.quantity.as_decimal(), Decimal::from(2));
2524 assert_eq!(position.avg_px_open, Price::from("9.47").as_f64());
2525 }
2526
2527 #[rstest]
2528 fn test_price_restatement_excludes_closed_quantity(audusd_sim: CurrencyPair) {
2529 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2530 let position_id = PositionId::from("P-CLOSED-QTY");
2531 let opening = OrderFilledSpec::builder()
2532 .instrument_id(instrument.id())
2533 .trade_id(TradeId::from("T-1"))
2534 .order_side(OrderSide::Buy)
2535 .last_qty(Quantity::from(2))
2536 .last_px(Price::from("12"))
2537 .position_id(position_id)
2538 .build();
2539 let closing = OrderFilledSpec::builder()
2540 .instrument_id(instrument.id())
2541 .trade_id(TradeId::from("T-CLOSE"))
2542 .order_side(OrderSide::Sell)
2543 .last_qty(Quantity::from(1))
2544 .last_px(Price::from("15"))
2545 .position_id(position_id)
2546 .event_id(UUID4::new())
2547 .build();
2548 let later = OrderFilledSpec::builder()
2549 .instrument_id(instrument.id())
2550 .trade_id(TradeId::from("T-2"))
2551 .order_side(OrderSide::Buy)
2552 .last_qty(Quantity::from(1))
2553 .last_px(Price::from("10"))
2554 .position_id(position_id)
2555 .event_id(UUID4::new())
2556 .build();
2557 let restatement = OrderFilledSpec::builder()
2558 .instrument_id(instrument.id())
2559 .trade_id(TradeId::from("T-1"))
2560 .order_side(OrderSide::Buy)
2561 .last_qty(Quantity::from(2))
2562 .last_px(Price::from("9.47"))
2563 .position_id(position_id)
2564 .event_id(UUID4::new())
2565 .build();
2566 let mut position = Position::new(&instrument, opening);
2567 position.apply(&closing);
2568 position.apply(&later);
2569 position.apply(&restatement);
2570
2571 let expected = f64::midpoint(11.0 * 2.0 - 12.0, Price::from("9.47").as_f64());
2572 assert_eq!(position.quantity.as_decimal(), Decimal::from(2));
2573 assert_eq!(position.avg_px_open, expected);
2574 }
2575
2576 #[rstest]
2577 fn test_price_restatement_after_close_across_two_lots_uses_average_cost(
2578 audusd_sim: CurrencyPair,
2579 ) {
2580 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2581 let position_id = PositionId::from("P-AVG-COST");
2582 let first = OrderFilledSpec::builder()
2583 .instrument_id(instrument.id())
2584 .trade_id(TradeId::from("T-1"))
2585 .order_side(OrderSide::Buy)
2586 .last_qty(Quantity::from(2))
2587 .last_px(Price::from("12"))
2588 .position_id(position_id)
2589 .build();
2590 let second = OrderFilledSpec::builder()
2591 .instrument_id(instrument.id())
2592 .trade_id(TradeId::from("T-2"))
2593 .order_side(OrderSide::Buy)
2594 .last_qty(Quantity::from(2))
2595 .last_px(Price::from("10"))
2596 .position_id(position_id)
2597 .event_id(UUID4::new())
2598 .build();
2599 let closing = OrderFilledSpec::builder()
2600 .instrument_id(instrument.id())
2601 .trade_id(TradeId::from("T-CLOSE"))
2602 .order_side(OrderSide::Sell)
2603 .last_qty(Quantity::from(2))
2604 .last_px(Price::from("15"))
2605 .position_id(position_id)
2606 .event_id(UUID4::new())
2607 .build();
2608 let restatement = OrderFilledSpec::builder()
2609 .instrument_id(instrument.id())
2610 .trade_id(TradeId::from("T-1"))
2611 .order_side(OrderSide::Buy)
2612 .last_qty(Quantity::from(2))
2613 .last_px(Price::from("9.47"))
2614 .position_id(position_id)
2615 .event_id(UUID4::new())
2616 .build();
2617 let mut position = Position::new(&instrument, first);
2618 position.apply(&second);
2619 position.apply(&closing);
2620 position.apply(&restatement);
2621
2622 let expected = f64::midpoint(11.0 * 2.0 - 12.0, Price::from("9.47").as_f64());
2623 assert_eq!(position.quantity.as_decimal(), Decimal::from(2));
2624 assert_eq!(position.avg_px_open, expected);
2625 }
2626
2627 #[rstest]
2628 fn test_purge_applies_restatement_before_later_close(audusd_sim: CurrencyPair) {
2629 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2630 let position_id = PositionId::from("P-PURGE-ORDER");
2631 let opening = OrderFilledSpec::builder()
2632 .client_order_id(ClientOrderId::from("O-KEEP"))
2633 .instrument_id(instrument.id())
2634 .trade_id(TradeId::from("T-KEEP"))
2635 .order_side(OrderSide::Buy)
2636 .last_qty(Quantity::from(2))
2637 .last_px(Price::from("12"))
2638 .position_id(position_id)
2639 .build();
2640 let restatement = OrderFilledSpec::builder()
2641 .client_order_id(ClientOrderId::from("O-KEEP"))
2642 .instrument_id(instrument.id())
2643 .trade_id(TradeId::from("T-KEEP"))
2644 .order_side(OrderSide::Buy)
2645 .last_qty(Quantity::from(2))
2646 .last_px(Price::from("9.47"))
2647 .position_id(position_id)
2648 .event_id(UUID4::new())
2649 .build();
2650 let closing = OrderFilledSpec::builder()
2651 .client_order_id(ClientOrderId::from("O-CLOSE"))
2652 .instrument_id(instrument.id())
2653 .trade_id(TradeId::from("T-CLOSE"))
2654 .order_side(OrderSide::Sell)
2655 .last_qty(Quantity::from(1))
2656 .last_px(Price::from("15"))
2657 .position_id(position_id)
2658 .event_id(UUID4::new())
2659 .build();
2660 let other = OrderFilledSpec::builder()
2661 .client_order_id(ClientOrderId::from("O-DROP"))
2662 .instrument_id(instrument.id())
2663 .trade_id(TradeId::from("T-DROP"))
2664 .order_side(OrderSide::Buy)
2665 .last_qty(Quantity::from(1))
2666 .last_px(Price::from("10"))
2667 .position_id(position_id)
2668 .event_id(UUID4::new())
2669 .build();
2670 let mut position = Position::new(&instrument, opening);
2671 position.apply(&restatement);
2672 position.apply(&closing);
2673 let pnl_before = position.realized_pnl;
2674 let avg_before = position.avg_px_open;
2675 position.apply(&other);
2676 position.purge_events_for_order(ClientOrderId::from("O-DROP"));
2677
2678 assert_eq!(position.realized_pnl, pnl_before);
2679 assert_eq!(position.avg_px_open, avg_before);
2680 assert_eq!(position.quantity.as_decimal(), Decimal::from(1));
2681 }
2682
2683 #[rstest]
2684 fn test_full_void_after_restatement_does_not_reopen(audusd_sim: CurrencyPair) {
2685 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2686 let position_id = PositionId::from("P-VOID-RESTATE");
2687 let trade_id = TradeId::from("T-1");
2688 let opening = OrderFilledSpec::builder()
2689 .instrument_id(instrument.id())
2690 .trade_id(trade_id)
2691 .order_side(OrderSide::Buy)
2692 .last_qty(Quantity::from(2))
2693 .last_px(Price::from("12"))
2694 .position_id(position_id)
2695 .build();
2696 let restatement = OrderFilledSpec::builder()
2697 .instrument_id(instrument.id())
2698 .trade_id(trade_id)
2699 .order_side(OrderSide::Buy)
2700 .last_qty(Quantity::from(2))
2701 .last_px(Price::from("9.47"))
2702 .position_id(position_id)
2703 .event_id(UUID4::new())
2704 .build();
2705 let mut position = Position::new(&instrument, opening);
2706 position.apply(&restatement);
2707 let voided = OrderFillVoidedSpec::builder()
2708 .instrument_id(instrument.id())
2709 .trade_id(trade_id)
2710 .voided_qty(Quantity::from(2))
2711 .last_px(Price::from("12"))
2712 .order_side(OrderSide::Buy)
2713 .position_id(position_id)
2714 .build();
2715 position
2716 .apply_fill_void(voided, Quantity::from(2), None)
2717 .unwrap();
2718
2719 assert_eq!(position.side, PositionSide::Flat);
2720 assert!(position.quantity.is_zero());
2721 }
2722
2723 #[rstest]
2724 fn test_return_to_original_price_then_full_void_stays_flat(audusd_sim: CurrencyPair) {
2725 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2726 let position_id = PositionId::from("P-PRICE-ROUNDTRIP");
2727 let trade_id = TradeId::from("T-1");
2728 let opening = OrderFilledSpec::builder()
2729 .instrument_id(instrument.id())
2730 .trade_id(trade_id)
2731 .order_side(OrderSide::Buy)
2732 .last_qty(Quantity::from(2))
2733 .last_px(Price::from("12"))
2734 .position_id(position_id)
2735 .build();
2736 let restated = OrderFilledSpec::builder()
2737 .instrument_id(instrument.id())
2738 .trade_id(trade_id)
2739 .order_side(OrderSide::Buy)
2740 .last_qty(Quantity::from(2))
2741 .last_px(Price::from("9.47"))
2742 .position_id(position_id)
2743 .event_id(UUID4::new())
2744 .build();
2745 let restored = OrderFilledSpec::builder()
2746 .instrument_id(instrument.id())
2747 .trade_id(trade_id)
2748 .order_side(OrderSide::Buy)
2749 .last_qty(Quantity::from(2))
2750 .last_px(Price::from("12"))
2751 .position_id(position_id)
2752 .event_id(UUID4::new())
2753 .build();
2754 let mut position = Position::new(&instrument, opening);
2755 position.apply(&restated);
2756 position.apply(&restored);
2757 let voided = OrderFillVoidedSpec::builder()
2758 .instrument_id(instrument.id())
2759 .trade_id(trade_id)
2760 .voided_qty(Quantity::from(2))
2761 .last_px(Price::from("12"))
2762 .order_side(OrderSide::Buy)
2763 .position_id(position_id)
2764 .build();
2765 position
2766 .apply_fill_void(voided, Quantity::from(2), None)
2767 .unwrap();
2768
2769 assert_eq!(position.side, PositionSide::Flat);
2770 assert!(position.quantity.is_zero());
2771 }
2772
2773 #[rstest]
2774 fn test_purge_keeps_voided_quantity_on_surviving_order(audusd_sim: CurrencyPair) {
2775 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2776 let position_id = PositionId::from("P-PURGE-VOID");
2777 let opening = OrderFilledSpec::builder()
2778 .client_order_id(ClientOrderId::from("O-KEEP"))
2779 .instrument_id(instrument.id())
2780 .trade_id(TradeId::from("T-KEEP"))
2781 .order_side(OrderSide::Buy)
2782 .last_qty(Quantity::from(2))
2783 .last_px(Price::from("12"))
2784 .position_id(position_id)
2785 .build();
2786 let other = OrderFilledSpec::builder()
2787 .client_order_id(ClientOrderId::from("O-DROP"))
2788 .instrument_id(instrument.id())
2789 .trade_id(TradeId::from("T-DROP"))
2790 .order_side(OrderSide::Buy)
2791 .last_qty(Quantity::from(1))
2792 .last_px(Price::from("10"))
2793 .position_id(position_id)
2794 .event_id(UUID4::new())
2795 .build();
2796 let mut position = Position::new(&instrument, opening);
2797 let voided = OrderFillVoidedSpec::builder()
2798 .client_order_id(ClientOrderId::from("O-KEEP"))
2799 .instrument_id(instrument.id())
2800 .trade_id(TradeId::from("T-KEEP"))
2801 .voided_qty(Quantity::from(1))
2802 .last_px(Price::from("12"))
2803 .order_side(OrderSide::Buy)
2804 .position_id(position_id)
2805 .build();
2806 position
2807 .apply_fill_void(voided, Quantity::from(1), None)
2808 .unwrap();
2809 position.apply(&other);
2810 position.purge_events_for_order(ClientOrderId::from("O-DROP"));
2811
2812 assert_eq!(position.quantity.as_decimal(), Decimal::from(1));
2813 }
2814
2815 #[rstest]
2816 fn test_position_long_display(stub_position_long: Position) {
2817 let display = format!("{stub_position_long}");
2818 assert_eq!(display, "Position(LONG 1 AUD/USD.SIM, id=1)");
2819 }
2820
2821 #[rstest]
2822 fn test_position_short_display(stub_position_short: Position) {
2823 let display = format!("{stub_position_short}");
2824 assert_eq!(display, "Position(SHORT 1 AUD/USD.SIM, id=1)");
2825 }
2826
2827 #[rstest]
2828 #[case::open(false)]
2829 #[case::closed(true)]
2830 fn test_clone_without_events_preserves_current_state(
2831 mut stub_position_long: Position,
2832 #[case] close: bool,
2833 ) {
2834 let adjustment = PositionAdjusted::new(
2835 stub_position_long.trader_id,
2836 stub_position_long.strategy_id,
2837 stub_position_long.instrument_id,
2838 stub_position_long.id,
2839 stub_position_long.account_id,
2840 PositionAdjustmentType::Funding,
2841 None,
2842 Some(Money::from_decimal(dec!(1.25), stub_position_long.settlement_currency).unwrap()),
2843 Some("clone-test".into()),
2844 uuid4(),
2845 UnixNanos::from(2),
2846 UnixNanos::from(2),
2847 );
2848 stub_position_long.apply_adjustment(adjustment);
2849
2850 if close {
2851 let closing_fill = OrderFilledSpec::builder()
2852 .trader_id(stub_position_long.trader_id)
2853 .strategy_id(stub_position_long.strategy_id)
2854 .instrument_id(stub_position_long.instrument_id)
2855 .client_order_id(ClientOrderId::from("CLONE-CLOSE"))
2856 .venue_order_id(VenueOrderId::from("CLONE-CLOSE"))
2857 .account_id(stub_position_long.account_id)
2858 .trade_id(TradeId::from("CLONE-CLOSE"))
2859 .order_side(OrderSide::Sell)
2860 .order_type(OrderType::Market)
2861 .last_qty(stub_position_long.quantity)
2862 .last_px(Price::from("1.0012"))
2863 .currency(stub_position_long.settlement_currency)
2864 .position_id(stub_position_long.id)
2865 .ts_event(UnixNanos::from(3))
2866 .ts_init(UnixNanos::from(3))
2867 .build();
2868 stub_position_long.apply(&closing_fill);
2869 }
2870
2871 let source_fill = stub_position_long.events[0].clone();
2872 let fill_voided = matching_fill_void(&source_fill, source_fill.last_qty, None);
2873 stub_position_long.fill_voids.push(PositionFillVoid {
2874 event: fill_voided,
2875 voided_qty: source_fill.last_qty,
2876 commission_voided: source_fill.commission,
2877 });
2878
2879 let cloned = stub_position_long.clone_without_events();
2880 let mut expected = stub_position_long.clone();
2881 expected.events.clear();
2882 expected.adjustments.clear();
2883 expected.replay_events.clear();
2884 expected.fill_voids.clear();
2885 expected.trade_ids.clear();
2886
2887 assert!(!stub_position_long.events.is_empty());
2888 assert!(!stub_position_long.adjustments.is_empty());
2889 assert!(!stub_position_long.replay_events.is_empty());
2890 assert!(!stub_position_long.fill_voids.is_empty());
2891 assert!(!stub_position_long.trade_ids.is_empty());
2892 assert!(cloned.events.is_empty());
2893 assert!(cloned.adjustments.is_empty());
2894 assert!(cloned.replay_events.is_empty());
2895 assert!(cloned.fill_voids.is_empty());
2896 assert!(cloned.trade_ids.is_empty());
2897 assert_eq!(
2898 serde_json::to_value(cloned).unwrap(),
2899 serde_json::to_value(expected).unwrap()
2900 );
2901 }
2902
2903 #[rstest]
2904 fn test_clone_for_snapshot_matches_clone_then_clear(mut stub_position_long: Position) {
2905 let source_fill = stub_position_long.events[0].clone();
2906 stub_position_long.fill_voids.push(PositionFillVoid {
2907 event: matching_fill_void(&source_fill, source_fill.last_qty, None),
2908 voided_qty: source_fill.last_qty,
2909 commission_voided: source_fill.commission,
2910 });
2911 let mut expected = stub_position_long.clone();
2912 expected.replay_events.clear();
2913 expected.fill_voids.clear();
2914
2915 let snapshot = stub_position_long.clone_for_snapshot();
2916
2917 assert!(snapshot.replay_events.is_empty());
2918 assert!(snapshot.fill_voids.is_empty());
2919 assert_eq!(
2920 serde_json::to_vec(&snapshot).unwrap(),
2921 serde_json::to_vec(&expected).unwrap(),
2922 );
2923 }
2924
2925 #[rstest]
2926 fn test_new_checked_rejects_missing_position_id(audusd_sim: CurrencyPair) {
2927 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2928 let fill = OrderFilledSpec::builder()
2929 .instrument_id(instrument.id())
2930 .build();
2931
2932 let error = Position::new_checked(&instrument, fill).unwrap_err();
2933
2934 assert_eq!(
2935 error,
2936 CorrectnessError::PredicateViolation {
2937 message: "`fill.position_id` was None".to_string(),
2938 }
2939 );
2940 }
2941
2942 #[rstest]
2943 fn test_new_checked_rejects_instrument_mismatch(audusd_sim: CurrencyPair) {
2944 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2945 let fill = OrderFilledSpec::builder()
2946 .instrument_id(InstrumentId::from("GBP/USD.SIM"))
2947 .position_id(PositionId::from("P-1"))
2948 .build();
2949
2950 let error = Position::new_checked(&instrument, fill).unwrap_err();
2951
2952 assert_eq!(
2953 error,
2954 CorrectnessError::EqualityMismatch {
2955 lhs_param: "instrument.id()".to_string(),
2956 rhs_param: "fill.instrument_id".to_string(),
2957 lhs: "AUD/USD.SIM".to_string(),
2958 rhs: "GBP/USD.SIM".to_string(),
2959 type_name: "value",
2960 }
2961 );
2962 }
2963
2964 #[rstest]
2965 #[case::instrument_mismatch(
2966 "GBP/USD.SIM",
2967 Some("P-1"),
2968 "'self.instrument_id' value of AUD/USD.SIM was not equal to 'fill.instrument_id' value of GBP/USD.SIM"
2969 )]
2970 #[case::missing_position_id("AUD/USD.SIM", None, "`fill.position_id` was None")]
2971 #[case::position_mismatch(
2972 "AUD/USD.SIM",
2973 Some("P-2"),
2974 "'self.id' value of P-1 was not equal to 'fill.position_id' value of P-2"
2975 )]
2976 fn test_try_apply_rejects_invalid_fill_identity_without_mutation(
2977 #[case] fill_instrument_id: &str,
2978 #[case] fill_position_id: Option<&str>,
2979 #[case] expected_error: &str,
2980 audusd_sim: CurrencyPair,
2981 ) {
2982 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2983 let position_id = PositionId::from("P-1");
2984 let fill_open = OrderFilledSpec::builder()
2985 .instrument_id(instrument.id())
2986 .trade_id(TradeId::from("T-1"))
2987 .position_id(position_id)
2988 .build();
2989 let mut fill_invalid = OrderFilledSpec::builder()
2990 .instrument_id(InstrumentId::from(fill_instrument_id))
2991 .trade_id(TradeId::from("T-2"))
2992 .build();
2993 fill_invalid.position_id = fill_position_id.map(PositionId::from);
2994 let mut position = Position::new(&instrument, fill_open);
2995 let state_before = serde_json::to_value(&position).unwrap();
2996
2997 let error = position.try_apply(&fill_invalid).unwrap_err();
2998
2999 assert_eq!(error.to_string(), expected_error);
3000 assert_eq!(serde_json::to_value(&position).unwrap(), state_before);
3001 }
3002
3003 #[rstest]
3004 fn test_try_apply_rejects_duplicate_trade_without_mutation(audusd_sim: CurrencyPair) {
3005 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3006 let position_id = PositionId::from("P-1");
3007 let fill_open = OrderFilledSpec::builder()
3008 .instrument_id(instrument.id())
3009 .trade_id(TradeId::from("T-1"))
3010 .position_id(position_id)
3011 .build();
3012 let fill_duplicate = OrderFilledSpec::builder()
3013 .instrument_id(instrument.id())
3014 .client_order_id(ClientOrderId::from("O-2"))
3015 .trade_id(TradeId::from("T-1"))
3016 .position_id(position_id)
3017 .build();
3018 let mut position = Position::new(&instrument, fill_open);
3019 let state_before = serde_json::to_value(&position).unwrap();
3020
3021 let error = position.try_apply(&fill_duplicate).unwrap_err();
3022
3023 assert_eq!(
3024 error,
3025 CorrectnessError::PredicateViolation {
3026 message: "`fill.trade_id` already contained in `trade_ids`".to_string(),
3027 }
3028 );
3029 assert_eq!(serde_json::to_value(&position).unwrap(), state_before);
3030 }
3031
3032 #[cfg(feature = "defi")]
3033 #[rstest]
3034 #[case::price_17("1.00000000000000001", "100000", None, 17)]
3035 #[case::price_18("1.000000000000000001", "100000", None, 18)]
3036 #[case::quantity_18("1.00000", "1.000000000000000001", None, 18)]
3037 #[case::settlement_commission_18("1.00000", "100000", Some(("USD", 18)), 18)]
3038 #[case::base_commission_18("1.00000", "100000", Some(("AUD", 18)), 18)]
3039 fn test_new_checked_rejects_fill_values_above_float_precision(
3040 #[case] last_px: &str,
3041 #[case] last_qty: &str,
3042 #[case] commission: Option<(&str, u8)>,
3043 #[case] expected_precision: u8,
3044 audusd_sim: CurrencyPair,
3045 ) {
3046 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3047 let mut fill = OrderFilledSpec::builder()
3048 .instrument_id(instrument.id())
3049 .last_px(Price::from(last_px))
3050 .last_qty(Quantity::from(last_qty))
3051 .position_id(PositionId::from("P-1"))
3052 .build();
3053 fill.commission = commission.map(|(code, precision)| {
3054 let currency = Currency::new(code, precision, 0, code, CurrencyType::Fiat);
3055 Money::from_decimal(dec!(1), currency).unwrap()
3056 });
3057
3058 let error = Position::new_checked(&instrument, fill).unwrap_err();
3059
3060 assert_eq!(
3061 error.to_string(),
3062 format!(
3063 "Fixed-point precision {expected_precision} exceeds maximum float precision 16"
3064 )
3065 );
3066 }
3067
3068 #[cfg(feature = "defi")]
3069 #[rstest]
3070 #[case::size_precision(18, 0, 2)]
3071 #[case::multiplier(0, 18, 2)]
3072 #[case::settlement_currency(0, 0, 18)]
3073 fn test_new_checked_rejects_instrument_above_float_precision(
3074 #[case] size_precision: u8,
3075 #[case] multiplier_precision: u8,
3076 #[case] quote_precision: u8,
3077 mut audusd_sim: CurrencyPair,
3078 ) {
3079 audusd_sim.size_precision = size_precision;
3080 audusd_sim.multiplier = Quantity::from_decimal_dp(dec!(1), multiplier_precision).unwrap();
3081 audusd_sim.quote_currency =
3082 Currency::new("USD", quote_precision, 840, "US dollar", CurrencyType::Fiat);
3083 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3084 let fill = OrderFilledSpec::builder()
3085 .instrument_id(instrument.id())
3086 .position_id(PositionId::from("P-1"))
3087 .build();
3088
3089 let error = Position::new_checked(&instrument, fill).unwrap_err();
3090
3091 assert_eq!(
3092 error.to_string(),
3093 "Fixed-point precision 18 exceeds maximum float precision 16"
3094 );
3095 }
3096
3097 #[cfg(feature = "defi")]
3098 #[rstest]
3099 fn test_new_checked_accepts_fill_values_at_float_precision(audusd_sim: CurrencyPair) {
3100 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3101 let usd = Currency::new("USD", 16, 840, "US dollar", CurrencyType::Fiat);
3102 let mut fill = OrderFilledSpec::builder()
3103 .instrument_id(instrument.id())
3104 .last_px(Price::from("1.5000000000000000"))
3105 .last_qty(Quantity::from("2.0000000000000000"))
3106 .position_id(PositionId::from("P-1"))
3107 .build();
3108 fill.commission = Some(Money::from_decimal(dec!(0.25), usd).unwrap());
3109
3110 let position = Position::new_checked(&instrument, fill).unwrap();
3111
3112 assert_eq!(position.avg_px_open, 1.5);
3113 assert_eq!(position.signed_qty, 2.0);
3114 assert_eq!(position.quantity, Quantity::from("2.0000000000000000"));
3115 assert_eq!(position.realized_pnl, Some(Money::from("-0.25 USD")));
3116 }
3117
3118 #[cfg(feature = "defi")]
3119 #[rstest]
3120 fn test_new_checked_accepts_unconverted_commission_above_float_precision(
3121 audusd_sim: CurrencyPair,
3122 ) {
3123 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3124 let wei = Currency::new("WEI", 18, 0, "Wei", CurrencyType::Crypto);
3125 let commission = Money::from_decimal(dec!(0.000000000000000001), wei).unwrap();
3126 let mut fill = OrderFilledSpec::builder()
3127 .instrument_id(instrument.id())
3128 .position_id(PositionId::from("P-1"))
3129 .build();
3130 fill.commission = Some(commission);
3131
3132 let position = Position::new_checked(&instrument, fill).unwrap();
3133
3134 assert_eq!(position.commissions(), vec![commission]);
3135 assert_eq!(position.realized_pnl, Some(Money::from("0 USD")));
3136 }
3137
3138 #[cfg(feature = "defi")]
3139 #[rstest]
3140 fn test_try_apply_rejects_fill_above_float_precision_without_mutation(
3141 audusd_sim: CurrencyPair,
3142 ) {
3143 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3144 let position_id = PositionId::from("P-1");
3145 let fill_open = OrderFilledSpec::builder()
3146 .instrument_id(instrument.id())
3147 .trade_id(TradeId::from("T-1"))
3148 .position_id(position_id)
3149 .build();
3150 let fill_invalid = OrderFilledSpec::builder()
3151 .instrument_id(instrument.id())
3152 .trade_id(TradeId::from("T-2"))
3153 .last_px(Price::from("1.000000000000000001"))
3154 .position_id(position_id)
3155 .build();
3156 let mut position = Position::new(&instrument, fill_open);
3157 let state_before = serde_json::to_value(&position).unwrap();
3158
3159 let error = position.try_apply(&fill_invalid).unwrap_err();
3160
3161 assert_eq!(
3162 error.to_string(),
3163 "Fixed-point precision 18 exceeds maximum float precision 16"
3164 );
3165 assert_eq!(serde_json::to_value(&position).unwrap(), state_before);
3166 }
3167
3168 #[cfg(feature = "defi")]
3169 #[rstest]
3170 #[case::unchanged(|_: &mut Position| {}, None)]
3171 #[case::buy_qty(
3172 |position: &mut Position| position.buy_qty = Quantity::from("1.000000000000000001"),
3173 Some(18)
3174 )]
3175 #[case::sell_qty(
3176 |position: &mut Position| position.sell_qty = Quantity::from("1.000000000000000001"),
3177 Some(18)
3178 )]
3179 #[case::event_fill(
3180 |position: &mut Position| position.events[0].last_px = Price::from("1.000000000000000001"),
3181 Some(18)
3182 )]
3183 #[case::replay_fill(
3184 |position: &mut Position| {
3185 let mut fill = position.events[0].clone();
3186 fill.last_qty = Quantity::from("1.00000000000000001");
3187 position.replay_events.push(PositionReplayEvent::Filled(fill));
3188 },
3189 Some(17)
3190 )]
3191 #[case::replay_close(
3192 |position: &mut Position| {
3193 let close = InstrumentClose::new(
3194 position.instrument_id,
3195 Price::from("1.000000000000000001"),
3196 InstrumentCloseType::ContractExpired,
3197 UnixNanos::default(),
3198 UnixNanos::default(),
3199 );
3200 position.replay_events.push(PositionReplayEvent::InstrumentClosed(close));
3201 },
3202 Some(18)
3203 )]
3204 #[case::realized_pnl(
3205 |position: &mut Position| {
3206 let usd = Currency::new("USD", 18, 840, "US dollar", CurrencyType::Fiat);
3207 position.realized_pnl = Some(Money::from_decimal(dec!(1), usd).unwrap());
3208 },
3209 Some(18)
3210 )]
3211 fn test_check_state_float_precision_covers_stored_values(
3212 #[case] mutate: fn(&mut Position),
3213 #[case] expected_error_precision: Option<u8>,
3214 mut stub_position_long: Position,
3215 ) {
3216 mutate(&mut stub_position_long);
3217
3218 let result = stub_position_long.check_state_float_precision();
3219
3220 let expected = expected_error_precision.map_or(Ok(()), |precision| {
3221 Err(CorrectnessError::PredicateViolation {
3222 message: format!(
3223 "Fixed-point precision {precision} exceeds maximum float precision 16"
3224 ),
3225 })
3226 });
3227
3228 assert_eq!(result, expected);
3229 }
3230
3231 #[cfg(feature = "defi")]
3232 #[rstest]
3233 fn test_try_pnl_rejects_values_above_float_precision(stub_position_long: Position) {
3234 let last = Price::from("1.000000000000000001");
3235 let quantity = Quantity::from("1.000000000000000001");
3236 let expected = "Fixed-point precision 18 exceeds maximum float precision 16";
3237
3238 let unrealized = stub_position_long.try_unrealized_pnl(last).unwrap_err();
3239 let total = stub_position_long.try_total_pnl(last).unwrap_err();
3240 let calculated = stub_position_long
3241 .try_calculate_pnl(1.0, 1.0, quantity)
3242 .unwrap_err();
3243
3244 assert_eq!(unrealized.to_string(), expected);
3245 assert_eq!(total.to_string(), expected);
3246 assert_eq!(calculated.to_string(), expected);
3247 }
3248
3249 #[rstest]
3250 #[should_panic(expected = "`fill.trade_id` already contained in `trade_ids`")]
3251 fn test_two_trades_with_same_trade_id_error(audusd_sim: CurrencyPair) {
3252 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3253 let order1 = OrderTestBuilder::new(OrderType::Market)
3254 .client_order_id(ClientOrderId::from("O-1"))
3255 .instrument_id(audusd_sim.id())
3256 .side(OrderSide::Buy)
3257 .quantity(Quantity::from(100_000))
3258 .build();
3259 let order2 = OrderTestBuilder::new(OrderType::Market)
3260 .client_order_id(ClientOrderId::from("O-2"))
3261 .instrument_id(audusd_sim.id())
3262 .side(OrderSide::Buy)
3263 .quantity(Quantity::from(100_000))
3264 .build();
3265 let fill1 = TestOrderEventStubs::filled(
3266 &order1,
3267 &audusd_sim,
3268 Some(TradeId::new("1")),
3269 None,
3270 Some(Price::from("1.00001")),
3271 None,
3272 None,
3273 None,
3274 None,
3275 None,
3276 );
3277 let fill2 = TestOrderEventStubs::filled(
3278 &order2,
3279 &audusd_sim,
3280 Some(TradeId::new("1")),
3281 None,
3282 Some(Price::from("1.00002")),
3283 None,
3284 None,
3285 None,
3286 None,
3287 None,
3288 );
3289 let mut position = Position::new(&audusd_sim, fill1.into());
3290 position.apply(&fill2.into());
3291 }
3292
3293 #[rstest]
3294 #[case(false)]
3295 #[case(true)]
3296 fn test_historical_duplicate_trade_id_does_not_poison_fill_void_replay(
3297 #[case] causal_duplicate: bool,
3298 audusd_sim: CurrencyPair,
3299 ) {
3300 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3301 let position_id = PositionId::from("P-DUP");
3302 let fill_open = OrderFilledSpec::builder()
3303 .instrument_id(instrument.id())
3304 .client_order_id(ClientOrderId::from("O-1"))
3305 .trade_id(TradeId::from("T-1"))
3306 .order_side(OrderSide::Buy)
3307 .last_qty(Quantity::from(10))
3308 .last_px(Price::from("1.00000"))
3309 .currency(Currency::USD())
3310 .position_id(position_id)
3311 .ts_event(UnixNanos::from(1))
3312 .build();
3313 let fill_close = OrderFilledSpec::builder()
3314 .instrument_id(instrument.id())
3315 .client_order_id(ClientOrderId::from("O-2"))
3316 .trade_id(TradeId::from("T-2"))
3317 .order_side(OrderSide::Sell)
3318 .last_qty(Quantity::from(10))
3319 .last_px(Price::from("1.00010"))
3320 .currency(Currency::USD())
3321 .position_id(position_id)
3322 .ts_event(UnixNanos::from(2))
3323 .build();
3324 let mut fill_duplicate = OrderFilledSpec::builder()
3325 .instrument_id(instrument.id())
3326 .client_order_id(ClientOrderId::from("O-1"))
3327 .trade_id(TradeId::from("T-1"))
3328 .order_side(OrderSide::Buy)
3329 .last_qty(Quantity::from(10))
3330 .last_px(Price::from("1.00020"))
3331 .currency(Currency::USD())
3332 .position_id(position_id)
3333 .ts_event(UnixNanos::from(3))
3334 .build();
3335
3336 if causal_duplicate {
3337 fill_duplicate.causation_id = Some(fill_open.event_id);
3338 }
3339 let fill_reopen = OrderFilledSpec::builder()
3340 .instrument_id(instrument.id())
3341 .client_order_id(ClientOrderId::from("O-3"))
3342 .trade_id(TradeId::from("T-3"))
3343 .order_side(OrderSide::Buy)
3344 .last_qty(Quantity::from(5))
3345 .last_px(Price::from("1.00000"))
3346 .currency(Currency::USD())
3347 .position_id(position_id)
3348 .ts_event(UnixNanos::from(4))
3349 .build();
3350 let mut fill_duplicate_open = fill_duplicate.clone();
3351 fill_duplicate_open.event_id = uuid4();
3352 fill_duplicate_open.client_order_id = ClientOrderId::from("O-4");
3353 fill_duplicate_open.ts_event = UnixNanos::from(5);
3354 let fill_voided = matching_fill_void(&fill_close, Quantity::from(10), None);
3355 let mut position = Position::new(&instrument, fill_open.clone());
3356 position.try_apply(&fill_close).unwrap();
3357
3358 position.try_apply(&fill_duplicate).unwrap();
3359
3360 assert_eq!(position.side, PositionSide::Flat);
3361 assert_eq!(position.quantity, Quantity::from(0));
3362 assert_eq!(position.events, vec![fill_open.clone(), fill_close.clone()]);
3363 assert_eq!(position.replay_events.len(), 2);
3364 assert_eq!(position.trade_ids.len(), 2);
3365 assert!(position.trade_ids.contains(&TradeId::from("T-1")));
3366 assert!(position.trade_ids.contains(&TradeId::from("T-2")));
3367
3368 position.try_apply(&fill_reopen).unwrap();
3369 position.try_apply(&fill_duplicate_open).unwrap();
3370
3371 assert_eq!(position.side, PositionSide::Long);
3372 assert_eq!(position.quantity, Quantity::from(5));
3373 assert_eq!(position.opening_order_id, ClientOrderId::from("O-3"));
3374 assert_eq!(position.events, vec![fill_reopen.clone()]);
3375 assert_eq!(position.replay_events.len(), 3);
3376 assert_eq!(position.trade_ids.len(), 1);
3377 assert!(position.trade_ids.contains(&TradeId::from("T-3")));
3378
3379 position
3380 .apply_fill_void(fill_voided, Quantity::from(10), None)
3381 .unwrap();
3382
3383 assert_eq!(position.side, PositionSide::Long);
3384 assert_eq!(position.quantity, Quantity::from(15));
3385 assert_eq!(position.opening_order_id, ClientOrderId::from("O-1"));
3386 assert_eq!(position.closing_order_id, None);
3387 assert_eq!(position.avg_px_open, 1.0);
3388 assert_eq!(position.buy_qty, Quantity::from(15));
3389 assert_eq!(position.sell_qty, Quantity::from(0));
3390 assert_eq!(
3391 position.events,
3392 vec![fill_open.clone(), fill_reopen.clone()]
3393 );
3394 assert_eq!(position.replay_events.len(), 3);
3395 assert_eq!(position.fill_voids.len(), 1);
3396 assert_eq!(position.trade_ids.len(), 2);
3397 assert!(position.trade_ids.contains(&TradeId::from("T-1")));
3398 assert!(position.trade_ids.contains(&TradeId::from("T-3")));
3399
3400 let mut fill_close_duplicate = fill_close;
3401 fill_close_duplicate.event_id = uuid4();
3402 fill_close_duplicate.ts_event = UnixNanos::from(6);
3403 position.try_apply(&fill_close_duplicate).unwrap();
3404
3405 assert_eq!(position.side, PositionSide::Long);
3406 assert_eq!(position.quantity, Quantity::from(15));
3407 assert_eq!(position.events, vec![fill_open, fill_reopen]);
3408 assert_eq!(position.replay_events.len(), 3);
3409 }
3410
3411 #[rstest]
3412 fn test_replay_fill_with_foreign_causation_stays_duplicate(audusd_sim: CurrencyPair) {
3413 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3414 let position_id = PositionId::from("P-CAUSATION");
3415 let fill_open = OrderFilledSpec::builder()
3416 .instrument_id(instrument.id())
3417 .client_order_id(ClientOrderId::from("O-1"))
3418 .trade_id(TradeId::from("T-1"))
3419 .order_side(OrderSide::Buy)
3420 .last_qty(Quantity::from(10))
3421 .last_px(Price::from("1.00000"))
3422 .currency(Currency::USD())
3423 .position_id(position_id)
3424 .ts_event(UnixNanos::from(1))
3425 .build();
3426 let fill_close = OrderFilledSpec::builder()
3427 .instrument_id(instrument.id())
3428 .client_order_id(ClientOrderId::from("O-2"))
3429 .trade_id(TradeId::from("T-2"))
3430 .order_side(OrderSide::Sell)
3431 .last_qty(Quantity::from(10))
3432 .last_px(Price::from("1.00000"))
3433 .currency(Currency::USD())
3434 .position_id(position_id)
3435 .ts_event(UnixNanos::from(2))
3436 .build();
3437 let mut position = Position::new(&instrument, fill_open.clone());
3438 position.apply(&fill_close);
3439
3440 let mut replayed = fill_open.clone();
3441 replayed.event_id = uuid4();
3442 replayed.ts_event = UnixNanos::from(3);
3443 replayed.causation_id = Some(fill_close.event_id);
3444
3445 position.apply(&replayed);
3446
3447 assert_eq!(position.side, PositionSide::Flat);
3448 assert_eq!(position.quantity, Quantity::from(0));
3449 assert_eq!(position.events, vec![fill_open, fill_close.clone()]);
3450 }
3451
3452 #[rstest]
3453 fn test_position_applies_fills_with_negative_prices(audusd_sim: CurrencyPair) {
3454 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3458 let order = OrderTestBuilder::new(OrderType::Market)
3459 .instrument_id(audusd_sim.id())
3460 .side(OrderSide::Buy)
3461 .quantity(Quantity::from(100_000))
3462 .build();
3463 let fill1 = TestOrderEventStubs::filled(
3464 &order,
3465 &audusd_sim,
3466 Some(TradeId::new("1")),
3467 None,
3468 Some(Price::from("-5.00000")),
3469 Some(Quantity::from(50_000)),
3470 None,
3471 None,
3472 None,
3473 None,
3474 );
3475 let fill2 = TestOrderEventStubs::filled(
3476 &order,
3477 &audusd_sim,
3478 Some(TradeId::new("2")),
3479 None,
3480 Some(Price::from("-7.00000")),
3481 Some(Quantity::from(50_000)),
3482 None,
3483 None,
3484 None,
3485 None,
3486 );
3487 let mut position = Position::new(&audusd_sim, fill1.into());
3488 position.apply(&fill2.into());
3489
3490 assert_eq!(position.quantity, Quantity::from(100_000));
3491 assert_eq!(position.signed_qty, 100_000.0);
3492 assert_eq!(position.side, PositionSide::Long);
3493 assert_eq!(position.avg_px_open, -6.0);
3495 }
3496
3497 #[rstest]
3498 fn test_position_filled_with_buy_order(audusd_sim: CurrencyPair) {
3499 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3500 let order = OrderTestBuilder::new(OrderType::Market)
3501 .instrument_id(audusd_sim.id())
3502 .side(OrderSide::Buy)
3503 .quantity(Quantity::from(100_000))
3504 .build();
3505 let fill = TestOrderEventStubs::filled(
3506 &order,
3507 &audusd_sim,
3508 None,
3509 None,
3510 Some(Price::from("1.00001")),
3511 None,
3512 None,
3513 None,
3514 None,
3515 None,
3516 );
3517 let last_price = Price::from_str("1.0005").unwrap();
3518 let position = Position::new(&audusd_sim, fill.into());
3519 assert_eq!(position.symbol(), audusd_sim.id().symbol);
3520 assert_eq!(position.venue(), audusd_sim.id().venue);
3521 assert_eq!(position.closing_order_side(), Some(OrderSide::Sell));
3522 assert!(!position.is_opposite_side(OrderSide::Buy));
3523 assert_eq!(position, position); assert!(position.closing_order_id.is_none());
3525 assert_eq!(position.quantity, Quantity::from(100_000));
3526 assert_eq!(position.peak_qty, Quantity::from(100_000));
3527 assert_eq!(position.size_precision, 0);
3528 assert_eq!(position.signed_qty, 100_000.0);
3529 assert_eq!(position.entry, OrderSide::Buy);
3530 assert_eq!(position.side, PositionSide::Long);
3531 assert_eq!(position.ts_opened.as_u64(), 0);
3532 assert_eq!(position.duration_ns, DurationNanos::default());
3533 assert_eq!(position.avg_px_open, 1.00001);
3534 assert_eq!(position.event_count(), 1);
3535 assert_eq!(position.id, PositionId::new("1"));
3536 assert_eq!(position.events.len(), 1);
3537 assert!(position.is_long());
3538 assert!(!position.is_short());
3539 assert!(position.is_open());
3540 assert!(!position.is_closed());
3541 assert_eq!(position.realized_return, 0.0);
3542 assert_eq!(position.realized_pnl, Some(Money::from("-2.0 USD")));
3543 assert_eq!(position.unrealized_pnl(last_price), Money::from("49.0 USD"));
3544 assert_eq!(position.total_pnl(last_price), Money::from("47.0 USD"));
3545 assert_eq!(position.commissions(), vec![Money::from("2.0 USD")]);
3546 assert_eq!(
3547 format!("{position}"),
3548 "Position(LONG 100_000 AUD/USD.SIM, id=1)"
3549 );
3550 }
3551
3552 #[rstest]
3553 fn test_position_filled_with_sell_order(audusd_sim: CurrencyPair) {
3554 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3555 let order = OrderTestBuilder::new(OrderType::Market)
3556 .instrument_id(audusd_sim.id())
3557 .side(OrderSide::Sell)
3558 .quantity(Quantity::from(100_000))
3559 .build();
3560 let fill = TestOrderEventStubs::filled(
3561 &order,
3562 &audusd_sim,
3563 None,
3564 None,
3565 Some(Price::from("1.00001")),
3566 None,
3567 None,
3568 None,
3569 None,
3570 None,
3571 );
3572 let last_price = Price::from_str("1.00050").unwrap();
3573 let position = Position::new(&audusd_sim, fill.into());
3574 assert_eq!(position.symbol(), audusd_sim.id().symbol);
3575 assert_eq!(position.venue(), audusd_sim.id().venue);
3576 assert_eq!(position.closing_order_side(), Some(OrderSide::Buy));
3577 assert!(!position.is_opposite_side(OrderSide::Sell));
3578 assert_eq!(position, position); assert!(position.closing_order_id.is_none());
3580 assert_eq!(position.quantity, Quantity::from(100_000));
3581 assert_eq!(position.peak_qty, Quantity::from(100_000));
3582 assert_eq!(position.signed_qty, -100_000.0);
3583 assert_eq!(position.entry, OrderSide::Sell);
3584 assert_eq!(position.side, PositionSide::Short);
3585 assert_eq!(position.ts_opened.as_u64(), 0);
3586 assert_eq!(position.avg_px_open, 1.00001);
3587 assert_eq!(position.event_count(), 1);
3588 assert_eq!(position.id, PositionId::new("1"));
3589 assert_eq!(position.events.len(), 1);
3590 assert!(!position.is_long());
3591 assert!(position.is_short());
3592 assert!(position.is_open());
3593 assert!(!position.is_closed());
3594 assert_eq!(position.realized_return, 0.0);
3595 assert_eq!(position.realized_pnl, Some(Money::from("-2.0 USD")));
3596 assert_eq!(
3597 position.unrealized_pnl(last_price),
3598 Money::from("-49.0 USD")
3599 );
3600 assert_eq!(position.total_pnl(last_price), Money::from("-51.0 USD"));
3601 assert_eq!(position.commissions(), vec![Money::from("2.0 USD")]);
3602 assert_eq!(
3603 format!("{position}"),
3604 "Position(SHORT 100_000 AUD/USD.SIM, id=1)"
3605 );
3606 }
3607
3608 #[rstest]
3609 fn test_position_partial_fills_with_buy_order(audusd_sim: CurrencyPair) {
3610 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3611 let order = OrderTestBuilder::new(OrderType::Market)
3612 .instrument_id(audusd_sim.id())
3613 .side(OrderSide::Buy)
3614 .quantity(Quantity::from(100_000))
3615 .build();
3616 let fill = TestOrderEventStubs::filled(
3617 &order,
3618 &audusd_sim,
3619 None,
3620 None,
3621 Some(Price::from("1.00001")),
3622 Some(Quantity::from(50_000)),
3623 None,
3624 None,
3625 None,
3626 None,
3627 );
3628 let last_price = Price::from_str("1.00048").unwrap();
3629 let position = Position::new(&audusd_sim, fill.into());
3630 assert_eq!(position.quantity, Quantity::from(50_000));
3631 assert_eq!(position.peak_qty, Quantity::from(50_000));
3632 assert_eq!(position.side, PositionSide::Long);
3633 assert_eq!(position.signed_qty, 50000.0);
3634 assert_eq!(position.avg_px_open, 1.00001);
3635 assert_eq!(position.event_count(), 1);
3636 assert_eq!(position.ts_opened.as_u64(), 0);
3637 assert!(position.is_long());
3638 assert!(!position.is_short());
3639 assert!(position.is_open());
3640 assert!(!position.is_closed());
3641 assert_eq!(position.realized_return, 0.0);
3642 assert_eq!(position.realized_pnl, Some(Money::from("-2.0 USD")));
3643 assert_eq!(position.unrealized_pnl(last_price), Money::from("23.5 USD"));
3644 assert_eq!(position.total_pnl(last_price), Money::from("21.5 USD"));
3645 assert_eq!(position.commissions(), vec![Money::from("2.0 USD")]);
3646 assert_eq!(
3647 format!("{position}"),
3648 "Position(LONG 50_000 AUD/USD.SIM, id=1)"
3649 );
3650 }
3651
3652 #[rstest]
3653 fn test_position_partial_fills_with_two_sell_orders(audusd_sim: CurrencyPair) {
3654 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3655 let order = OrderTestBuilder::new(OrderType::Market)
3656 .instrument_id(audusd_sim.id())
3657 .side(OrderSide::Sell)
3658 .quantity(Quantity::from(100_000))
3659 .build();
3660 let fill1 = TestOrderEventStubs::filled(
3661 &order,
3662 &audusd_sim,
3663 Some(TradeId::new("1")),
3664 None,
3665 Some(Price::from("1.00001")),
3666 Some(Quantity::from(50_000)),
3667 None,
3668 None,
3669 None,
3670 None,
3671 );
3672 let fill2 = TestOrderEventStubs::filled(
3673 &order,
3674 &audusd_sim,
3675 Some(TradeId::new("2")),
3676 None,
3677 Some(Price::from("1.00002")),
3678 Some(Quantity::from(50_000)),
3679 None,
3680 None,
3681 None,
3682 None,
3683 );
3684 let last_price = Price::from_str("1.0005").unwrap();
3685 let mut position = Position::new(&audusd_sim, fill1.into());
3686 position.apply(&fill2.into());
3687
3688 assert_eq!(position.quantity, Quantity::from(100_000));
3689 assert_eq!(position.peak_qty, Quantity::from(100_000));
3690 assert_eq!(position.side, PositionSide::Short);
3691 assert_eq!(position.signed_qty, -100_000.0);
3692 assert_eq!(position.avg_px_open, 1.000_015);
3693 assert_eq!(position.event_count(), 2);
3694 assert_eq!(position.ts_opened, 0);
3695 assert!(position.is_short());
3696 assert!(!position.is_long());
3697 assert!(position.is_open());
3698 assert!(!position.is_closed());
3699 assert_eq!(position.realized_return, 0.0);
3700 assert_eq!(position.realized_pnl, Some(Money::from("-4.0 USD")));
3701 assert_eq!(
3702 position.unrealized_pnl(last_price),
3703 Money::from("-48.5 USD")
3704 );
3705 assert_eq!(position.total_pnl(last_price), Money::from("-52.5 USD"));
3706 assert_eq!(position.commissions(), vec![Money::from("4.0 USD")]);
3707 }
3708
3709 #[rstest]
3710 pub fn test_position_filled_with_buy_order_then_sell_order(audusd_sim: CurrencyPair) {
3711 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3712 let order = OrderTestBuilder::new(OrderType::Market)
3713 .instrument_id(audusd_sim.id())
3714 .side(OrderSide::Buy)
3715 .quantity(Quantity::from(150_000))
3716 .build();
3717 let fill = TestOrderEventStubs::filled(
3718 &order,
3719 &audusd_sim,
3720 Some(TradeId::new("1")),
3721 Some(PositionId::new("P-1")),
3722 Some(Price::from("1.00001")),
3723 None,
3724 None,
3725 None,
3726 Some(UnixNanos::from(1_000_000_000)),
3727 None,
3728 );
3729 let mut position = Position::new(&audusd_sim, fill.into());
3730
3731 let fill2 = OrderFilledSpec::builder()
3732 .trader_id(order.trader_id())
3733 .strategy_id(StrategyId::new("S-001"))
3734 .instrument_id(order.instrument_id())
3735 .client_order_id(order.client_order_id())
3736 .venue_order_id(VenueOrderId::from("2"))
3737 .account_id(order.account_id().unwrap_or(AccountId::new("SIM-001")))
3738 .trade_id(TradeId::new("2"))
3739 .order_side(OrderSide::Sell)
3740 .last_qty(order.quantity())
3741 .last_px(Price::from("1.00011"))
3742 .currency(audusd_sim.quote_currency())
3743 .ts_event(2_000_000_000.into())
3744 .position_id(PositionId::new("T1"))
3745 .commission(Money::from("0.0 USD"))
3746 .build();
3747 position.apply(&fill2);
3748 let last = Price::from_str("1.0005").unwrap();
3749
3750 assert!(position.is_opposite_side(fill2.order_side));
3751 assert_eq!(
3752 position.quantity,
3753 Quantity::zero(audusd_sim.price_precision())
3754 );
3755 assert_eq!(position.size_precision, 0);
3756 assert_eq!(position.signed_qty, 0.0);
3757 assert_eq!(position.side, PositionSide::Flat);
3758 assert_eq!(position.ts_opened, 1_000_000_000);
3759 assert_eq!(position.ts_closed, Some(UnixNanos::from(2_000_000_000)));
3760 assert_eq!(position.duration_ns, DurationNanos::from_secs(1));
3761 assert_eq!(position.avg_px_open, 1.00001);
3762 assert_eq!(position.avg_px_close, Some(1.00011));
3763 assert!(!position.is_long());
3764 assert!(!position.is_short());
3765 assert!(!position.is_open());
3766 assert!(position.is_closed());
3767 assert_eq!(position.realized_return, 9.999_900_000_998_888e-5);
3768 assert_eq!(position.realized_pnl, Some(Money::from("13.0 USD")));
3769 assert_eq!(position.unrealized_pnl(last), Money::from("0 USD"));
3770 assert_eq!(position.commissions(), vec![Money::from("2 USD")]);
3771 assert_eq!(position.total_pnl(last), Money::from("13 USD"));
3772 assert_eq!(format!("{position}"), "Position(FLAT AUD/USD.SIM, id=P-1)");
3773 }
3774
3775 #[rstest]
3776 pub fn test_position_filled_with_sell_order_then_buy_order(audusd_sim: CurrencyPair) {
3777 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3778 let order1 = OrderTestBuilder::new(OrderType::Market)
3779 .instrument_id(audusd_sim.id())
3780 .side(OrderSide::Sell)
3781 .quantity(Quantity::from(100_000))
3782 .build();
3783 let order2 = OrderTestBuilder::new(OrderType::Market)
3784 .instrument_id(audusd_sim.id())
3785 .side(OrderSide::Buy)
3786 .quantity(Quantity::from(100_000))
3787 .build();
3788 let fill1 = TestOrderEventStubs::filled(
3789 &order1,
3790 &audusd_sim,
3791 None,
3792 Some(PositionId::new("P-19700101-000000-001-001-1")),
3793 Some(Price::from("1.0")),
3794 None,
3795 None,
3796 None,
3797 None,
3798 None,
3799 );
3800 let mut position = Position::new(&audusd_sim, fill1.into());
3801 let fill2 = TestOrderEventStubs::filled(
3803 &order2,
3804 &audusd_sim,
3805 Some(TradeId::new("1")),
3806 Some(PositionId::new("P-19700101-000000-001-001-1")),
3807 Some(Price::from("1.00001")),
3808 Some(Quantity::from(50_000)),
3809 None,
3810 None,
3811 None,
3812 None,
3813 );
3814 let fill3 = TestOrderEventStubs::filled(
3815 &order2,
3816 &audusd_sim,
3817 Some(TradeId::new("2")),
3818 Some(PositionId::new("P-19700101-000000-001-001-1")),
3819 Some(Price::from("1.00003")),
3820 Some(Quantity::from(50_000)),
3821 None,
3822 None,
3823 None,
3824 None,
3825 );
3826 let last = Price::from("1.0005");
3827 position.apply(&fill2.into());
3828 position.apply(&fill3.into());
3829
3830 assert_eq!(
3831 position.quantity,
3832 Quantity::zero(audusd_sim.price_precision())
3833 );
3834 assert_eq!(position.side, PositionSide::Flat);
3835 assert_eq!(position.ts_opened, 0);
3836 assert_eq!(position.avg_px_open, 1.0);
3837 assert_eq!(position.events.len(), 3);
3838 assert_eq!(position.ts_closed, Some(UnixNanos::default()));
3839 assert_eq!(position.avg_px_close, Some(1.00002));
3840 assert!(!position.is_long());
3841 assert!(!position.is_short());
3842 assert!(!position.is_open());
3843 assert!(position.is_closed());
3844 assert_eq!(position.commissions(), vec![Money::from("6.0 USD")]);
3845 assert_eq!(position.unrealized_pnl(last), Money::from("0 USD"));
3846 assert_eq!(position.realized_pnl, Some(Money::from("-8.0 USD")));
3847 assert_eq!(position.total_pnl(last), Money::from("-8.0 USD"));
3848 assert_eq!(
3849 format!("{position}"),
3850 "Position(FLAT AUD/USD.SIM, id=P-19700101-000000-001-001-1)"
3851 );
3852 }
3853
3854 #[rstest]
3855 fn test_position_filled_with_no_change(audusd_sim: CurrencyPair) {
3856 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3857 let order1 = OrderTestBuilder::new(OrderType::Market)
3858 .instrument_id(audusd_sim.id())
3859 .side(OrderSide::Buy)
3860 .quantity(Quantity::from(100_000))
3861 .build();
3862 let order2 = OrderTestBuilder::new(OrderType::Market)
3863 .instrument_id(audusd_sim.id())
3864 .side(OrderSide::Sell)
3865 .quantity(Quantity::from(100_000))
3866 .build();
3867 let fill1 = TestOrderEventStubs::filled(
3868 &order1,
3869 &audusd_sim,
3870 Some(TradeId::new("1")),
3871 Some(PositionId::new("P-19700101-000000-001-001-1")),
3872 Some(Price::from("1.0")),
3873 None,
3874 None,
3875 None,
3876 None,
3877 None,
3878 );
3879 let mut position = Position::new(&audusd_sim, fill1.into());
3880 let fill2 = TestOrderEventStubs::filled(
3881 &order2,
3882 &audusd_sim,
3883 Some(TradeId::new("2")),
3884 Some(PositionId::new("P-19700101-000000-001-001-1")),
3885 Some(Price::from("1.0")),
3886 None,
3887 None,
3888 None,
3889 None,
3890 None,
3891 );
3892 let last = Price::from("1.0005");
3893 position.apply(&fill2.into());
3894
3895 assert_eq!(
3896 position.quantity,
3897 Quantity::zero(audusd_sim.price_precision())
3898 );
3899 assert_eq!(position.closing_order_side(), None);
3900 assert_eq!(position.side, PositionSide::Flat);
3901 assert_eq!(position.ts_opened, 0);
3902 assert_eq!(position.avg_px_open, 1.0);
3903 assert_eq!(position.events.len(), 2);
3904 assert_eq!(position.ts_closed, Some(UnixNanos::default()));
3906 assert_eq!(position.avg_px_close, Some(1.0));
3907 assert!(!position.is_long());
3908 assert!(!position.is_short());
3909 assert!(!position.is_open());
3910 assert!(position.is_closed());
3911 assert_eq!(position.commissions(), vec![Money::from("4.0 USD")]);
3912 assert_eq!(position.unrealized_pnl(last), Money::from("0 USD"));
3913 assert_eq!(position.realized_pnl, Some(Money::from("-4.0 USD")));
3914 assert_eq!(position.total_pnl(last), Money::from("-4.0 USD"));
3915 assert_eq!(
3916 format!("{position}"),
3917 "Position(FLAT AUD/USD.SIM, id=P-19700101-000000-001-001-1)"
3918 );
3919 }
3920
3921 #[rstest]
3922 fn test_position_long_with_multiple_filled_orders(audusd_sim: CurrencyPair) {
3923 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3924 let order1 = OrderTestBuilder::new(OrderType::Market)
3925 .instrument_id(audusd_sim.id())
3926 .side(OrderSide::Buy)
3927 .quantity(Quantity::from(100_000))
3928 .build();
3929 let order2 = OrderTestBuilder::new(OrderType::Market)
3930 .instrument_id(audusd_sim.id())
3931 .side(OrderSide::Buy)
3932 .quantity(Quantity::from(100_000))
3933 .build();
3934 let order3 = OrderTestBuilder::new(OrderType::Market)
3935 .instrument_id(audusd_sim.id())
3936 .side(OrderSide::Sell)
3937 .quantity(Quantity::from(200_000))
3938 .build();
3939 let fill1 = TestOrderEventStubs::filled(
3940 &order1,
3941 &audusd_sim,
3942 Some(TradeId::new("1")),
3943 Some(PositionId::new("P-123456")),
3944 Some(Price::from("1.0")),
3945 None,
3946 None,
3947 None,
3948 None,
3949 None,
3950 );
3951 let fill2 = TestOrderEventStubs::filled(
3952 &order2,
3953 &audusd_sim,
3954 Some(TradeId::new("2")),
3955 Some(PositionId::new("P-123456")),
3956 Some(Price::from("1.00001")),
3957 None,
3958 None,
3959 None,
3960 None,
3961 None,
3962 );
3963 let fill3 = TestOrderEventStubs::filled(
3964 &order3,
3965 &audusd_sim,
3966 Some(TradeId::new("3")),
3967 Some(PositionId::new("P-123456")),
3968 Some(Price::from("1.0001")),
3969 None,
3970 None,
3971 None,
3972 None,
3973 None,
3974 );
3975 let mut position = Position::new(&audusd_sim, fill1.into());
3976 let last = Price::from("1.0005");
3977 position.apply(&fill2.into());
3978 position.apply(&fill3.into());
3979
3980 assert_eq!(
3981 position.quantity,
3982 Quantity::zero(audusd_sim.price_precision())
3983 );
3984 assert_eq!(position.side, PositionSide::Flat);
3985 assert_eq!(position.ts_opened, 0);
3986 assert_eq!(position.avg_px_open, 1.000_005);
3987 assert_eq!(position.events.len(), 3);
3988 assert_eq!(position.ts_closed, Some(UnixNanos::default()));
3993 assert_eq!(position.avg_px_close, Some(1.0001));
3994 assert!(position.is_closed());
3995 assert!(!position.is_open());
3996 assert!(!position.is_long());
3997 assert!(!position.is_short());
3998 assert_eq!(position.commissions(), vec![Money::from("6.0 USD")]);
3999 assert_eq!(position.realized_pnl, Some(Money::from("13.0 USD")));
4000 assert_eq!(position.unrealized_pnl(last), Money::from("0 USD"));
4001 assert_eq!(position.total_pnl(last), Money::from("13 USD"));
4002 assert_eq!(
4003 format!("{position}"),
4004 "Position(FLAT AUD/USD.SIM, id=P-123456)"
4005 );
4006 }
4007
4008 #[rstest]
4009 fn test_pnl_calculation_from_trading_technologies_example(currency_pair_ethusdt: CurrencyPair) {
4010 let ethusdt = InstrumentAny::CurrencyPair(currency_pair_ethusdt);
4011 let quantity1 = Quantity::from(12);
4012 let price1 = Price::from("100.0");
4013 let order1 = OrderTestBuilder::new(OrderType::Market)
4014 .instrument_id(ethusdt.id())
4015 .side(OrderSide::Buy)
4016 .quantity(quantity1)
4017 .build();
4018 let commission1 =
4019 calculate_commission(ðusdt, order1.quantity(), price1, None, dec!(0.0001));
4020 let fill1 = TestOrderEventStubs::filled(
4021 &order1,
4022 ðusdt,
4023 Some(TradeId::new("1")),
4024 Some(PositionId::new("P-123456")),
4025 Some(price1),
4026 None,
4027 None,
4028 Some(commission1),
4029 None,
4030 None,
4031 );
4032 let mut position = Position::new(ðusdt, fill1.into());
4033 let quantity2 = Quantity::from(17);
4034 let order2 = OrderTestBuilder::new(OrderType::Market)
4035 .instrument_id(ethusdt.id())
4036 .side(OrderSide::Buy)
4037 .quantity(quantity2)
4038 .build();
4039 let price2 = Price::from("99.0");
4040 let commission2 =
4041 calculate_commission(ðusdt, order2.quantity(), price2, None, dec!(0.0001));
4042 let fill2 = TestOrderEventStubs::filled(
4043 &order2,
4044 ðusdt,
4045 Some(TradeId::new("2")),
4046 Some(PositionId::new("P-123456")),
4047 Some(price2),
4048 None,
4049 None,
4050 Some(commission2),
4051 None,
4052 None,
4053 );
4054 position.apply(&fill2.into());
4055 assert_eq!(position.quantity, Quantity::from(29));
4056 assert_eq!(position.realized_pnl, Some(Money::from("-0.28830000 USDT")));
4057 assert_eq!(position.avg_px_open, 99.413_793_103_448_27);
4058 let quantity3 = Quantity::from(9);
4059 let order3 = OrderTestBuilder::new(OrderType::Market)
4060 .instrument_id(ethusdt.id())
4061 .side(OrderSide::Sell)
4062 .quantity(quantity3)
4063 .build();
4064 let price3 = Price::from("101.0");
4065 let commission3 =
4066 calculate_commission(ðusdt, order3.quantity(), price3, None, dec!(0.0001));
4067 let fill3 = TestOrderEventStubs::filled(
4068 &order3,
4069 ðusdt,
4070 Some(TradeId::new("3")),
4071 Some(PositionId::new("P-123456")),
4072 Some(price3),
4073 None,
4074 None,
4075 Some(commission3),
4076 None,
4077 None,
4078 );
4079 position.apply(&fill3.into());
4080 assert_eq!(position.quantity, Quantity::from(20));
4081 assert_eq!(position.realized_pnl, Some(Money::from("13.89666207 USDT")));
4082 assert_eq!(position.avg_px_open, 99.413_793_103_448_27);
4083 let quantity4 = Quantity::from("4");
4084 let price4 = Price::from("105.0");
4085 let order4 = OrderTestBuilder::new(OrderType::Market)
4086 .instrument_id(ethusdt.id())
4087 .side(OrderSide::Sell)
4088 .quantity(quantity4)
4089 .build();
4090 let commission4 =
4091 calculate_commission(ðusdt, order4.quantity(), price4, None, dec!(0.0001));
4092 let fill4 = TestOrderEventStubs::filled(
4093 &order4,
4094 ðusdt,
4095 Some(TradeId::new("4")),
4096 Some(PositionId::new("P-123456")),
4097 Some(price4),
4098 None,
4099 None,
4100 Some(commission4),
4101 None,
4102 None,
4103 );
4104 position.apply(&fill4.into());
4105 assert_eq!(position.quantity, Quantity::from("16"));
4106 assert_eq!(position.realized_pnl, Some(Money::from("36.19948966 USDT")));
4107 assert_eq!(position.avg_px_open, 99.413_793_103_448_27);
4108 let quantity5 = Quantity::from("3");
4109 let price5 = Price::from("103.0");
4110 let order5 = OrderTestBuilder::new(OrderType::Market)
4111 .instrument_id(ethusdt.id())
4112 .side(OrderSide::Buy)
4113 .quantity(quantity5)
4114 .build();
4115 let commission5 =
4116 calculate_commission(ðusdt, order5.quantity(), price5, None, dec!(0.0001));
4117 let fill5 = TestOrderEventStubs::filled(
4118 &order5,
4119 ðusdt,
4120 Some(TradeId::new("5")),
4121 Some(PositionId::new("P-123456")),
4122 Some(price5),
4123 None,
4124 None,
4125 Some(commission5),
4126 None,
4127 None,
4128 );
4129 position.apply(&fill5.into());
4130 assert_eq!(position.quantity, Quantity::from("19"));
4131 assert_eq!(position.realized_pnl, Some(Money::from("36.16858966 USDT")));
4132 assert_eq!(position.avg_px_open, 99.980_036_297_640_65);
4133 assert_eq!(
4134 format!("{position}"),
4135 "Position(LONG 19.00000 ETHUSDT.BINANCE, id=P-123456)"
4136 );
4137 }
4138
4139 #[rstest]
4140 fn test_position_closed_and_reopened(audusd_sim: CurrencyPair) {
4141 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
4142 let quantity1 = Quantity::from(150_000);
4143 let price1 = Price::from("1.00001");
4144 let order = OrderTestBuilder::new(OrderType::Market)
4145 .instrument_id(audusd_sim.id())
4146 .side(OrderSide::Buy)
4147 .quantity(quantity1)
4148 .build();
4149 let commission1 = calculate_commission(&audusd_sim, quantity1, price1, None, dec!(0.00002));
4150 let fill1 = TestOrderEventStubs::filled(
4151 &order,
4152 &audusd_sim,
4153 Some(TradeId::new("5")),
4154 Some(PositionId::new("P-123456")),
4155 Some(Price::from("1.00001")),
4156 None,
4157 None,
4158 Some(commission1),
4159 Some(UnixNanos::from(1_000_000_000)),
4160 None,
4161 );
4162 let mut position = Position::new(&audusd_sim, fill1.into());
4163
4164 let fill2 = OrderFilledSpec::builder()
4165 .trader_id(order.trader_id())
4166 .strategy_id(order.strategy_id())
4167 .instrument_id(order.instrument_id())
4168 .client_order_id(order.client_order_id())
4169 .venue_order_id(VenueOrderId::from("2"))
4170 .account_id(order.account_id().unwrap_or(AccountId::new("SIM-001")))
4171 .trade_id(TradeId::from("2"))
4172 .order_side(OrderSide::Sell)
4173 .last_qty(order.quantity())
4174 .last_px(Price::from("1.00011"))
4175 .currency(audusd_sim.quote_currency())
4176 .ts_event(UnixNanos::from(2_000_000_000))
4177 .position_id(PositionId::from("P-123456"))
4178 .commission(Money::from("0 USD"))
4179 .build();
4180
4181 position.apply(&fill2);
4182
4183 let fill3 = OrderFilledSpec::builder()
4184 .trader_id(order.trader_id())
4185 .strategy_id(order.strategy_id())
4186 .instrument_id(order.instrument_id())
4187 .client_order_id(order.client_order_id())
4188 .venue_order_id(VenueOrderId::from("2"))
4189 .account_id(order.account_id().unwrap_or(AccountId::new("SIM-001")))
4190 .trade_id(TradeId::from("3"))
4191 .last_qty(order.quantity())
4192 .last_px(Price::from("1.00012"))
4193 .currency(audusd_sim.quote_currency())
4194 .ts_event(UnixNanos::from(3_000_000_000))
4195 .position_id(PositionId::from("P-123456"))
4196 .commission(Money::from("0 USD"))
4197 .build();
4198
4199 position.apply(&fill3);
4200
4201 let last = Price::from("1.0003");
4202 assert!(position.is_opposite_side(fill2.order_side));
4203 assert_eq!(position.quantity, Quantity::from(150_000));
4204 assert_eq!(position.peak_qty, Quantity::from(150_000));
4205 assert_eq!(position.side, PositionSide::Long);
4206 assert_eq!(position.opening_order_id, fill3.client_order_id);
4207 assert_eq!(position.closing_order_id, None);
4208 assert_eq!(position.ts_opened, 3_000_000_000);
4209 assert_eq!(position.duration_ns, DurationNanos::default());
4210 assert_eq!(position.avg_px_open, 1.00012);
4211 assert_eq!(position.event_count(), 1);
4212 assert_eq!(position.ts_closed, None);
4213 assert_eq!(position.avg_px_close, None);
4214 assert!(position.is_long());
4215 assert!(!position.is_short());
4216 assert!(position.is_open());
4217 assert!(!position.is_closed());
4218 assert_eq!(position.realized_return, 0.0);
4219 assert_eq!(position.realized_pnl, Some(Money::from("0 USD")));
4220 assert_eq!(position.unrealized_pnl(last), Money::from("27 USD"));
4221 assert_eq!(position.total_pnl(last), Money::from("27 USD"));
4222 assert_eq!(position.commissions(), vec![Money::from("0 USD")]);
4223 assert_eq!(
4224 format!("{position}"),
4225 "Position(LONG 150_000 AUD/USD.SIM, id=P-123456)"
4226 );
4227 }
4228
4229 #[rstest]
4230 #[case::zero(Quantity::from(0))]
4231 #[case::exceeds_fragments(Quantity::from(11))]
4232 fn test_apply_fill_void_rejects_invalid_allocation_without_mutation(
4233 #[case] voided_qty: Quantity,
4234 audusd_sim: CurrencyPair,
4235 ) {
4236 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4237 let position_id = PositionId::from("P-VOID-INVALID");
4238 let fill = OrderFilledSpec::builder()
4239 .instrument_id(instrument.id())
4240 .client_order_id(ClientOrderId::from("O-VOID-INVALID"))
4241 .trade_id(TradeId::from("T-VOID-INVALID"))
4242 .order_side(OrderSide::Buy)
4243 .last_qty(Quantity::from(10))
4244 .last_px(Price::from("1.00000"))
4245 .currency(Currency::USD())
4246 .position_id(position_id)
4247 .build();
4248 let fill_voided = matching_fill_void(&fill, voided_qty, None);
4249 let mut position = Position::new(&instrument, fill);
4250 let state_before = serde_json::to_value(&position).unwrap();
4251
4252 let error = position
4253 .apply_fill_void(fill_voided, voided_qty, None)
4254 .unwrap_err();
4255
4256 assert_eq!(
4257 error.to_string(),
4258 "position fill void exceeds known fragments for T-VOID-INVALID"
4259 );
4260 assert_eq!(serde_json::to_value(&position).unwrap(), state_before);
4261 }
4262
4263 #[rstest]
4264 #[case::stale(
4265 Quantity::from(4),
4266 Money::from("0.40 USD"),
4267 "stale position fill void for T-VOID-CUMULATIVE"
4268 )]
4269 #[case::duplicate(
4270 Quantity::from(5),
4271 Money::from("0.50 USD"),
4272 "duplicate position fill void for T-VOID-CUMULATIVE"
4273 )]
4274 fn test_apply_fill_void_rejects_invalid_cumulative_update_without_mutation(
4275 #[case] voided_qty: Quantity,
4276 #[case] commission_voided: Money,
4277 #[case] expected_error: &str,
4278 audusd_sim: CurrencyPair,
4279 ) {
4280 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4281 let position_id = PositionId::from("P-VOID-CUMULATIVE");
4282 let fill = OrderFilledSpec::builder()
4283 .instrument_id(instrument.id())
4284 .client_order_id(ClientOrderId::from("O-VOID-CUMULATIVE"))
4285 .trade_id(TradeId::from("T-VOID-CUMULATIVE"))
4286 .order_side(OrderSide::Buy)
4287 .last_qty(Quantity::from(10))
4288 .last_px(Price::from("1.00000"))
4289 .currency(Currency::USD())
4290 .position_id(position_id)
4291 .commission(Money::from("1.00 USD"))
4292 .build();
4293 let fill_voided =
4294 matching_fill_void(&fill, Quantity::from(5), Some(Money::from("0.50 USD")));
4295 let mut position = Position::new(&instrument, fill);
4296 position
4297 .apply_fill_void(
4298 fill_voided.clone(),
4299 Quantity::from(5),
4300 Some(Money::from("0.50 USD")),
4301 )
4302 .unwrap();
4303 let state_before = serde_json::to_value(&position).unwrap();
4304
4305 let error = position
4306 .apply_fill_void(fill_voided, voided_qty, Some(commission_voided))
4307 .unwrap_err();
4308
4309 assert_eq!(error.to_string(), expected_error);
4310 assert_eq!(serde_json::to_value(&position).unwrap(), state_before);
4311 }
4312
4313 #[rstest]
4314 fn test_apply_fill_void_rejects_currency_mismatch_without_mutation(audusd_sim: CurrencyPair) {
4315 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4316 let fill = OrderFilledSpec::builder()
4317 .instrument_id(instrument.id())
4318 .client_order_id(ClientOrderId::from("O-VOID-CURRENCY"))
4319 .trade_id(TradeId::from("T-VOID-CURRENCY"))
4320 .order_side(OrderSide::Buy)
4321 .last_qty(Quantity::from(10))
4322 .last_px(Price::from("1.00000"))
4323 .currency(Currency::USD())
4324 .position_id(PositionId::from("P-VOID-CURRENCY"))
4325 .commission(Money::from("1.00 USD"))
4326 .build();
4327 let commission_voided = Some(Money::from("0.50 EUR"));
4328 let voided_qty = Quantity::from(5);
4329 let fill_voided = matching_fill_void(&fill, voided_qty, commission_voided);
4330
4331 let mut position = Position::new(&instrument, fill);
4332 let before = serde_json::to_value(&position).unwrap();
4333 let error = position
4334 .apply_fill_void(fill_voided, voided_qty, commission_voided)
4335 .unwrap_err();
4336 assert_eq!(
4337 error.to_string(),
4338 "position commission currency differs for fill T-VOID-CURRENCY"
4339 );
4340 assert_eq!(serde_json::to_value(&position).unwrap(), before);
4341 }
4342
4343 #[rstest]
4344 fn test_apply_fill_void_uses_latest_cumulative_commission(audusd_sim: CurrencyPair) {
4345 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4346 let position_id = PositionId::from("P-VOID-LATEST");
4347 let fill = OrderFilledSpec::builder()
4348 .instrument_id(instrument.id())
4349 .client_order_id(ClientOrderId::from("O-VOID-LATEST"))
4350 .trade_id(TradeId::from("T-VOID-LATEST"))
4351 .order_side(OrderSide::Buy)
4352 .last_qty(Quantity::from(10))
4353 .last_px(Price::from("1.00000"))
4354 .currency(Currency::USD())
4355 .position_id(position_id)
4356 .commission(Money::from("1.00 USD"))
4357 .build();
4358 let fill_voided =
4359 matching_fill_void(&fill, Quantity::from(4), Some(Money::from("0.40 USD")));
4360 let mut position = Position::new(&instrument, fill);
4361 position
4362 .apply_fill_void(
4363 fill_voided.clone(),
4364 Quantity::from(4),
4365 Some(Money::from("0.40 USD")),
4366 )
4367 .unwrap();
4368
4369 position
4370 .apply_fill_void(fill_voided, Quantity::from(7), None)
4371 .unwrap();
4372
4373 assert_eq!(position.side, PositionSide::Long);
4374 assert_eq!(position.quantity, Quantity::from(3));
4375 assert_eq!(position.commissions(), vec![Money::from("1.00 USD")]);
4376 assert_eq!(position.realized_pnl, Some(Money::from("-1.00 USD")));
4377 assert_eq!(position.fill_voids.len(), 2);
4378 }
4379
4380 #[rstest]
4381 fn test_fill_void_replays_across_position_close_and_reopen(audusd_sim: CurrencyPair) {
4382 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4383 let position_id = PositionId::from("P-VOID-REPLAY");
4384 let fill1 = OrderFilledSpec::builder()
4385 .instrument_id(instrument.id())
4386 .client_order_id(ClientOrderId::from("O-OPEN"))
4387 .trade_id(TradeId::from("T-OPEN"))
4388 .order_side(OrderSide::Buy)
4389 .last_qty(Quantity::from(10))
4390 .last_px(Price::from("1.00000"))
4391 .currency(Currency::USD())
4392 .position_id(position_id)
4393 .commission(Money::from("1.00 USD"))
4394 .ts_event(UnixNanos::from(1))
4395 .build();
4396 let fill2 = OrderFilledSpec::builder()
4397 .instrument_id(instrument.id())
4398 .client_order_id(ClientOrderId::from("O-CLOSE"))
4399 .trade_id(TradeId::from("T-CLOSE"))
4400 .order_side(OrderSide::Sell)
4401 .last_qty(Quantity::from(10))
4402 .last_px(Price::from("1.10000"))
4403 .currency(Currency::USD())
4404 .position_id(position_id)
4405 .commission(Money::from("1.00 USD"))
4406 .ts_event(UnixNanos::from(2))
4407 .build();
4408 let fill3 = OrderFilledSpec::builder()
4409 .instrument_id(instrument.id())
4410 .client_order_id(ClientOrderId::from("O-REOPEN"))
4411 .trade_id(TradeId::from("T-REOPEN"))
4412 .order_side(OrderSide::Buy)
4413 .last_qty(Quantity::from(5))
4414 .last_px(Price::from("1.20000"))
4415 .currency(Currency::USD())
4416 .position_id(position_id)
4417 .commission(Money::from("1.00 USD"))
4418 .ts_event(UnixNanos::from(3))
4419 .build();
4420 let fill_voided =
4421 matching_fill_void(&fill2, Quantity::from(5), Some(Money::from("0.50 USD")));
4422 let mut position = Position::new(&instrument, fill1);
4423 position.apply(&fill2);
4424 position.apply(&fill3);
4425
4426 position
4427 .apply_fill_void(
4428 fill_voided,
4429 Quantity::from(5),
4430 Some(Money::from("0.50 USD")),
4431 )
4432 .unwrap();
4433 let encoded = serde_json::to_string(&position).unwrap();
4434 let restored: Position = serde_json::from_str(&encoded).unwrap();
4435
4436 assert_eq!(position.side, PositionSide::Long);
4437 assert_eq!(position.quantity, Quantity::from(10));
4438 assert_eq!(position.opening_order_id, ClientOrderId::from("O-OPEN"));
4439 assert_eq!(position.buy_qty, Quantity::from(15));
4440 assert_eq!(position.sell_qty, Quantity::from(5));
4441 assert_eq!(position.commissions(), vec![Money::from("2.50 USD")]);
4442 assert_eq!(position.replay_events.len(), 3);
4443 assert_eq!(position.fill_voids.len(), 1);
4444 assert_eq!(restored.quantity, position.quantity);
4445 assert_eq!(restored.opening_order_id, position.opening_order_id);
4446 assert_eq!(restored.commissions(), position.commissions());
4447 assert_eq!(restored.replay_events.len(), position.replay_events.len());
4448 assert_eq!(restored.fill_voids.len(), position.fill_voids.len());
4449 }
4450
4451 #[rstest]
4452 fn test_fill_void_replays_manual_adjustment(audusd_sim: CurrencyPair) {
4453 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4454 let position_id = PositionId::from("P-VOID-ADJUSTMENT");
4455 let fill = OrderFilledSpec::builder()
4456 .instrument_id(instrument.id())
4457 .client_order_id(ClientOrderId::from("O-VOID-ADJUSTMENT"))
4458 .trade_id(TradeId::from("T-VOID-ADJUSTMENT"))
4459 .order_side(OrderSide::Buy)
4460 .last_qty(Quantity::from(10))
4461 .last_px(Price::from("1.00000"))
4462 .currency(Currency::USD())
4463 .position_id(position_id)
4464 .ts_event(UnixNanos::from(1))
4465 .build();
4466 let fill_voided = matching_fill_void(&fill, Quantity::from(2), None);
4467 let adjustment = PositionAdjusted::new(
4468 fill.trader_id,
4469 fill.strategy_id,
4470 fill.instrument_id,
4471 position_id,
4472 fill.account_id,
4473 PositionAdjustmentType::Funding,
4474 None,
4475 Some(Money::from("5.00 USD")),
4476 Some("funding".into()),
4477 uuid4(),
4478 UnixNanos::from(2),
4479 UnixNanos::from(2),
4480 );
4481 let mut position = Position::new(&instrument, fill);
4482 position.apply_adjustment(adjustment);
4483
4484 position
4485 .apply_fill_void(fill_voided, Quantity::from(2), None)
4486 .unwrap();
4487
4488 assert_eq!(position.side, PositionSide::Long);
4489 assert_eq!(position.quantity, Quantity::from(8));
4490 assert_eq!(position.realized_pnl, Some(Money::from("5.00 USD")));
4491 assert_eq!(position.adjustments, vec![adjustment]);
4492 assert_eq!(position.replay_events.len(), 2);
4493 assert_eq!(position.fill_voids.len(), 1);
4494 assert_eq!(position.ts_last, UnixNanos::from(2));
4495 }
4496
4497 fn binary_fill(
4498 instrument: &InstrumentAny,
4499 trade_id: &str,
4500 last_qty: &str,
4501 last_px: &str,
4502 ts_event: u64,
4503 ) -> OrderFilled {
4504 OrderFilledSpec::builder()
4505 .instrument_id(instrument.id())
4506 .client_order_id(ClientOrderId::from(format!("O-{trade_id}").as_str()))
4507 .venue_order_id(VenueOrderId::from(format!("V-{trade_id}").as_str()))
4508 .account_id(AccountId::from("POLYMARKET-001"))
4509 .trade_id(TradeId::from(trade_id))
4510 .order_side(OrderSide::Buy)
4511 .last_qty(Quantity::from(last_qty))
4512 .last_px(Price::from(last_px))
4513 .currency(Currency::USDC())
4514 .commission(Money::from("0.10 USDC"))
4515 .position_id(PositionId::from("P-BINARY"))
4516 .ts_event(UnixNanos::from(ts_event))
4517 .ts_init(UnixNanos::from(ts_event))
4518 .build()
4519 }
4520
4521 fn contract_expired(instrument: &InstrumentAny, close_price: &str) -> InstrumentClose {
4522 InstrumentClose::new(
4523 instrument.id(),
4524 Price::from(close_price),
4525 InstrumentCloseType::ContractExpired,
4526 UnixNanos::from(300),
4527 UnixNanos::from(301),
4528 )
4529 }
4530
4531 #[rstest]
4532 #[case::winner("1.000", dec!(5.90), 1.5)]
4533 #[case::loser("0.000", dec!(-4.10), -1.0)]
4534 fn test_apply_instrument_close_settles_without_fill(
4535 binary_option: BinaryOption,
4536 #[case] close_price: &str,
4537 #[case] expected_realized_pnl: Decimal,
4538 #[case] expected_return: f64,
4539 ) {
4540 let instrument = InstrumentAny::BinaryOption(binary_option);
4541 let fill = binary_fill(&instrument, "T-OPEN", "10.00", "0.400", 100);
4542 let mut position = Position::new(&instrument, fill.clone());
4543 let close = contract_expired(&instrument, close_price);
4544 let requires_replay_state_before = position.requires_replay_state();
4545
4546 position.apply_instrument_close(close).unwrap();
4547 let snapshot = PositionSnapshot::from_replay_state(&position, None);
4548 let restored: Position = serde_json::from_value(snapshot.replay_state.unwrap()).unwrap();
4549
4550 assert!(!requires_replay_state_before);
4551 assert!(position.requires_replay_state());
4552 assert!(position.is_closed());
4553 assert!(position.is_settled());
4554 assert_eq!(position.side, PositionSide::Flat);
4555 assert_eq!(position.signed_qty, 0.0);
4556 assert_eq!(position.quantity, Quantity::from("0.00"));
4557 assert_eq!(position.peak_qty, Quantity::from("10.00"));
4558 assert_eq!(position.buy_qty, Quantity::from("10.00"));
4559 assert_eq!(position.sell_qty, Quantity::from("0.00"));
4560 assert_eq!(position.opening_order_id, fill.client_order_id);
4561 assert_eq!(position.closing_order_id, None);
4562 assert_eq!(position.events, vec![fill]);
4563 assert_eq!(position.commissions(), vec![Money::from("0.10 USDC")]);
4564 assert_eq!(position.avg_px_open, 0.4);
4565 assert_eq!(
4566 position.avg_px_close,
4567 Some(Price::from(close_price).as_f64())
4568 );
4569 assert!((position.realized_return - expected_return).abs() < 1e-12);
4570 assert_eq!(
4571 position.realized_pnl.unwrap().as_decimal(),
4572 expected_realized_pnl
4573 );
4574 assert_eq!(position.ts_last, UnixNanos::from(300));
4575 assert_eq!(position.ts_closed, Some(UnixNanos::from(300)));
4576 assert_eq!(position.duration_ns, DurationNanos::new(200));
4577 assert!(matches!(
4578 position.replay_events.last(),
4579 Some(PositionReplayEvent::InstrumentClosed(event)) if *event == close
4580 ));
4581 assert!(restored.is_settled());
4582 assert!(restored.is_closed());
4583 assert_eq!(restored.realized_pnl, position.realized_pnl);
4584 assert_eq!(restored.avg_px_close, position.avg_px_close);
4585 assert_eq!(restored.ts_closed, position.ts_closed);
4586 }
4587
4588 #[rstest]
4589 fn test_apply_instrument_close_after_partial_close(binary_option: BinaryOption) {
4590 let instrument = InstrumentAny::BinaryOption(binary_option);
4591 let opening_fill = binary_fill(&instrument, "T-OPEN", "10.00", "0.400", 100);
4592 let mut reducing_fill = binary_fill(&instrument, "T-REDUCE", "4.00", "0.600", 200);
4593 reducing_fill.order_side = OrderSide::Sell;
4594 let mut position = Position::new(&instrument, opening_fill);
4595 position.apply(&reducing_fill);
4596
4597 position
4598 .apply_instrument_close(contract_expired(&instrument, "1.000"))
4599 .unwrap();
4600
4601 assert!(position.is_closed());
4602 assert_eq!(position.quantity, Quantity::from("0.00"));
4603 assert_eq!(position.buy_qty, Quantity::from("10.00"));
4604 assert_eq!(position.sell_qty, Quantity::from("4.00"));
4605 assert!((position.avg_px_close.unwrap() - 0.84).abs() < 1e-12);
4606 assert!((position.realized_return - 1.1).abs() < 1e-12);
4607 assert_eq!(position.realized_pnl.unwrap().as_decimal(), dec!(4.20));
4608 assert_eq!(position.closing_order_id, None);
4609 }
4610
4611 #[rstest]
4612 #[case::fills_only(false, false, false)]
4613 #[case::fill_voided(true, false, true)]
4614 #[case::settled(false, true, true)]
4615 fn test_requires_replay_state(
4616 binary_option: BinaryOption,
4617 #[case] void_fill: bool,
4618 #[case] settle: bool,
4619 #[case] expected: bool,
4620 ) {
4621 let instrument = InstrumentAny::BinaryOption(binary_option);
4622 let fill = binary_fill(&instrument, "T-OPEN", "10.00", "0.400", 100);
4623 let mut position = Position::new(&instrument, fill.clone());
4624
4625 if void_fill {
4626 position
4627 .apply_fill_void(
4628 matching_fill_void(&fill, Quantity::from("2.00"), None),
4629 Quantity::from("2.00"),
4630 None,
4631 )
4632 .unwrap();
4633 }
4634
4635 if settle {
4636 position
4637 .apply_instrument_close(contract_expired(&instrument, "1.000"))
4638 .unwrap();
4639 }
4640
4641 assert_eq!(position.requires_replay_state(), expected);
4642 }
4643
4644 #[rstest]
4645 #[case::other_instrument(Some("OTHER.POLYMARKET"), InstrumentCloseType::ContractExpired, false)]
4646 #[case::end_of_session(None, InstrumentCloseType::EndOfSession, false)]
4647 #[case::not_open(None, InstrumentCloseType::ContractExpired, true)]
4648 fn test_apply_instrument_close_rejects_without_mutation(
4649 binary_option: BinaryOption,
4650 #[case] instrument_id: Option<&str>,
4651 #[case] close_type: InstrumentCloseType,
4652 #[case] settle_first: bool,
4653 ) {
4654 let instrument = InstrumentAny::BinaryOption(binary_option);
4655 let fill = binary_fill(&instrument, "T-OPEN", "10.00", "0.400", 100);
4656 let mut position = Position::new(&instrument, fill);
4657
4658 if settle_first {
4659 position
4660 .apply_instrument_close(contract_expired(&instrument, "1.000"))
4661 .unwrap();
4662 }
4663
4664 let before = serde_json::to_value(&position).unwrap();
4665
4666 let close = InstrumentClose::new(
4667 instrument_id.map_or_else(|| instrument.id(), InstrumentId::from),
4668 Price::from("1.000"),
4669 close_type,
4670 UnixNanos::from(400),
4671 UnixNanos::from(401),
4672 );
4673
4674 let result = position.apply_instrument_close(close);
4675
4676 assert!(result.is_err());
4677 assert_eq!(serde_json::to_value(&position).unwrap(), before);
4678 }
4679
4680 #[rstest]
4681 fn test_fill_void_replays_instrument_close(binary_option: BinaryOption) {
4682 let instrument = InstrumentAny::BinaryOption(binary_option);
4683 let opening_fill = binary_fill(&instrument, "T-OPEN", "10.00", "0.400", 100);
4684 let adding_fill = binary_fill(&instrument, "T-ADD", "5.00", "0.500", 200);
4685 let fill_voided = matching_fill_void(&adding_fill, Quantity::from("5.00"), None);
4686 let mut position = Position::new(&instrument, opening_fill);
4687 position.apply(&adding_fill);
4688 let close = contract_expired(&instrument, "1.000");
4689 position.apply_instrument_close(close).unwrap();
4690
4691 let closed_cycles_pnl = position
4692 .apply_fill_void(fill_voided, Quantity::from("5.00"), None)
4693 .unwrap();
4694
4695 assert_eq!(closed_cycles_pnl, None);
4696 assert!(position.is_closed());
4697 assert!(position.is_settled());
4698 assert_eq!(position.quantity, Quantity::from("0.00"));
4699 assert_eq!(position.peak_qty, Quantity::from("10.00"));
4700 assert_eq!(position.buy_qty, Quantity::from("10.00"));
4701 assert_eq!(position.realized_pnl.unwrap().as_decimal(), dec!(5.80));
4702 assert_eq!(position.ts_closed, Some(UnixNanos::from(300)));
4703 assert_eq!(position.fill_voids.len(), 1);
4704 assert_eq!(position.replay_events.len(), 3);
4705 }
4706
4707 #[rstest]
4708 fn test_fill_void_returns_all_closed_cycle_pnl(audusd_sim: CurrencyPair) {
4709 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4710 let position_id = PositionId::from("P-VOID-CYCLES");
4711 let fill = |client_order_id: &str,
4712 trade_id: &str,
4713 order_side: OrderSide,
4714 quantity: u32,
4715 price: &str,
4716 ts_event: u64| {
4717 OrderFilledSpec::builder()
4718 .instrument_id(instrument.id())
4719 .client_order_id(ClientOrderId::from(client_order_id))
4720 .trade_id(TradeId::from(trade_id))
4721 .order_side(order_side)
4722 .last_qty(Quantity::from(quantity))
4723 .last_px(Price::from(price))
4724 .currency(Currency::USD())
4725 .position_id(position_id)
4726 .ts_event(UnixNanos::from(ts_event))
4727 .build()
4728 };
4729 let fills = [
4730 fill("O-OPEN-1", "T-OPEN-1", OrderSide::Buy, 10, "1.0", 1),
4731 fill("O-CLOSE-1", "T-CLOSE-1", OrderSide::Sell, 10, "2.0", 2),
4732 fill("O-OPEN-2", "T-OPEN-2", OrderSide::Buy, 10, "3.0", 3),
4733 fill("O-CLOSE-2", "T-CLOSE-2", OrderSide::Sell, 10, "5.0", 4),
4734 fill("O-CURRENT", "T-CURRENT", OrderSide::Buy, 5, "6.0", 5),
4735 ];
4736 let current = fills.last().unwrap();
4737 let fill_voided = matching_fill_void(current, Quantity::from(1), None);
4738 let mut position = Position::new(&instrument, fills[0].clone());
4739 for fill in &fills[1..] {
4740 position.apply(fill);
4741 }
4742
4743 let closed_cycles_pnl = position
4744 .apply_fill_void(fill_voided, Quantity::from(1), None)
4745 .unwrap();
4746
4747 assert_eq!(closed_cycles_pnl, Some(Money::from("30.00 USD")));
4748 assert_eq!(position.side, PositionSide::Long);
4749 assert_eq!(position.quantity, Quantity::from(4));
4750 assert_eq!(position.avg_px_open, 6.0);
4751 assert_eq!(position.realized_pnl, Some(Money::from("0.00 USD")));
4752 assert_eq!(position.events.len(), 1);
4753 assert_eq!(position.replay_events.len(), 5);
4754 assert_eq!(position.fill_voids.len(), 1);
4755 }
4756
4757 #[rstest]
4758 fn test_full_fill_void_preserves_unvoided_commission(audusd_sim: CurrencyPair) {
4759 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4760 let position_id = PositionId::from("P-FEE-VOID");
4761 let fill = OrderFilledSpec::builder()
4762 .instrument_id(instrument.id())
4763 .client_order_id(ClientOrderId::from("O-FEE"))
4764 .trade_id(TradeId::from("T-FEE"))
4765 .order_side(OrderSide::Buy)
4766 .last_qty(Quantity::from(10))
4767 .last_px(Price::from("1.00000"))
4768 .currency(Currency::USD())
4769 .position_id(position_id)
4770 .commission(Money::from("1.00 USD"))
4771 .build();
4772 let fill_voided = OrderFillVoidedSpec::builder()
4773 .instrument_id(fill.instrument_id)
4774 .client_order_id(fill.client_order_id)
4775 .venue_order_id(fill.venue_order_id)
4776 .account_id(fill.account_id)
4777 .trade_id(fill.trade_id)
4778 .voided_qty(fill.last_qty)
4779 .order_side(fill.order_side)
4780 .order_type(fill.order_type)
4781 .last_px(fill.last_px)
4782 .currency(fill.currency)
4783 .liquidity_side(fill.liquidity_side)
4784 .build();
4785 let mut position = Position::new(&instrument, fill);
4786
4787 position
4788 .apply_fill_void(fill_voided, Quantity::from(10), None)
4789 .unwrap();
4790
4791 assert_eq!(position.side, PositionSide::Flat);
4792 assert_eq!(position.quantity, Quantity::from(0));
4793 assert_eq!(position.commissions(), vec![Money::from("1.00 USD")]);
4794 assert_eq!(position.realized_pnl, Some(Money::from("-1.00 USD")));
4795 assert!(position.events.is_empty());
4796 }
4797
4798 #[rstest]
4799 fn test_full_fill_void_preserves_unvoided_base_commission() {
4800 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
4801 let position_id = PositionId::from("P-BASE-FEE-VOID");
4802 let fill = OrderFilledSpec::builder()
4803 .instrument_id(instrument.id())
4804 .client_order_id(ClientOrderId::from("O-BASE-FEE"))
4805 .trade_id(TradeId::from("T-BASE-FEE"))
4806 .order_side(OrderSide::Buy)
4807 .last_qty(Quantity::from("1.000000"))
4808 .last_px(Price::from("50000.00"))
4809 .currency(Currency::USDT())
4810 .position_id(position_id)
4811 .commission(Money::from("0.00100000 BTC"))
4812 .ts_event(UnixNanos::from(2_000))
4813 .ts_init(UnixNanos::from(1_900))
4814 .build();
4815 let fill_voided = matching_fill_void(&fill, fill.last_qty, None);
4816 let mut position = Position::new(&instrument, fill);
4817
4818 let closed_cycles_pnl = position
4819 .apply_fill_void(fill_voided, Quantity::from("1.000000"), None)
4820 .unwrap();
4821
4822 assert_eq!(closed_cycles_pnl, None);
4823 assert_eq!(position.entry, OrderSide::Sell);
4824 assert_eq!(position.side, PositionSide::Short);
4825 assert_eq!(position.signed_decimal_qty(), dec!(-0.001));
4826 assert_eq!(position.quantity.as_decimal(), dec!(0.001));
4827 assert_eq!(position.buy_qty.as_decimal(), Decimal::ZERO);
4828 assert_eq!(position.sell_qty.as_decimal(), Decimal::ZERO);
4829 assert_eq!(position.commissions(), vec![Money::from("0.00100000 BTC")]);
4830 assert_eq!(position.adjustments.len(), 1);
4831 assert_eq!(
4832 position.adjustments[0].adjustment_type,
4833 PositionAdjustmentType::Commission
4834 );
4835 assert_eq!(position.adjustments[0].quantity_change, Some(dec!(-0.001)));
4836 assert_eq!(position.opening_order_id, ClientOrderId::from("O-BASE-FEE"));
4837 assert_eq!(position.closing_order_id, None);
4838 assert_eq!(position.ts_init, UnixNanos::from(1_900));
4839 assert_eq!(position.ts_opened, UnixNanos::from(2_000));
4840 assert_eq!(position.ts_last, UnixNanos::from(2_000));
4841 assert_eq!(position.ts_closed, None);
4842 assert_eq!(position.duration_ns, DurationNanos::default());
4843 assert_eq!(position.avg_px_open, 50_000.0);
4844 assert_eq!(position.avg_px_close, None);
4845 assert_eq!(position.realized_pnl, None);
4846 assert!(position.events.is_empty());
4847 assert!(position.is_open());
4848 assert!(!position.is_closed());
4849 }
4850
4851 #[rstest]
4852 fn test_surviving_base_commission_can_close_position() {
4853 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
4854 let position_id = PositionId::from("P-BASE-FEE-CLOSE");
4855 let opening = OrderFilledSpec::builder()
4856 .instrument_id(instrument.id())
4857 .client_order_id(ClientOrderId::from("O-BASE-OPEN"))
4858 .trade_id(TradeId::from("T-BASE-OPEN"))
4859 .order_side(OrderSide::Buy)
4860 .last_qty(Quantity::from("0.001000"))
4861 .last_px(Price::from("50000.00"))
4862 .currency(Currency::USDT())
4863 .position_id(position_id)
4864 .ts_event(UnixNanos::from(1_000))
4865 .ts_init(UnixNanos::from(900))
4866 .build();
4867 let fee_fill = OrderFilledSpec::builder()
4868 .instrument_id(instrument.id())
4869 .client_order_id(ClientOrderId::from("O-BASE-FEE"))
4870 .trade_id(TradeId::from("T-BASE-FEE"))
4871 .order_side(OrderSide::Buy)
4872 .last_qty(Quantity::from("1.000000"))
4873 .last_px(Price::from("51000.00"))
4874 .currency(Currency::USDT())
4875 .position_id(position_id)
4876 .commission(Money::from("0.00100000 BTC"))
4877 .ts_event(UnixNanos::from(2_000))
4878 .ts_init(UnixNanos::from(1_900))
4879 .build();
4880 let fill_voided = matching_fill_void(&fee_fill, fee_fill.last_qty, None);
4881 let mut position = Position::new(&instrument, opening);
4882 position.apply(&fee_fill);
4883
4884 let closed_cycles_pnl = position
4885 .apply_fill_void(fill_voided, Quantity::from("1.000000"), None)
4886 .unwrap();
4887
4888 assert_eq!(closed_cycles_pnl, None);
4889 assert_eq!(position.entry, OrderSide::Buy);
4890 assert_eq!(position.side, PositionSide::Flat);
4891 assert_eq!(position.signed_decimal_qty(), Decimal::ZERO);
4892 assert_eq!(position.quantity.as_decimal(), Decimal::ZERO);
4893 assert_eq!(position.buy_qty.as_decimal(), dec!(0.001));
4894 assert_eq!(position.sell_qty.as_decimal(), Decimal::ZERO);
4895 assert_eq!(position.commissions(), vec![Money::from("0.00100000 BTC")]);
4896 assert_eq!(position.events.len(), 1);
4897 assert_eq!(position.adjustments.len(), 1);
4898 assert_eq!(
4899 position.opening_order_id,
4900 ClientOrderId::from("O-BASE-OPEN")
4901 );
4902 assert_eq!(
4903 position.closing_order_id,
4904 Some(ClientOrderId::from("O-BASE-FEE"))
4905 );
4906 assert_eq!(position.ts_init, UnixNanos::from(900));
4907 assert_eq!(position.ts_opened, UnixNanos::from(1_000));
4908 assert_eq!(position.ts_last, UnixNanos::from(2_000));
4909 assert_eq!(position.ts_closed, Some(UnixNanos::from(2_000)));
4910 assert_eq!(position.duration_ns, DurationNanos::new(1_000));
4911 assert_eq!(position.avg_px_open, 50_000.0);
4912 assert_eq!(position.avg_px_close, None);
4913 assert_eq!(position.realized_pnl, Some(Money::from("0.00 USDT")));
4914 assert!(!position.is_open());
4915 assert!(position.is_closed());
4916 }
4917
4918 #[rstest]
4919 fn test_surviving_base_commission_reopen_returns_previous_cycle_pnl() {
4920 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
4921 let position_id = PositionId::from("P-BASE-FEE-REOPEN");
4922 let opening = OrderFilledSpec::builder()
4923 .instrument_id(instrument.id())
4924 .client_order_id(ClientOrderId::from("O-OPEN"))
4925 .trade_id(TradeId::from("T-OPEN"))
4926 .order_side(OrderSide::Buy)
4927 .last_qty(Quantity::from("1.000000"))
4928 .last_px(Price::from("50000.00"))
4929 .currency(Currency::USDT())
4930 .position_id(position_id)
4931 .ts_event(UnixNanos::from(1_000))
4932 .build();
4933 let closing = OrderFilledSpec::builder()
4934 .instrument_id(instrument.id())
4935 .client_order_id(ClientOrderId::from("O-CLOSE"))
4936 .trade_id(TradeId::from("T-CLOSE"))
4937 .order_side(OrderSide::Sell)
4938 .last_qty(Quantity::from("1.000000"))
4939 .last_px(Price::from("51000.00"))
4940 .currency(Currency::USDT())
4941 .position_id(position_id)
4942 .ts_event(UnixNanos::from(2_000))
4943 .build();
4944 let reopening = OrderFilledSpec::builder()
4945 .instrument_id(instrument.id())
4946 .client_order_id(ClientOrderId::from("O-REOPEN"))
4947 .trade_id(TradeId::from("T-REOPEN"))
4948 .order_side(OrderSide::Buy)
4949 .last_qty(Quantity::from("1.000000"))
4950 .last_px(Price::from("52000.00"))
4951 .currency(Currency::USDT())
4952 .position_id(position_id)
4953 .commission(Money::from("0.00100000 BTC"))
4954 .ts_event(UnixNanos::from(3_000))
4955 .ts_init(UnixNanos::from(2_900))
4956 .build();
4957 let fill_voided = matching_fill_void(&reopening, reopening.last_qty, None);
4958 let mut position = Position::new(&instrument, opening);
4959 position.apply(&closing);
4960 position.apply(&reopening);
4961
4962 let closed_cycles_pnl = position
4963 .apply_fill_void(fill_voided, Quantity::from("1.000000"), None)
4964 .unwrap();
4965
4966 assert_eq!(closed_cycles_pnl, Some(Money::from("1000.00 USDT")));
4967 assert_eq!(position.entry, OrderSide::Sell);
4968 assert_eq!(position.side, PositionSide::Short);
4969 assert_eq!(position.signed_decimal_qty(), dec!(-0.001));
4970 assert_eq!(position.quantity.as_decimal(), dec!(0.001));
4971 assert_eq!(position.buy_qty.as_decimal(), Decimal::ZERO);
4972 assert_eq!(position.sell_qty.as_decimal(), Decimal::ZERO);
4973 assert_eq!(position.commissions(), vec![Money::from("0.00100000 BTC")]);
4974 assert!(position.events.is_empty());
4975 assert_eq!(position.adjustments.len(), 1);
4976 assert_eq!(position.opening_order_id, ClientOrderId::from("O-REOPEN"));
4977 assert_eq!(position.closing_order_id, None);
4978 assert_eq!(position.ts_init, UnixNanos::from(2_900));
4979 assert_eq!(position.ts_opened, UnixNanos::from(3_000));
4980 assert_eq!(position.ts_last, UnixNanos::from(3_000));
4981 assert_eq!(position.ts_closed, None);
4982 assert_eq!(position.duration_ns, DurationNanos::default());
4983 assert_eq!(position.avg_px_open, 52_000.0);
4984 assert_eq!(position.avg_px_close, None);
4985 assert_eq!(position.realized_pnl, None);
4986 assert!(position.is_open());
4987 assert!(!position.is_closed());
4988 }
4989
4990 #[rstest]
4991 fn test_surviving_base_commission_can_flip_position() {
4992 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
4993 let position_id = PositionId::from("P-BASE-FEE-FLIP");
4994 let opening = OrderFilledSpec::builder()
4995 .instrument_id(instrument.id())
4996 .client_order_id(ClientOrderId::from("O-BASE-OPEN"))
4997 .trade_id(TradeId::from("T-BASE-OPEN"))
4998 .order_side(OrderSide::Buy)
4999 .last_qty(Quantity::from("0.000500"))
5000 .last_px(Price::from("50000.00"))
5001 .currency(Currency::USDT())
5002 .position_id(position_id)
5003 .ts_event(UnixNanos::from(1_000))
5004 .ts_init(UnixNanos::from(900))
5005 .build();
5006 let fee_fill = OrderFilledSpec::builder()
5007 .instrument_id(instrument.id())
5008 .client_order_id(ClientOrderId::from("O-BASE-FEE"))
5009 .trade_id(TradeId::from("T-BASE-FEE"))
5010 .order_side(OrderSide::Buy)
5011 .last_qty(Quantity::from("1.000000"))
5012 .last_px(Price::from("52000.00"))
5013 .currency(Currency::USDT())
5014 .position_id(position_id)
5015 .commission(Money::from("0.00100000 BTC"))
5016 .ts_event(UnixNanos::from(2_000))
5017 .ts_init(UnixNanos::from(1_900))
5018 .build();
5019 let fill_voided = matching_fill_void(&fee_fill, fee_fill.last_qty, None);
5020 let mut position = Position::new(&instrument, opening);
5021 position.apply(&fee_fill);
5022
5023 let closed_cycles_pnl = position
5024 .apply_fill_void(fill_voided, Quantity::from("1.000000"), None)
5025 .unwrap();
5026
5027 assert_eq!(closed_cycles_pnl, None);
5028 assert_eq!(position.entry, OrderSide::Sell);
5029 assert_eq!(position.side, PositionSide::Short);
5030 assert_eq!(position.signed_decimal_qty(), dec!(-0.0005));
5031 assert_eq!(position.quantity.as_decimal(), dec!(0.0005));
5032 assert_eq!(position.buy_qty.as_decimal(), dec!(0.0005));
5033 assert_eq!(position.sell_qty.as_decimal(), Decimal::ZERO);
5034 assert_eq!(position.commissions(), vec![Money::from("0.00100000 BTC")]);
5035 assert_eq!(position.events.len(), 1);
5036 assert_eq!(position.adjustments.len(), 1);
5037 assert_eq!(
5038 position.opening_order_id,
5039 ClientOrderId::from("O-BASE-OPEN")
5040 );
5041 assert_eq!(position.closing_order_id, None);
5042 assert_eq!(position.ts_init, UnixNanos::from(900));
5043 assert_eq!(position.ts_opened, UnixNanos::from(1_000));
5044 assert_eq!(position.ts_last, UnixNanos::from(2_000));
5045 assert_eq!(position.ts_closed, None);
5046 assert_eq!(position.duration_ns, DurationNanos::default());
5047 assert_eq!(position.avg_px_open, 52_000.0);
5048 assert_eq!(position.avg_px_close, None);
5049 assert_eq!(position.realized_pnl, Some(Money::from("0.00 USDT")));
5050 assert!(position.is_open());
5051 assert!(!position.is_closed());
5052 }
5053
5054 #[rstest]
5055 fn test_fill_void_replays_netting_flip_fragments_with_one_trade_id(audusd_sim: CurrencyPair) {
5056 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5057 let position_id = PositionId::from("P-FLIP-VOID");
5058 let opening = OrderFilledSpec::builder()
5059 .instrument_id(instrument.id())
5060 .client_order_id(ClientOrderId::from("O-OPEN"))
5061 .trade_id(TradeId::from("T-OPEN"))
5062 .order_side(OrderSide::Buy)
5063 .last_qty(Quantity::from(10))
5064 .last_px(Price::from("1.00000"))
5065 .currency(Currency::USD())
5066 .position_id(position_id)
5067 .build();
5068 let closing = OrderFilledSpec::builder()
5069 .instrument_id(instrument.id())
5070 .client_order_id(ClientOrderId::from("O-FLIP"))
5071 .trade_id(TradeId::from("T-FLIP"))
5072 .order_side(OrderSide::Sell)
5073 .last_qty(Quantity::from(10))
5074 .last_px(Price::from("1.10000"))
5075 .currency(Currency::USD())
5076 .position_id(position_id)
5077 .build();
5078 let mut reopening = closing.clone();
5079 reopening.last_qty = Quantity::from(5);
5080 reopening.event_id = uuid4();
5081 reopening.causation_id = Some(closing.event_id);
5082 let fill_voided = matching_fill_void(&closing, Quantity::from(12), None);
5083 let mut position = Position::new(&instrument, opening);
5084 position.apply(&closing);
5085 assert!(!position.is_duplicate_replay_fill(&reopening));
5086 position.apply(&reopening);
5087
5088 position
5089 .apply_fill_void(fill_voided, Quantity::from(12), None)
5090 .unwrap();
5091
5092 assert_eq!(position.side, PositionSide::Long);
5093 assert_eq!(position.quantity, Quantity::from(7));
5094 assert_eq!(position.buy_qty, Quantity::from(10));
5095 assert_eq!(position.sell_qty, Quantity::from(3));
5096 assert_eq!(position.replay_events.len(), 3);
5097 assert!(position.is_duplicate_replay_fill(&reopening));
5098 }
5099
5100 #[rstest]
5101 fn test_fill_void_replays_split_fragments_in_one_corrected_cycle(audusd_sim: CurrencyPair) {
5102 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5103 let position_id = PositionId::from("P-FLIP-CYCLE-VOID");
5104 let opening = OrderFilledSpec::builder()
5105 .instrument_id(instrument.id())
5106 .client_order_id(ClientOrderId::from("O-SELL-1"))
5107 .trade_id(TradeId::from("T-SELL-1"))
5108 .order_side(OrderSide::Sell)
5109 .last_qty(Quantity::from(17))
5110 .last_px(Price::from("1.00000"))
5111 .currency(Currency::USD())
5112 .position_id(position_id)
5113 .build();
5114 let second_sell = OrderFilledSpec::builder()
5115 .instrument_id(instrument.id())
5116 .client_order_id(ClientOrderId::from("O-SELL-2"))
5117 .trade_id(TradeId::from("T-SELL-2"))
5118 .order_side(OrderSide::Sell)
5119 .last_qty(Quantity::from(17))
5120 .last_px(Price::from("1.00000"))
5121 .currency(Currency::USD())
5122 .position_id(position_id)
5123 .build();
5124 let closing = OrderFilledSpec::builder()
5125 .instrument_id(instrument.id())
5126 .client_order_id(ClientOrderId::from("O-FLIP"))
5127 .trade_id(TradeId::from("T-FLIP"))
5128 .order_side(OrderSide::Buy)
5129 .last_qty(Quantity::from(34))
5130 .last_px(Price::from("1.10000"))
5131 .currency(Currency::USD())
5132 .position_id(position_id)
5133 .build();
5134 let mut reopening = closing.clone();
5135 reopening.last_qty = Quantity::from(591);
5136 reopening.event_id = uuid4();
5137 reopening.causation_id = Some(closing.event_id);
5138 let fill_voided = matching_fill_void(&second_sell, Quantity::from(2), None);
5139 let mut position = Position::new(&instrument, opening);
5140 position.apply(&second_sell);
5141 position.apply(&closing);
5142 position.apply(&reopening);
5143
5144 position
5145 .apply_fill_void(fill_voided, Quantity::from(2), None)
5146 .unwrap();
5147
5148 assert_eq!(position.side, PositionSide::Long);
5149 assert_eq!(position.quantity, Quantity::from(593));
5150 assert_eq!(position.buy_qty, Quantity::from(593));
5152 assert_eq!(position.sell_qty, Quantity::from(0));
5153 assert_eq!(position.events.len(), 4);
5154 assert_eq!(position.replay_events.len(), 4);
5155 assert_eq!(position.fill_voids.len(), 1);
5156 assert_eq!(position.trade_ids.len(), 3);
5157 assert!(position.trade_ids.contains(&TradeId::from("T-FLIP")));
5158 }
5159
5160 #[rstest]
5161 fn test_fill_void_replays_partially_voided_reversal(audusd_sim: CurrencyPair) {
5162 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5163 let position_id = PositionId::from("P-REVERSAL-PARTIAL-VOID");
5164 let opening = OrderFilledSpec::builder()
5165 .instrument_id(instrument.id())
5166 .client_order_id(ClientOrderId::from("O-OPEN"))
5167 .trade_id(TradeId::from("T-OPEN"))
5168 .order_side(OrderSide::Sell)
5169 .last_qty(Quantity::from(10))
5170 .last_px(Price::from("1.00000"))
5171 .currency(Currency::USD())
5172 .position_id(position_id)
5173 .build();
5174 let partial_close = OrderFilledSpec::builder()
5175 .instrument_id(instrument.id())
5176 .client_order_id(ClientOrderId::from("O-CLOSE"))
5177 .trade_id(TradeId::from("T-CLOSE"))
5178 .order_side(OrderSide::Buy)
5179 .last_qty(Quantity::from(4))
5180 .last_px(Price::from("0.90000"))
5181 .currency(Currency::USD())
5182 .position_id(position_id)
5183 .build();
5184 let reversal = OrderFilledSpec::builder()
5185 .instrument_id(instrument.id())
5186 .client_order_id(ClientOrderId::from("O-REVERSE"))
5187 .trade_id(TradeId::from("T-REVERSE"))
5188 .order_side(OrderSide::Buy)
5189 .last_qty(Quantity::from(11))
5190 .last_px(Price::from("1.10000"))
5191 .currency(Currency::USD())
5192 .position_id(position_id)
5193 .build();
5194 let fill_voided = matching_fill_void(&reversal, Quantity::from(2), None);
5195 let mut position = Position::new(&instrument, opening);
5196 position.apply(&partial_close);
5197 position.apply(&reversal);
5198
5199 position
5200 .apply_fill_void(fill_voided, Quantity::from(2), None)
5201 .unwrap();
5202
5203 assert_eq!(position.side, PositionSide::Long);
5204 assert_eq!(position.quantity, Quantity::from(3));
5205 assert_eq!(position.avg_px_open, 1.1);
5206 assert_eq!(position.avg_px_close, None);
5207 assert_eq!(position.realized_return, 0.0);
5208 assert_eq!(position.buy_qty, Quantity::from(3));
5209 assert_eq!(position.sell_qty, Quantity::from(0));
5210 assert_eq!(position.realized_pnl, Some(Money::from("-0.20 USD")));
5211 assert_eq!(position.fill_voids.len(), 1);
5212 }
5213
5214 #[rstest]
5215 fn test_position_realized_pnl_with_interleaved_order_sides(
5216 currency_pair_btcusdt: CurrencyPair,
5217 ) {
5218 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
5219 let order1 = OrderTestBuilder::new(OrderType::Market)
5220 .instrument_id(btcusdt.id())
5221 .side(OrderSide::Buy)
5222 .quantity(Quantity::from(12))
5223 .build();
5224 let commission1 = calculate_commission(
5225 &btcusdt,
5226 order1.quantity(),
5227 Price::from("10000.0"),
5228 None,
5229 dec!(0.001),
5230 );
5231 let fill1 = TestOrderEventStubs::filled(
5232 &order1,
5233 &btcusdt,
5234 Some(TradeId::from("1")),
5235 Some(PositionId::from("P-19700101-000000-001-001-1")),
5236 Some(Price::from("10000.0")),
5237 None,
5238 None,
5239 Some(commission1),
5240 None,
5241 None,
5242 );
5243 let mut position = Position::new(&btcusdt, fill1.into());
5244 let order2 = OrderTestBuilder::new(OrderType::Market)
5245 .instrument_id(btcusdt.id())
5246 .side(OrderSide::Buy)
5247 .quantity(Quantity::from(17))
5248 .build();
5249 let commission2 = calculate_commission(
5250 &btcusdt,
5251 order2.quantity(),
5252 Price::from("9999.0"),
5253 None,
5254 dec!(0.001),
5255 );
5256 let fill2 = TestOrderEventStubs::filled(
5257 &order2,
5258 &btcusdt,
5259 Some(TradeId::from("2")),
5260 Some(PositionId::from("P-19700101-000000-001-001-1")),
5261 Some(Price::from("9999.0")),
5262 None,
5263 None,
5264 Some(commission2),
5265 None,
5266 None,
5267 );
5268 position.apply(&fill2.into());
5269 assert_eq!(position.quantity, Quantity::from(29));
5270 assert_eq!(
5271 position.realized_pnl,
5272 Some(Money::from("-289.98300000 USDT"))
5273 );
5274 assert_eq!(position.avg_px_open, 9_999.413_793_103_447);
5275 let order3 = OrderTestBuilder::new(OrderType::Market)
5276 .instrument_id(btcusdt.id())
5277 .side(OrderSide::Sell)
5278 .quantity(Quantity::from(9))
5279 .build();
5280 let commission3 = calculate_commission(
5281 &btcusdt,
5282 order3.quantity(),
5283 Price::from("10001.0"),
5284 None,
5285 dec!(0.001),
5286 );
5287 let fill3 = TestOrderEventStubs::filled(
5288 &order3,
5289 &btcusdt,
5290 Some(TradeId::from("3")),
5291 Some(PositionId::from("P-19700101-000000-001-001-1")),
5292 Some(Price::from("10001.0")),
5293 None,
5294 None,
5295 Some(commission3),
5296 None,
5297 None,
5298 );
5299 position.apply(&fill3.into());
5300 assert_eq!(position.quantity, Quantity::from(20));
5301 assert_eq!(
5302 position.realized_pnl,
5303 Some(Money::from("-365.71613793 USDT"))
5304 );
5305 assert_eq!(position.avg_px_open, 9_999.413_793_103_447);
5306 let order4 = OrderTestBuilder::new(OrderType::Market)
5307 .instrument_id(btcusdt.id())
5308 .side(OrderSide::Buy)
5309 .quantity(Quantity::from(3))
5310 .build();
5311 let commission4 = calculate_commission(
5312 &btcusdt,
5313 order4.quantity(),
5314 Price::from("10003.0"),
5315 None,
5316 dec!(0.001),
5317 );
5318 let fill4 = TestOrderEventStubs::filled(
5319 &order4,
5320 &btcusdt,
5321 Some(TradeId::from("4")),
5322 Some(PositionId::from("P-19700101-000000-001-001-1")),
5323 Some(Price::from("10003.0")),
5324 None,
5325 None,
5326 Some(commission4),
5327 None,
5328 None,
5329 );
5330 position.apply(&fill4.into());
5331 assert_eq!(position.quantity, Quantity::from(23));
5332 assert_eq!(
5333 position.realized_pnl,
5334 Some(Money::from("-395.72513793 USDT"))
5335 );
5336 assert_eq!(position.avg_px_open, 9_999.881_559_220_39);
5337 let order5 = OrderTestBuilder::new(OrderType::Market)
5338 .instrument_id(btcusdt.id())
5339 .side(OrderSide::Sell)
5340 .quantity(Quantity::from(4))
5341 .build();
5342 let commission5 = calculate_commission(
5343 &btcusdt,
5344 order5.quantity(),
5345 Price::from("10005.0"),
5346 None,
5347 dec!(0.001),
5348 );
5349 let fill5 = TestOrderEventStubs::filled(
5350 &order5,
5351 &btcusdt,
5352 Some(TradeId::from("5")),
5353 Some(PositionId::from("P-19700101-000000-001-001-1")),
5354 Some(Price::from("10005.0")),
5355 None,
5356 None,
5357 Some(commission5),
5358 None,
5359 None,
5360 );
5361 position.apply(&fill5.into());
5362 assert_eq!(position.quantity, Quantity::from(19));
5363 assert_eq!(
5364 position.realized_pnl,
5365 Some(Money::from("-415.27137481 USDT"))
5366 );
5367 assert_eq!(position.avg_px_open, 9_999.881_559_220_39);
5368 assert_eq!(
5369 format!("{position}"),
5370 "Position(LONG 19.000000 BTCUSDT.BINANCE, id=P-19700101-000000-001-001-1)"
5371 );
5372 }
5373
5374 #[rstest]
5375 fn test_calculate_pnl_when_given_position_side_flat_returns_zero(
5376 currency_pair_btcusdt: CurrencyPair,
5377 ) {
5378 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
5379 let order = OrderTestBuilder::new(OrderType::Market)
5380 .instrument_id(btcusdt.id())
5381 .side(OrderSide::Buy)
5382 .quantity(Quantity::from(12))
5383 .build();
5384 let fill = TestOrderEventStubs::filled(
5385 &order,
5386 &btcusdt,
5387 None,
5388 Some(PositionId::from("P-123456")),
5389 Some(Price::from("10500.0")),
5390 None,
5391 None,
5392 None,
5393 None,
5394 None,
5395 );
5396 let position = Position::new(&btcusdt, fill.into());
5397 let result = position.calculate_pnl(10500.0, 10500.0, Quantity::from("100000.0"));
5398 assert_eq!(result, Money::from("0 USDT"));
5399 }
5400
5401 #[rstest]
5402 fn test_calculate_pnl_for_long_position_win(currency_pair_btcusdt: CurrencyPair) {
5403 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
5404 let order = OrderTestBuilder::new(OrderType::Market)
5405 .instrument_id(btcusdt.id())
5406 .side(OrderSide::Buy)
5407 .quantity(Quantity::from(12))
5408 .build();
5409 let commission = calculate_commission(
5410 &btcusdt,
5411 order.quantity(),
5412 Price::from("10500.0"),
5413 None,
5414 dec!(0.001),
5415 );
5416 let fill = TestOrderEventStubs::filled(
5417 &order,
5418 &btcusdt,
5419 None,
5420 Some(PositionId::from("P-123456")),
5421 Some(Price::from("10500.0")),
5422 None,
5423 None,
5424 Some(commission),
5425 None,
5426 None,
5427 );
5428 let position = Position::new(&btcusdt, fill.into());
5429 let pnl = position.calculate_pnl(10500.0, 10510.0, Quantity::from("12.0"));
5430 assert_eq!(pnl, Money::from("120 USDT"));
5431 assert_eq!(position.realized_pnl, Some(Money::from("-126 USDT")));
5432 assert_eq!(
5433 position.unrealized_pnl(Price::from("10510.0")),
5434 Money::from("120.0 USDT")
5435 );
5436 assert_eq!(
5437 position.total_pnl(Price::from("10510.0")),
5438 Money::from("-6 USDT")
5439 );
5440 assert_eq!(position.commissions(), vec![Money::from("126.0 USDT")]);
5441 }
5442
5443 #[rstest]
5444 fn test_calculate_pnl_for_long_position_loss(currency_pair_btcusdt: CurrencyPair) {
5445 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
5446 let order = OrderTestBuilder::new(OrderType::Market)
5447 .instrument_id(btcusdt.id())
5448 .side(OrderSide::Buy)
5449 .quantity(Quantity::from(12))
5450 .build();
5451 let commission = calculate_commission(
5452 &btcusdt,
5453 order.quantity(),
5454 Price::from("10500.0"),
5455 None,
5456 dec!(0.001),
5457 );
5458 let fill = TestOrderEventStubs::filled(
5459 &order,
5460 &btcusdt,
5461 None,
5462 Some(PositionId::from("P-123456")),
5463 Some(Price::from("10500.0")),
5464 None,
5465 None,
5466 Some(commission),
5467 None,
5468 None,
5469 );
5470 let position = Position::new(&btcusdt, fill.into());
5471 let pnl = position.calculate_pnl(10500.0, 10480.5, Quantity::from("10.0"));
5472 assert_eq!(pnl, Money::from("-195 USDT"));
5473 assert_eq!(position.realized_pnl, Some(Money::from("-126 USDT")));
5474 assert_eq!(
5475 position.unrealized_pnl(Price::from("10480.50")),
5476 Money::from("-234.0 USDT")
5477 );
5478 assert_eq!(
5479 position.total_pnl(Price::from("10480.50")),
5480 Money::from("-360 USDT")
5481 );
5482 assert_eq!(position.commissions(), vec![Money::from("126.0 USDT")]);
5483 }
5484
5485 #[rstest]
5486 fn test_calculate_pnl_for_short_position_winning(currency_pair_btcusdt: CurrencyPair) {
5487 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
5488 let order = OrderTestBuilder::new(OrderType::Market)
5489 .instrument_id(btcusdt.id())
5490 .side(OrderSide::Sell)
5491 .quantity(Quantity::from("10.15"))
5492 .build();
5493 let commission = calculate_commission(
5494 &btcusdt,
5495 order.quantity(),
5496 Price::from("10500.0"),
5497 None,
5498 dec!(0.001),
5499 );
5500 let fill = TestOrderEventStubs::filled(
5501 &order,
5502 &btcusdt,
5503 None,
5504 Some(PositionId::from("P-123456")),
5505 Some(Price::from("10500.0")),
5506 None,
5507 None,
5508 Some(commission),
5509 None,
5510 None,
5511 );
5512 let position = Position::new(&btcusdt, fill.into());
5513 let pnl = position.calculate_pnl(10500.0, 10390.0, Quantity::from("10.15"));
5514 assert_eq!(pnl, Money::from("1116.5 USDT"));
5515 assert_eq!(
5516 position.unrealized_pnl(Price::from("10390.0")),
5517 Money::from("1116.5 USDT")
5518 );
5519 assert_eq!(position.realized_pnl, Some(Money::from("-106.575 USDT")));
5520 assert_eq!(position.commissions(), vec![Money::from("106.575 USDT")]);
5521 assert_eq!(
5522 position.notional_value(Price::from("10390.0")),
5523 Money::from("105458.5 USDT")
5524 );
5525 }
5526
5527 #[rstest]
5528 fn test_calculate_pnl_for_short_position_loss(currency_pair_btcusdt: CurrencyPair) {
5529 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
5530 let order = OrderTestBuilder::new(OrderType::Market)
5531 .instrument_id(btcusdt.id())
5532 .side(OrderSide::Sell)
5533 .quantity(Quantity::from("10.0"))
5534 .build();
5535 let commission = calculate_commission(
5536 &btcusdt,
5537 order.quantity(),
5538 Price::from("10500.0"),
5539 None,
5540 dec!(0.001),
5541 );
5542 let fill = TestOrderEventStubs::filled(
5543 &order,
5544 &btcusdt,
5545 None,
5546 Some(PositionId::from("P-123456")),
5547 Some(Price::from("10500.0")),
5548 None,
5549 None,
5550 Some(commission),
5551 None,
5552 None,
5553 );
5554 let position = Position::new(&btcusdt, fill.into());
5555 let pnl = position.calculate_pnl(10500.0, 10670.5, Quantity::from("10.0"));
5556 assert_eq!(pnl, Money::from("-1705 USDT"));
5557 assert_eq!(
5558 position.unrealized_pnl(Price::from("10670.5")),
5559 Money::from("-1705 USDT")
5560 );
5561 assert_eq!(position.realized_pnl, Some(Money::from("-105 USDT")));
5562 assert_eq!(position.commissions(), vec![Money::from("105 USDT")]);
5563 assert_eq!(
5564 position.notional_value(Price::from("10670.5")),
5565 Money::from("106705 USDT")
5566 );
5567 }
5568
5569 #[rstest]
5570 fn test_calculate_pnl_for_inverse1(btcusd_bybit: CryptoPerpetual) {
5571 let btcusd_bybit = InstrumentAny::CryptoPerpetual(btcusd_bybit);
5572 let order = OrderTestBuilder::new(OrderType::Market)
5573 .instrument_id(btcusd_bybit.id())
5574 .side(OrderSide::Sell)
5575 .quantity(Quantity::from("100000"))
5576 .build();
5577 let commission = calculate_commission(
5578 &btcusd_bybit,
5579 order.quantity(),
5580 Price::from("10000.0"),
5581 None,
5582 dec!(0.00075),
5583 );
5584 let fill = TestOrderEventStubs::filled(
5585 &order,
5586 &btcusd_bybit,
5587 None,
5588 Some(PositionId::from("P-123456")),
5589 Some(Price::from("10000.0")),
5590 None,
5591 None,
5592 Some(commission),
5593 None,
5594 None,
5595 );
5596 let position = Position::new(&btcusd_bybit, fill.into());
5597 let pnl = position.calculate_pnl(10000.0, 11000.0, Quantity::from("100000.0"));
5598 assert_eq!(pnl, Money::from("-0.90909091 BTC"));
5599 assert_eq!(
5600 position.unrealized_pnl(Price::from("11000.0")),
5601 Money::from("-0.90909091 BTC")
5602 );
5603 assert_eq!(position.realized_pnl, Some(Money::from("-0.00750000 BTC")));
5604 assert_eq!(
5605 position.notional_value(Price::from("11000.0")),
5606 Money::from("9.09090909 BTC")
5607 );
5608 }
5609
5610 #[rstest]
5611 #[case("0.030", "0.001 BTC", "0.003 BTC", "0.00098 BTC")]
5612 #[case("0.000", "-0.002 BTC", "0 BTC", "-0.00202 BTC")]
5613 fn test_calculate_pnl_for_inverse_option_values_premium_in_base(
5614 mut crypto_option_btc_deribit: CryptoOption,
5615 #[case] close_px: &str,
5616 #[case] expected_unrealized_pnl: &str,
5617 #[case] expected_notional: &str,
5618 #[case] expected_realized_pnl: &str,
5619 ) {
5620 crypto_option_btc_deribit.is_inverse = true;
5621 crypto_option_btc_deribit.multiplier = Quantity::from("0.01");
5622 let option = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
5623 let open_order = OrderTestBuilder::new(OrderType::Market)
5624 .instrument_id(option.id())
5625 .side(OrderSide::Buy)
5626 .quantity(Quantity::from("10.0"))
5627 .build();
5628 let close_order = OrderTestBuilder::new(OrderType::Market)
5629 .instrument_id(option.id())
5630 .side(OrderSide::Sell)
5631 .quantity(Quantity::from("10.0"))
5632 .build();
5633 let open_fill = TestOrderEventStubs::filled(
5634 &open_order,
5635 &option,
5636 Some(TradeId::new("1")),
5637 None,
5638 Some(Price::from("0.020")),
5639 None,
5640 None,
5641 Some(Money::from("0.00001 BTC")),
5642 None,
5643 None,
5644 );
5645 let close_fill = TestOrderEventStubs::filled(
5646 &close_order,
5647 &option,
5648 Some(TradeId::new("2")),
5649 None,
5650 Some(Price::from(close_px)),
5651 None,
5652 None,
5653 Some(Money::from("0.00001 BTC")),
5654 None,
5655 None,
5656 );
5657 let mut position = Position::new(&option, open_fill.into());
5658 let unrealized_pnl = position.unrealized_pnl(Price::from(close_px));
5659 let notional = position.notional_value(Price::from(close_px));
5660
5661 position.apply(&close_fill.into());
5662
5663 assert_eq!(unrealized_pnl, Money::from(expected_unrealized_pnl));
5664 assert_eq!(notional, Money::from(expected_notional));
5665 assert_eq!(
5666 position.realized_pnl,
5667 Some(Money::from(expected_realized_pnl))
5668 );
5669 }
5670
5671 #[rstest]
5672 fn test_calculate_pnl_scales_by_contract_multiplier(
5673 mut btcusd_bybit: CryptoPerpetual,
5674 mut crypto_perpetual_ethusdt: CryptoPerpetual,
5675 ) {
5676 btcusd_bybit.multiplier = Quantity::from(10);
5677 let btcusd_bybit = InstrumentAny::CryptoPerpetual(btcusd_bybit);
5678 let inverse_order = OrderTestBuilder::new(OrderType::Market)
5679 .instrument_id(btcusd_bybit.id())
5680 .side(OrderSide::Sell)
5681 .quantity(Quantity::from("100000"))
5682 .build();
5683 let inverse_fill = TestOrderEventStubs::filled(
5684 &inverse_order,
5685 &btcusd_bybit,
5686 None,
5687 Some(PositionId::from("P-MULTIPLIER-INVERSE")),
5688 Some(Price::from("10000.0")),
5689 None,
5690 None,
5691 None,
5692 None,
5693 None,
5694 );
5695 let inverse_position = Position::new(&btcusd_bybit, inverse_fill.into());
5696
5697 assert_eq!(
5698 inverse_position.calculate_pnl(10000.0, 11000.0, Quantity::from("100000.0")),
5699 Money::from("-9.09090909 BTC")
5700 );
5701
5702 crypto_perpetual_ethusdt.multiplier = Quantity::from(10);
5703 let ethusdt = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt);
5704 let linear_order = OrderTestBuilder::new(OrderType::Market)
5705 .instrument_id(ethusdt.id())
5706 .side(OrderSide::Buy)
5707 .quantity(Quantity::from("2.000"))
5708 .build();
5709 let linear_fill = TestOrderEventStubs::filled(
5710 &linear_order,
5711 ðusdt,
5712 None,
5713 Some(PositionId::from("P-MULTIPLIER-LINEAR")),
5714 Some(Price::from("1000.00")),
5715 None,
5716 None,
5717 None,
5718 None,
5719 None,
5720 );
5721 let linear_position = Position::new(ðusdt, linear_fill.into());
5722
5723 assert_eq!(
5724 linear_position.calculate_pnl(1000.0, 1100.0, Quantity::from("2.000")),
5725 Money::new(2000.0, linear_position.settlement_currency)
5726 );
5727 }
5728
5729 #[rstest]
5730 fn test_try_notional_value_for_inverse_zero_price_returns_error(btcusd_bybit: CryptoPerpetual) {
5731 let btcusd_bybit = InstrumentAny::CryptoPerpetual(btcusd_bybit);
5732 let order = OrderTestBuilder::new(OrderType::Market)
5733 .instrument_id(btcusd_bybit.id())
5734 .side(OrderSide::Sell)
5735 .quantity(Quantity::from("100000"))
5736 .build();
5737 let fill = TestOrderEventStubs::filled(
5738 &order,
5739 &btcusd_bybit,
5740 None,
5741 Some(PositionId::from("P-ZERO-PRICE")),
5742 Some(Price::from("10000.0")),
5743 None,
5744 None,
5745 None,
5746 None,
5747 None,
5748 );
5749 let mut position = Position::new(&btcusd_bybit, fill.into());
5750
5751 let result = position.try_notional_value(Price::new(0.0, 1));
5752
5753 assert_eq!(
5754 result.unwrap_err().to_string(),
5755 "price must be positive for inverse notional valuation"
5756 );
5757 assert!(
5758 position
5759 .try_calculate_pnl(10_000.0, 0.0, position.quantity)
5760 .is_err()
5761 );
5762 assert!(position.try_unrealized_pnl(Price::new(0.0, 1)).is_err());
5763 assert!(position.try_total_pnl(Price::new(0.0, 1)).is_err());
5764 assert!(position.try_unrealized_pnl(Price::new(-1.0, 1)).is_err());
5765 assert_eq!(
5766 position.calculate_pnl(10_000.0, 0.0, position.quantity),
5767 Money::zero(position.settlement_currency)
5768 );
5769 assert_eq!(
5770 position.unrealized_pnl(Price::new(0.0, 1)),
5771 Money::zero(position.settlement_currency)
5772 );
5773 assert_eq!(
5774 position.total_pnl(Price::new(0.0, 1)),
5775 Money::zero(position.settlement_currency)
5776 );
5777
5778 position.base_currency = None;
5779 let result = position.try_notional_value(Price::from("10000.0"));
5780
5781 assert_eq!(
5782 result.unwrap_err().to_string(),
5783 "inverse position BTCUSD.BYBIT has no base currency"
5784 );
5785 assert!(position.try_unrealized_pnl(Price::from("10000.0")).is_err());
5786 }
5787
5788 #[rstest]
5789 fn test_calculate_avg_px_zero_quantity_paths(stub_position_long: Position) {
5790 assert_eq!(
5791 stub_position_long
5792 .calculate_avg_px(0.0, 1.0, 2.0, 5.0)
5793 .unwrap(),
5794 2.0
5795 );
5796 assert_eq!(
5797 stub_position_long
5798 .calculate_avg_px(0.0, 1.0, 2.0, 0.0)
5799 .unwrap_err()
5800 .to_string(),
5801 "Cannot calculate average price: both quantities are zero"
5802 );
5803 assert_eq!(
5804 stub_position_long
5805 .calculate_avg_px(5.0, 1.0, 2.0, 0.0)
5806 .unwrap_err()
5807 .to_string(),
5808 "Cannot calculate average price: fill quantity is zero"
5809 );
5810 }
5811
5812 #[rstest]
5813 fn test_calculate_return_rejects_zero_open_price(stub_position_long: Position) {
5814 assert_eq!(
5815 stub_position_long
5816 .calculate_return(0.0, 1.5)
5817 .unwrap_err()
5818 .to_string(),
5819 "Cannot calculate return: open price is zero (close price: 1.5)"
5820 );
5821 assert_eq!(stub_position_long.calculate_return(2.0, 3.0).unwrap(), 0.5);
5822 }
5823
5824 #[rstest]
5825 fn test_calculate_points_inverse_rejects_non_positive_prices(btcusd_bybit: CryptoPerpetual) {
5826 let btcusd_bybit = InstrumentAny::CryptoPerpetual(btcusd_bybit);
5827 let order = OrderTestBuilder::new(OrderType::Market)
5828 .instrument_id(btcusd_bybit.id())
5829 .side(OrderSide::Sell)
5830 .quantity(Quantity::from("100000"))
5831 .build();
5832 let fill = TestOrderEventStubs::filled(
5833 &order,
5834 &btcusd_bybit,
5835 None,
5836 Some(PositionId::from("P-INVERSE-GUARD")),
5837 Some(Price::from("10000.0")),
5838 None,
5839 None,
5840 None,
5841 None,
5842 None,
5843 );
5844 let position = Position::new(&btcusd_bybit, fill.into());
5845
5846 assert_eq!(
5847 position
5848 .calculate_points_inverse(-1.0, 10_000.0)
5849 .unwrap_err()
5850 .to_string(),
5851 "Cannot calculate inverse points: open price is not positive or is too small (-1)"
5852 );
5853 assert_eq!(
5854 position
5855 .calculate_points_inverse(10_000.0, -1.0)
5856 .unwrap_err()
5857 .to_string(),
5858 "Cannot calculate inverse points: close price is not positive or is too small (-1)"
5859 );
5860 assert!(position.calculate_points_inverse(1e-16, 10_000.0).is_err());
5861 assert!(position.calculate_points_inverse(10_000.0, 1e-16).is_err());
5862 assert!(position.calculate_points_inverse(1e-15, 10_000.0).is_ok());
5863 assert!(position.calculate_points_inverse(10_000.0, 1e-15).is_ok());
5864 }
5865
5866 #[rstest]
5867 fn test_calculate_pnl_for_inverse2(ethusd_bybit: CryptoPerpetual) {
5868 let ethusd_bybit = InstrumentAny::CryptoPerpetual(ethusd_bybit);
5869 let order = OrderTestBuilder::new(OrderType::Market)
5870 .instrument_id(ethusd_bybit.id())
5871 .side(OrderSide::Sell)
5872 .quantity(Quantity::from("100000"))
5873 .build();
5874 let commission = calculate_commission(
5875 ðusd_bybit,
5876 order.quantity(),
5877 Price::from("375.95"),
5878 None,
5879 dec!(0.00075),
5880 );
5881 let fill = TestOrderEventStubs::filled(
5882 &order,
5883 ðusd_bybit,
5884 None,
5885 Some(PositionId::from("P-123456")),
5886 Some(Price::from("375.95")),
5887 None,
5888 None,
5889 Some(commission),
5890 None,
5891 None,
5892 );
5893 let position = Position::new(ðusd_bybit, fill.into());
5894
5895 assert_eq!(
5896 position.unrealized_pnl(Price::from("370.00")),
5897 Money::from("4.27745208 ETH")
5898 );
5899 assert_eq!(
5900 position.notional_value(Price::from("370.00")),
5901 Money::from("270.27027027 ETH")
5902 );
5903 }
5904
5905 #[rstest]
5906 fn test_notional_value_for_quanto_uses_settlement_currency(ethbtc_quanto: CryptoFuture) {
5907 let instrument = InstrumentAny::CryptoFuture(ethbtc_quanto);
5908 let order = OrderTestBuilder::new(OrderType::Market)
5909 .instrument_id(instrument.id())
5910 .side(OrderSide::Buy)
5911 .quantity(Quantity::from("5"))
5912 .build();
5913 let price = Price::from("0.03600");
5914 let fill = TestOrderEventStubs::filled(
5915 &order,
5916 &instrument,
5917 None,
5918 Some(PositionId::from("P-QUANTO-NOTIONAL")),
5919 Some(price),
5920 None,
5921 None,
5922 None,
5923 None,
5924 None,
5925 );
5926 let position = Position::new(&instrument, fill.into());
5927 let position_notional = position.notional_value(price);
5928 let instrument_notional =
5929 instrument.calculate_notional_value(position.quantity, price, None);
5930
5931 assert_eq!(position_notional, instrument_notional);
5932 assert_eq!(position_notional, Money::from("0.18 USDT"));
5933 }
5934
5935 #[rstest]
5936 fn test_calculate_unrealized_pnl_for_long(currency_pair_btcusdt: CurrencyPair) {
5937 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
5938 let order1 = OrderTestBuilder::new(OrderType::Market)
5939 .instrument_id(btcusdt.id())
5940 .side(OrderSide::Buy)
5941 .quantity(Quantity::from("2.000000"))
5942 .build();
5943 let order2 = OrderTestBuilder::new(OrderType::Market)
5944 .instrument_id(btcusdt.id())
5945 .side(OrderSide::Buy)
5946 .quantity(Quantity::from("2.000000"))
5947 .build();
5948 let commission1 = calculate_commission(
5949 &btcusdt,
5950 order1.quantity(),
5951 Price::from("10500.0"),
5952 None,
5953 dec!(0.001),
5954 );
5955 let fill1 = TestOrderEventStubs::filled(
5956 &order1,
5957 &btcusdt,
5958 Some(TradeId::new("1")),
5959 Some(PositionId::new("P-123456")),
5960 Some(Price::from("10500.00")),
5961 None,
5962 None,
5963 Some(commission1),
5964 None,
5965 None,
5966 );
5967 let commission2 = calculate_commission(
5968 &btcusdt,
5969 order2.quantity(),
5970 Price::from("10500.0"),
5971 None,
5972 dec!(0.001),
5973 );
5974 let fill2 = TestOrderEventStubs::filled(
5975 &order2,
5976 &btcusdt,
5977 Some(TradeId::new("2")),
5978 Some(PositionId::new("P-123456")),
5979 Some(Price::from("10500.00")),
5980 None,
5981 None,
5982 Some(commission2),
5983 None,
5984 None,
5985 );
5986 let mut position = Position::new(&btcusdt, fill1.into());
5987 position.apply(&fill2.into());
5988 let pnl = position.unrealized_pnl(Price::from("11505.60"));
5989 assert_eq!(pnl, Money::from("4022.40000000 USDT"));
5990 assert_eq!(
5991 position.realized_pnl,
5992 Some(Money::from("-42.00000000 USDT"))
5993 );
5994 assert_eq!(
5995 position.commissions(),
5996 vec![Money::from("42.00000000 USDT")]
5997 );
5998 }
5999
6000 #[rstest]
6001 fn test_calculate_unrealized_pnl_for_short(currency_pair_btcusdt: CurrencyPair) {
6002 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
6003 let order = OrderTestBuilder::new(OrderType::Market)
6004 .instrument_id(btcusdt.id())
6005 .side(OrderSide::Sell)
6006 .quantity(Quantity::from("5.912000"))
6007 .build();
6008 let commission = calculate_commission(
6009 &btcusdt,
6010 order.quantity(),
6011 Price::from("10505.60"),
6012 None,
6013 dec!(0.001),
6014 );
6015 let fill = TestOrderEventStubs::filled(
6016 &order,
6017 &btcusdt,
6018 Some(TradeId::new("1")),
6019 Some(PositionId::new("P-123456")),
6020 Some(Price::from("10505.60")),
6021 None,
6022 None,
6023 Some(commission),
6024 None,
6025 None,
6026 );
6027 let position = Position::new(&btcusdt, fill.into());
6028 let pnl = position.unrealized_pnl(Price::from("10407.15"));
6029 assert_eq!(pnl, Money::from("582.03640000 USDT"));
6030 assert_eq!(
6031 position.realized_pnl,
6032 Some(Money::from("-62.10910720 USDT"))
6033 );
6034 assert_eq!(
6035 position.commissions(),
6036 vec![Money::from("62.10910720 USDT")]
6037 );
6038 }
6039
6040 #[rstest]
6041 fn test_calculate_unrealized_pnl_for_long_inverse(btcusd_bybit: CryptoPerpetual) {
6042 let btcusd_bybit = InstrumentAny::CryptoPerpetual(btcusd_bybit);
6043 let order = OrderTestBuilder::new(OrderType::Market)
6044 .instrument_id(btcusd_bybit.id())
6045 .side(OrderSide::Buy)
6046 .quantity(Quantity::from("100000"))
6047 .build();
6048 let commission = calculate_commission(
6049 &btcusd_bybit,
6050 order.quantity(),
6051 Price::from("10500.0"),
6052 None,
6053 dec!(0.00075),
6054 );
6055 let fill = TestOrderEventStubs::filled(
6056 &order,
6057 &btcusd_bybit,
6058 Some(TradeId::new("1")),
6059 Some(PositionId::new("P-123456")),
6060 Some(Price::from("10500.00")),
6061 None,
6062 None,
6063 Some(commission),
6064 None,
6065 None,
6066 );
6067
6068 let position = Position::new(&btcusd_bybit, fill.into());
6069 let pnl = position.unrealized_pnl(Price::from("11505.60"));
6070 assert_eq!(pnl, Money::from("0.83238969 BTC"));
6071 assert_eq!(position.realized_pnl, Some(Money::from("-0.00714286 BTC")));
6072 assert_eq!(position.commissions(), vec![Money::from("0.00714286 BTC")]);
6073 }
6074
6075 #[rstest]
6076 fn test_calculate_unrealized_pnl_for_short_inverse(btcusd_bybit: CryptoPerpetual) {
6077 let btcusd_bybit = InstrumentAny::CryptoPerpetual(btcusd_bybit);
6078 let order = OrderTestBuilder::new(OrderType::Market)
6079 .instrument_id(btcusd_bybit.id())
6080 .side(OrderSide::Sell)
6081 .quantity(Quantity::from("1250000"))
6082 .build();
6083 let commission = calculate_commission(
6084 &btcusd_bybit,
6085 order.quantity(),
6086 Price::from("15500.00"),
6087 None,
6088 dec!(0.00075),
6089 );
6090 let fill = TestOrderEventStubs::filled(
6091 &order,
6092 &btcusd_bybit,
6093 Some(TradeId::new("1")),
6094 Some(PositionId::new("P-123456")),
6095 Some(Price::from("15500.00")),
6096 None,
6097 None,
6098 Some(commission),
6099 None,
6100 None,
6101 );
6102 let position = Position::new(&btcusd_bybit, fill.into());
6103 let pnl = position.unrealized_pnl(Price::from("12506.65"));
6104
6105 assert_eq!(pnl, Money::from("19.30166700 BTC"));
6106 assert_eq!(position.realized_pnl, Some(Money::from("-0.06048387 BTC")));
6107 assert_eq!(position.commissions(), vec![Money::from("0.06048387 BTC")]);
6108 }
6109
6110 #[rstest]
6111 #[case(OrderSide::Buy, 25, 25.0)]
6112 #[case(OrderSide::Sell,25,-25.0)]
6113 fn test_signed_qty_decimal_qty_for_equity(
6114 #[case] order_side: OrderSide,
6115 #[case] quantity: i64,
6116 #[case] expected: f64,
6117 audusd_sim: CurrencyPair,
6118 ) {
6119 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
6120 let order = OrderTestBuilder::new(OrderType::Market)
6121 .instrument_id(audusd_sim.id())
6122 .side(order_side)
6123 .quantity(Quantity::from(quantity))
6124 .build();
6125
6126 let commission = calculate_commission(
6127 &audusd_sim,
6128 order.quantity(),
6129 Price::from("1.0"),
6130 None,
6131 dec!(0.00002),
6132 );
6133 let fill = TestOrderEventStubs::filled(
6134 &order,
6135 &audusd_sim,
6136 None,
6137 Some(PositionId::from("P-123456")),
6138 None,
6139 None,
6140 None,
6141 Some(commission),
6142 None,
6143 None,
6144 );
6145 let position = Position::new(&audusd_sim, fill.into());
6146 assert_eq!(position.signed_qty, expected);
6147 }
6148
6149 #[rstest]
6150 fn test_position_with_commission_none(audusd_sim: CurrencyPair) {
6151 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
6152 let fill = OrderFilledSpec::builder()
6153 .position_id(PositionId::from("1"))
6154 .build();
6155
6156 let position = Position::new(&audusd_sim, fill);
6157 assert_eq!(position.realized_pnl, Some(Money::from("0 USD")));
6158 }
6159
6160 #[rstest]
6161 fn test_position_with_commission_zero(audusd_sim: CurrencyPair) {
6162 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
6163 let fill = OrderFilledSpec::builder()
6164 .position_id(PositionId::from("1"))
6165 .commission(Money::from("0 USD"))
6166 .build();
6167
6168 let position = Position::new(&audusd_sim, fill);
6169 assert_eq!(position.realized_pnl, Some(Money::from("0 USD")));
6170 }
6171
6172 #[rstest]
6173 fn test_cache_purge_order_events() {
6174 let audusd_sim = audusd_sim();
6175 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
6176
6177 let order1 = OrderTestBuilder::new(OrderType::Market)
6178 .client_order_id(ClientOrderId::new("O-1"))
6179 .instrument_id(audusd_sim.id())
6180 .side(OrderSide::Buy)
6181 .quantity(Quantity::from(50_000))
6182 .build();
6183
6184 let order2 = OrderTestBuilder::new(OrderType::Market)
6185 .client_order_id(ClientOrderId::new("O-2"))
6186 .instrument_id(audusd_sim.id())
6187 .side(OrderSide::Buy)
6188 .quantity(Quantity::from(50_000))
6189 .build();
6190
6191 let position_id = PositionId::new("P-123456");
6192
6193 let fill1 = TestOrderEventStubs::filled(
6194 &order1,
6195 &audusd_sim,
6196 Some(TradeId::new("1")),
6197 Some(position_id),
6198 Some(Price::from("1.00001")),
6199 None,
6200 None,
6201 None,
6202 None,
6203 None,
6204 );
6205
6206 let mut position = Position::new(&audusd_sim, fill1.into());
6207
6208 let fill2 = TestOrderEventStubs::filled(
6209 &order2,
6210 &audusd_sim,
6211 Some(TradeId::new("2")),
6212 Some(position_id),
6213 Some(Price::from("1.00002")),
6214 None,
6215 None,
6216 None,
6217 None,
6218 None,
6219 );
6220
6221 position.apply(&fill2.into());
6222 position.purge_events_for_order(order1.client_order_id());
6223
6224 assert_eq!(position.events.len(), 1);
6225 assert_eq!(position.trade_ids.len(), 1);
6226 assert_eq!(position.events[0].client_order_id, order2.client_order_id());
6227 assert!(position.trade_ids.contains(&TradeId::new("2")));
6228 }
6229
6230 #[rstest]
6231 fn test_purge_all_events_returns_none_for_last_event_and_trade_id() {
6232 let audusd_sim = audusd_sim();
6233 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
6234
6235 let order = OrderTestBuilder::new(OrderType::Market)
6236 .client_order_id(ClientOrderId::new("O-1"))
6237 .instrument_id(audusd_sim.id())
6238 .side(OrderSide::Buy)
6239 .quantity(Quantity::from(100_000))
6240 .build();
6241
6242 let position_id = PositionId::new("P-123456");
6243 let fill = TestOrderEventStubs::filled(
6244 &order,
6245 &audusd_sim,
6246 Some(TradeId::new("1")),
6247 Some(position_id),
6248 Some(Price::from("1.00050")),
6249 None,
6250 None,
6251 None,
6252 Some(UnixNanos::from(1_000_000_000)), None,
6254 );
6255
6256 let mut position = Position::new(&audusd_sim, fill.into());
6257
6258 assert_eq!(position.events.len(), 1);
6259 assert!(position.last_event().is_some());
6260 assert!(position.last_trade_id().is_some());
6261
6262 let original_ts_opened = position.ts_opened;
6264 let original_ts_last = position.ts_last;
6265 assert_ne!(original_ts_opened, UnixNanos::default());
6266 assert_ne!(original_ts_last, UnixNanos::default());
6267
6268 position.purge_events_for_order(order.client_order_id());
6269
6270 assert_eq!(position.events.len(), 0);
6271 assert_eq!(position.trade_ids.len(), 0);
6272 assert!(position.last_event().is_none());
6273 assert!(position.last_trade_id().is_none());
6274
6275 assert_eq!(position.ts_opened, UnixNanos::default());
6278 assert_eq!(position.ts_last, UnixNanos::default());
6279 assert_eq!(position.ts_closed, Some(UnixNanos::default()));
6280 assert_eq!(position.duration_ns, DurationNanos::default());
6281
6282 assert!(position.is_closed());
6285 assert!(!position.is_open());
6286 assert_eq!(position.side, PositionSide::Flat);
6287 }
6288
6289 #[rstest]
6290 fn test_revive_from_empty_shell(audusd_sim: CurrencyPair) {
6291 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
6293
6294 let order1 = OrderTestBuilder::new(OrderType::Market)
6296 .instrument_id(audusd_sim.id())
6297 .side(OrderSide::Buy)
6298 .quantity(Quantity::from(100_000))
6299 .build();
6300
6301 let fill1 = TestOrderEventStubs::filled(
6302 &order1,
6303 &audusd_sim,
6304 None,
6305 Some(PositionId::new("P-1")),
6306 Some(Price::from("1.00000")),
6307 None,
6308 None,
6309 None,
6310 Some(UnixNanos::from(1_000_000_000)),
6311 None,
6312 );
6313
6314 let mut position = Position::new(&audusd_sim, fill1.into());
6315 position.purge_events_for_order(order1.client_order_id());
6316
6317 assert!(position.is_closed());
6319 assert_eq!(position.ts_closed, Some(UnixNanos::default()));
6320 assert_eq!(position.event_count(), 0);
6321
6322 let order2 = OrderTestBuilder::new(OrderType::Market)
6324 .instrument_id(audusd_sim.id())
6325 .side(OrderSide::Buy)
6326 .quantity(Quantity::from(50_000))
6327 .build();
6328
6329 let fill2 = TestOrderEventStubs::filled(
6330 &order2,
6331 &audusd_sim,
6332 None,
6333 Some(PositionId::new("P-1")),
6334 Some(Price::from("1.00020")),
6335 None,
6336 None,
6337 None,
6338 Some(UnixNanos::from(3_000_000_000)),
6339 None,
6340 );
6341
6342 let fill2_typed: OrderFilled = fill2.clone().into();
6343 position.apply(&fill2_typed);
6344
6345 assert!(position.is_long());
6347 assert!(!position.is_closed());
6348 assert!(position.ts_closed.is_none());
6349 assert_eq!(position.ts_opened, fill2.ts_event());
6350 assert_eq!(position.ts_last, fill2.ts_event());
6351 assert_eq!(position.event_count(), 1);
6352 assert_eq!(position.quantity, Quantity::from(50_000));
6353 }
6354
6355 #[rstest]
6356 fn test_empty_shell_position_invariants(audusd_sim: CurrencyPair) {
6357 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
6359
6360 let order = OrderTestBuilder::new(OrderType::Market)
6361 .instrument_id(audusd_sim.id())
6362 .side(OrderSide::Buy)
6363 .quantity(Quantity::from(100_000))
6364 .build();
6365
6366 let fill = TestOrderEventStubs::filled(
6367 &order,
6368 &audusd_sim,
6369 None,
6370 Some(PositionId::new("P-1")),
6371 Some(Price::from("1.00000")),
6372 None,
6373 None,
6374 None,
6375 Some(UnixNanos::from(1_000_000_000)),
6376 None,
6377 );
6378
6379 let mut position = Position::new(&audusd_sim, fill.into());
6380 position.purge_events_for_order(order.client_order_id());
6381
6382 assert_eq!(
6384 position.event_count(),
6385 0,
6386 "Precondition: event_count must be 0"
6387 );
6388
6389 assert!(
6391 position.is_closed(),
6392 "INV1: Empty shell must report is_closed() == true"
6393 );
6394 assert!(
6395 !position.is_open(),
6396 "INV1: Empty shell must report is_open() == false"
6397 );
6398
6399 assert_eq!(
6401 position.side,
6402 PositionSide::Flat,
6403 "INV2: Empty shell must be FLAT"
6404 );
6405
6406 assert!(
6408 position.ts_closed.is_some(),
6409 "INV3: Empty shell must have ts_closed.is_some()"
6410 );
6411 assert_eq!(
6412 position.ts_closed,
6413 Some(UnixNanos::default()),
6414 "INV3: Empty shell ts_closed must be 0"
6415 );
6416
6417 assert_eq!(
6419 position.ts_opened,
6420 UnixNanos::default(),
6421 "INV4: Empty shell ts_opened must be 0"
6422 );
6423 assert_eq!(
6424 position.ts_last,
6425 UnixNanos::default(),
6426 "INV4: Empty shell ts_last must be 0"
6427 );
6428 assert_eq!(
6429 position.duration_ns,
6430 DurationNanos::default(),
6431 "INV4: Empty shell duration_ns must be 0"
6432 );
6433
6434 assert_eq!(
6436 position.quantity,
6437 Quantity::zero(audusd_sim.size_precision()),
6438 "INV5: Empty shell quantity must be 0"
6439 );
6440
6441 assert!(
6443 position.events.is_empty(),
6444 "INV6: Empty shell must have no events"
6445 );
6446 assert!(
6447 position.trade_ids.is_empty(),
6448 "INV6: Empty shell must have no trade IDs"
6449 );
6450 assert!(
6451 position.last_event().is_none(),
6452 "INV6: Empty shell must have no last event"
6453 );
6454 assert!(
6455 position.last_trade_id().is_none(),
6456 "INV6: Empty shell must have no last trade ID"
6457 );
6458 }
6459
6460 #[rstest]
6461 fn test_position_pnl_precision_with_very_small_amounts(audusd_sim: CurrencyPair) {
6462 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
6465 let order = OrderTestBuilder::new(OrderType::Market)
6466 .instrument_id(audusd_sim.id())
6467 .side(OrderSide::Buy)
6468 .quantity(Quantity::from(100))
6469 .build();
6470
6471 let small_commission = Money::new(0.01, Currency::USD());
6473 let fill = TestOrderEventStubs::filled(
6474 &order,
6475 &audusd_sim,
6476 None,
6477 None,
6478 Some(Price::from("1.00001")),
6479 Some(Quantity::from(100)),
6480 None,
6481 Some(small_commission),
6482 None,
6483 None,
6484 );
6485
6486 let position = Position::new(&audusd_sim, fill.into());
6487
6488 assert_eq!(position.commissions().len(), 1);
6490 let recorded_commission = position.commissions()[0];
6491 assert!(
6492 recorded_commission.as_f64() > 0.0,
6493 "Commission of 0.01 should be preserved"
6494 );
6495
6496 let realized = position.realized_pnl.unwrap().as_f64();
6498 assert!(
6499 realized < 0.0,
6500 "Realized PnL should be negative due to commission"
6501 );
6502 }
6503
6504 #[rstest]
6505 fn test_position_pnl_precision_with_high_precision_instrument() {
6506 use crate::instruments::stubs::crypto_perpetual_ethusdt;
6508 let ethusdt = crypto_perpetual_ethusdt();
6509 let ethusdt = InstrumentAny::CryptoPerpetual(ethusdt);
6510
6511 let size_precision = ethusdt.size_precision();
6513
6514 let order = OrderTestBuilder::new(OrderType::Market)
6515 .instrument_id(ethusdt.id())
6516 .side(OrderSide::Buy)
6517 .quantity(Quantity::from("1.123456789"))
6518 .build();
6519
6520 let fill = TestOrderEventStubs::filled(
6521 &order,
6522 ðusdt,
6523 None,
6524 None,
6525 Some(Price::from("2345.123456789")),
6526 Some(Quantity::from("1.123456789")),
6527 None,
6528 Some(Money::from("0.1 USDT")),
6529 None,
6530 None,
6531 );
6532
6533 let position = Position::new(ðusdt, fill.into());
6534
6535 let avg_px = position.avg_px_open;
6537 assert!(
6538 (avg_px - 2_345.123_456_789).abs() < 1e-6,
6539 "High precision price should be preserved within f64 tolerance"
6540 );
6541
6542 assert_eq!(
6545 position.quantity.precision, size_precision,
6546 "Quantity precision should match instrument"
6547 );
6548
6549 let qty_f64 = position.quantity.as_f64();
6551 assert!(
6552 qty_f64 > 1.0 && qty_f64 < 2.0,
6553 "Quantity should be in expected range"
6554 );
6555 }
6556
6557 #[rstest]
6558 fn test_position_pnl_accumulation_across_many_fills(audusd_sim: CurrencyPair) {
6559 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
6561 let order = OrderTestBuilder::new(OrderType::Market)
6562 .instrument_id(audusd_sim.id())
6563 .side(OrderSide::Buy)
6564 .quantity(Quantity::from(1000))
6565 .build();
6566
6567 let initial_fill = TestOrderEventStubs::filled(
6568 &order,
6569 &audusd_sim,
6570 Some(TradeId::new("1")),
6571 None,
6572 Some(Price::from("1.00000")),
6573 Some(Quantity::from(10)),
6574 None,
6575 Some(Money::from("0.01 USD")),
6576 None,
6577 None,
6578 );
6579
6580 let mut position = Position::new(&audusd_sim, initial_fill.into());
6581
6582 for i in 2..=100 {
6584 let price_offset = f64::from(i) * 0.00001;
6585 let fill = TestOrderEventStubs::filled(
6586 &order,
6587 &audusd_sim,
6588 Some(TradeId::new(i.to_string())),
6589 None,
6590 Some(Price::from(&format!("{:.5}", 1.0 + price_offset))),
6591 Some(Quantity::from(10)),
6592 None,
6593 Some(Money::from("0.01 USD")),
6594 None,
6595 None,
6596 );
6597 position.apply(&fill.into());
6598 }
6599
6600 assert_eq!(position.events.len(), 100);
6602 assert_eq!(position.quantity, Quantity::from(1000));
6603
6604 let total_commission: f64 = position.commissions().iter().map(Money::as_f64).sum();
6606 assert!(
6607 (total_commission - 1.0).abs() < 1e-10,
6608 "Commission accumulation should be accurate: expected 1.0, was {total_commission}"
6609 );
6610
6611 let avg_px = position.avg_px_open;
6613 assert!(
6614 avg_px > 1.0 && avg_px < 1.001,
6615 "Average price should be reasonable: got {avg_px}"
6616 );
6617 }
6618
6619 #[rstest]
6620 fn test_position_pnl_with_extreme_price_values(audusd_sim: CurrencyPair) {
6621 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
6623
6624 let order_small = OrderTestBuilder::new(OrderType::Market)
6626 .instrument_id(audusd_sim.id())
6627 .side(OrderSide::Buy)
6628 .quantity(Quantity::from(100_000))
6629 .build();
6630
6631 let fill_small = TestOrderEventStubs::filled(
6632 &order_small,
6633 &audusd_sim,
6634 None,
6635 None,
6636 Some(Price::from("0.00001")),
6637 Some(Quantity::from(100_000)),
6638 None,
6639 None,
6640 None,
6641 None,
6642 );
6643
6644 let position_small = Position::new(&audusd_sim, fill_small.into());
6645 assert_eq!(position_small.avg_px_open, 0.00001);
6646
6647 let last_price_small = Price::from("0.00002");
6649 let unrealized = position_small.unrealized_pnl(last_price_small);
6650 assert!(
6651 unrealized.as_f64() > 0.0,
6652 "Unrealized PnL should be positive when price doubles"
6653 );
6654
6655 let order_large = OrderTestBuilder::new(OrderType::Market)
6657 .instrument_id(audusd_sim.id())
6658 .side(OrderSide::Buy)
6659 .quantity(Quantity::from(100))
6660 .build();
6661
6662 let fill_large = TestOrderEventStubs::filled(
6663 &order_large,
6664 &audusd_sim,
6665 None,
6666 None,
6667 Some(Price::from("99999.99999")),
6668 Some(Quantity::from(100)),
6669 None,
6670 None,
6671 None,
6672 None,
6673 );
6674
6675 let position_large = Position::new(&audusd_sim, fill_large.into());
6676 assert!(
6677 (position_large.avg_px_open - 99999.99999).abs() < 1e-6,
6678 "Large price should be preserved within f64 tolerance"
6679 );
6680 }
6681
6682 #[rstest]
6683 fn test_position_pnl_roundtrip_precision(audusd_sim: CurrencyPair) {
6684 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
6686 let buy_order = OrderTestBuilder::new(OrderType::Market)
6687 .instrument_id(audusd_sim.id())
6688 .side(OrderSide::Buy)
6689 .quantity(Quantity::from(100_000))
6690 .build();
6691
6692 let sell_order = OrderTestBuilder::new(OrderType::Market)
6693 .instrument_id(audusd_sim.id())
6694 .side(OrderSide::Sell)
6695 .quantity(Quantity::from(100_000))
6696 .build();
6697
6698 let open_fill = TestOrderEventStubs::filled(
6700 &buy_order,
6701 &audusd_sim,
6702 Some(TradeId::new("1")),
6703 None,
6704 Some(Price::from("1.123456")),
6705 None,
6706 None,
6707 Some(Money::from("0.50 USD")),
6708 None,
6709 None,
6710 );
6711
6712 let mut position = Position::new(&audusd_sim, open_fill.into());
6713
6714 let close_fill = TestOrderEventStubs::filled(
6716 &sell_order,
6717 &audusd_sim,
6718 Some(TradeId::new("2")),
6719 None,
6720 Some(Price::from("1.123456")),
6721 None,
6722 None,
6723 Some(Money::from("0.50 USD")),
6724 None,
6725 None,
6726 );
6727
6728 position.apply(&close_fill.into());
6729
6730 assert!(position.is_closed());
6732
6733 let realized = position.realized_pnl.unwrap().as_f64();
6735 assert!(
6736 (realized - (-1.0)).abs() < 1e-10,
6737 "Realized PnL should be exactly -1.0 USD (commissions), was {realized}"
6738 );
6739 }
6740
6741 #[rstest]
6742 fn test_position_commission_in_base_currency_buy() {
6743 let btc_usdt = currency_pair_btcusdt();
6745 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
6746
6747 let order = OrderTestBuilder::new(OrderType::Market)
6748 .instrument_id(btc_usdt.id())
6749 .side(OrderSide::Buy)
6750 .quantity(Quantity::from("1.0"))
6751 .build();
6752
6753 let fill = match TestOrderEventStubs::filled(
6755 &order,
6756 &btc_usdt,
6757 Some(TradeId::new("1")),
6758 None,
6759 Some(Price::from("50000.0")),
6760 Some(Quantity::from("1.0")),
6761 None,
6762 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
6763 None,
6764 None,
6765 ) {
6766 OrderEventAny::Filled(fill) => fill,
6767 _ => unreachable!(),
6768 };
6769
6770 let position = Position::new(&btc_usdt, fill.clone());
6771 let replayed_position = Position::new(&btc_usdt, fill);
6772
6773 assert!(
6775 (position.quantity.as_f64() - 0.999).abs() < 1e-9,
6776 "Position quantity should be 0.999 BTC (1.0 - 0.001 commission), was {}",
6777 position.quantity.as_f64()
6778 );
6779
6780 assert!(
6782 (position.signed_qty - 0.999).abs() < 1e-9,
6783 "Signed qty should be 0.999, was {}",
6784 position.signed_qty
6785 );
6786
6787 assert_eq!(
6789 position.adjustments.len(),
6790 1,
6791 "Should have 1 adjustment event"
6792 );
6793 let adjustment = &position.adjustments[0];
6794 assert_eq!(
6795 adjustment.adjustment_type,
6796 PositionAdjustmentType::Commission
6797 );
6798 assert_eq!(
6799 adjustment.quantity_change,
6800 Some(rust_decimal_macros::dec!(-0.001))
6801 );
6802 assert_eq!(adjustment.pnl_change, None);
6803 assert_eq!(
6804 adjustment.event_id,
6805 replayed_position.adjustments[0].event_id
6806 );
6807 }
6808
6809 #[rstest]
6810 fn test_base_commission_adjustment_id_flips_low_bit_of_fill_id(audusd_sim: CurrencyPair) {
6811 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
6812 let base_currency = instrument.base_currency().unwrap();
6813 let fill = OrderFilledSpec::builder()
6814 .instrument_id(instrument.id())
6815 .client_order_id(ClientOrderId::from("O-1"))
6816 .trade_id(TradeId::from("T-1"))
6817 .order_side(OrderSide::Buy)
6818 .last_qty(Quantity::from(10))
6819 .last_px(Price::from("1.00000"))
6820 .currency(Currency::USD())
6821 .commission(Money::new(2.0, base_currency))
6822 .position_id(PositionId::from("P-COMMISSION"))
6823 .event_id(UUID4::from_str("a1b2c3d4-e5f6-4789-8abc-def012345679").unwrap())
6824 .ts_event(UnixNanos::from(1))
6825 .build();
6826
6827 let position = Position::new(&instrument, fill);
6828
6829 assert_eq!(position.adjustments.len(), 1);
6830 assert_eq!(
6831 position.adjustments[0].event_id,
6832 UUID4::from_str("a1b2c3d4-e5f6-4789-8abc-def012345678").unwrap()
6833 );
6834 }
6835
6836 #[rstest]
6837 fn test_position_commission_in_base_currency_sell() {
6838 let btc_usdt = currency_pair_btcusdt();
6840 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
6841
6842 let order = OrderTestBuilder::new(OrderType::Market)
6843 .instrument_id(btc_usdt.id())
6844 .side(OrderSide::Sell)
6845 .quantity(Quantity::from("1.0"))
6846 .build();
6847
6848 let fill = TestOrderEventStubs::filled(
6850 &order,
6851 &btc_usdt,
6852 Some(TradeId::new("1")),
6853 None,
6854 Some(Price::from("50000.0")),
6855 Some(Quantity::from("1.0")),
6856 None,
6857 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
6858 None,
6859 None,
6860 );
6861
6862 let position = Position::new(&btc_usdt, fill.into());
6863
6864 assert!(
6867 (position.quantity.as_f64() - 1.001).abs() < 1e-9,
6868 "Position quantity should be 1.001 BTC (1.0 + 0.001 commission), was {}",
6869 position.quantity.as_f64()
6870 );
6871
6872 assert!(
6874 (position.signed_qty - (-1.001)).abs() < 1e-9,
6875 "Signed qty should be -1.001, was {}",
6876 position.signed_qty
6877 );
6878
6879 assert_eq!(
6881 position.adjustments.len(),
6882 1,
6883 "Should have 1 adjustment event"
6884 );
6885 let adjustment = &position.adjustments[0];
6886 assert_eq!(
6887 adjustment.adjustment_type,
6888 PositionAdjustmentType::Commission
6889 );
6890 assert_eq!(
6892 adjustment.quantity_change,
6893 Some(rust_decimal_macros::dec!(-0.001))
6894 );
6895 assert_eq!(adjustment.pnl_change, None);
6896 }
6897
6898 #[rstest]
6899 fn test_position_commission_in_quote_currency_no_adjustment() {
6900 let btc_usdt = currency_pair_btcusdt();
6902 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
6903
6904 let order = OrderTestBuilder::new(OrderType::Market)
6905 .instrument_id(btc_usdt.id())
6906 .side(OrderSide::Buy)
6907 .quantity(Quantity::from("1.0"))
6908 .build();
6909
6910 let fill = TestOrderEventStubs::filled(
6912 &order,
6913 &btc_usdt,
6914 Some(TradeId::new("1")),
6915 None,
6916 Some(Price::from("50000.0")),
6917 Some(Quantity::from("1.0")),
6918 None,
6919 Some(Money::new(50.0, Currency::USD())),
6920 None,
6921 None,
6922 );
6923
6924 let position = Position::new(&btc_usdt, fill.into());
6925
6926 assert!(
6928 (position.quantity.as_f64() - 1.0).abs() < 1e-9,
6929 "Position quantity should be 1.0 BTC (no adjustment for quote currency commission), was {}",
6930 position.quantity.as_f64()
6931 );
6932
6933 assert_eq!(
6935 position.adjustments.len(),
6936 0,
6937 "Should have no adjustment events for quote currency commission"
6938 );
6939 }
6940
6941 #[rstest]
6942 fn test_position_reset_clears_adjustments() {
6943 let btc_usdt = currency_pair_btcusdt();
6945 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
6946
6947 let buy_order = OrderTestBuilder::new(OrderType::Market)
6949 .instrument_id(btc_usdt.id())
6950 .side(OrderSide::Buy)
6951 .quantity(Quantity::from("1.0"))
6952 .build();
6953
6954 let buy_fill = TestOrderEventStubs::filled(
6955 &buy_order,
6956 &btc_usdt,
6957 Some(TradeId::new("1")),
6958 None,
6959 Some(Price::from("50000.0")),
6960 Some(Quantity::from("1.0")),
6961 None,
6962 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
6963 None,
6964 None,
6965 );
6966
6967 let mut position = Position::new(&btc_usdt, buy_fill.into());
6968 assert_eq!(position.adjustments.len(), 1, "Should have 1 adjustment");
6969
6970 let sell_order = OrderTestBuilder::new(OrderType::Market)
6972 .instrument_id(btc_usdt.id())
6973 .side(OrderSide::Sell)
6974 .quantity(Quantity::from("0.999"))
6975 .build();
6976
6977 let sell_fill = TestOrderEventStubs::filled(
6978 &sell_order,
6979 &btc_usdt,
6980 Some(TradeId::new("2")),
6981 None,
6982 Some(Price::from("51000.0")),
6983 Some(Quantity::from("0.999")),
6984 None,
6985 Some(Money::new(50.0, Currency::USD())), None,
6987 None,
6988 );
6989
6990 position.apply(&sell_fill.into());
6991 assert_eq!(position.side, PositionSide::Flat);
6992 assert_eq!(
6993 position.adjustments.len(),
6994 1,
6995 "Should still have 1 adjustment (no new one from quote commission)"
6996 );
6997
6998 let buy_order2 = OrderTestBuilder::new(OrderType::Market)
7000 .instrument_id(btc_usdt.id())
7001 .side(OrderSide::Buy)
7002 .quantity(Quantity::from("2.0"))
7003 .build();
7004
7005 let buy_fill2 = TestOrderEventStubs::filled(
7006 &buy_order2,
7007 &btc_usdt,
7008 Some(TradeId::new("3")),
7009 None,
7010 Some(Price::from("52000.0")),
7011 Some(Quantity::from("2.0")),
7012 None,
7013 Some(Money::new(0.002, btc_usdt.base_currency().unwrap())),
7014 None,
7015 None,
7016 );
7017
7018 position.apply(&buy_fill2.into());
7019
7020 assert_eq!(
7022 position.adjustments.len(),
7023 1,
7024 "Adjustments should be cleared on position reset, only new adjustment"
7025 );
7026 assert_eq!(
7027 position.adjustments[0].quantity_change,
7028 Some(rust_decimal_macros::dec!(-0.002)),
7029 "New adjustment should be for the new fill"
7030 );
7031 assert_eq!(position.events.len(), 1, "Events should also be reset");
7032 }
7033
7034 #[rstest]
7035 fn test_id_accessors_deduplicate_and_sort(audusd_sim: CurrencyPair) {
7036 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
7037 let position_id = PositionId::from("P-IDS");
7038 let fill1 = OrderFilledSpec::builder()
7039 .instrument_id(instrument.id())
7040 .client_order_id(ClientOrderId::from("O-2"))
7041 .venue_order_id(VenueOrderId::from("V-2"))
7042 .trade_id(TradeId::from("T-3"))
7043 .order_side(OrderSide::Buy)
7044 .last_qty(Quantity::from(10))
7045 .last_px(Price::from("1.00000"))
7046 .currency(Currency::USD())
7047 .position_id(position_id)
7048 .ts_event(UnixNanos::from(1))
7049 .build();
7050 let fill2 = OrderFilledSpec::builder()
7051 .instrument_id(instrument.id())
7052 .client_order_id(ClientOrderId::from("O-1"))
7053 .venue_order_id(VenueOrderId::from("V-1"))
7054 .trade_id(TradeId::from("T-1"))
7055 .order_side(OrderSide::Buy)
7056 .last_qty(Quantity::from(10))
7057 .last_px(Price::from("1.00000"))
7058 .currency(Currency::USD())
7059 .position_id(position_id)
7060 .ts_event(UnixNanos::from(2))
7061 .build();
7062 let fill3 = OrderFilledSpec::builder()
7063 .instrument_id(instrument.id())
7064 .client_order_id(ClientOrderId::from("O-1"))
7065 .venue_order_id(VenueOrderId::from("V-1"))
7066 .trade_id(TradeId::from("T-2"))
7067 .order_side(OrderSide::Buy)
7068 .last_qty(Quantity::from(10))
7069 .last_px(Price::from("1.00000"))
7070 .currency(Currency::USD())
7071 .position_id(position_id)
7072 .ts_event(UnixNanos::from(3))
7073 .build();
7074 let mut position = Position::new(&instrument, fill1);
7075 position.apply(&fill2);
7076 position.apply(&fill3);
7077
7078 assert_eq!(
7079 position.client_order_ids(),
7080 vec![ClientOrderId::from("O-1"), ClientOrderId::from("O-2")]
7081 );
7082 assert_eq!(
7083 position.venue_order_ids(),
7084 vec![VenueOrderId::from("V-1"), VenueOrderId::from("V-2")]
7085 );
7086 assert_eq!(
7087 position.trade_ids(),
7088 vec![
7089 TradeId::from("T-1"),
7090 TradeId::from("T-2"),
7091 TradeId::from("T-3")
7092 ]
7093 );
7094 }
7095
7096 #[rstest]
7097 fn test_is_open_and_is_closed_require_both_conditions(audusd_sim: CurrencyPair) {
7098 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
7099 let position_id = PositionId::from("P-PREDICATES");
7100 let fill_open = OrderFilledSpec::builder()
7101 .instrument_id(instrument.id())
7102 .client_order_id(ClientOrderId::from("O-1"))
7103 .trade_id(TradeId::from("T-1"))
7104 .order_side(OrderSide::Buy)
7105 .last_qty(Quantity::from(10))
7106 .last_px(Price::from("1.00000"))
7107 .currency(Currency::USD())
7108 .position_id(position_id)
7109 .ts_event(UnixNanos::from(1))
7110 .build();
7111 let fill_close = OrderFilledSpec::builder()
7112 .instrument_id(instrument.id())
7113 .client_order_id(ClientOrderId::from("O-2"))
7114 .trade_id(TradeId::from("T-2"))
7115 .order_side(OrderSide::Sell)
7116 .last_qty(Quantity::from(10))
7117 .last_px(Price::from("1.00000"))
7118 .currency(Currency::USD())
7119 .position_id(position_id)
7120 .ts_event(UnixNanos::from(2))
7121 .build();
7122 let mut position = Position::new(&instrument, fill_open);
7123 position.apply(&fill_close);
7124
7125 assert!(position.is_closed());
7126 assert!(!position.is_open());
7127
7128 position.apply_adjustment(PositionAdjusted::new(
7129 position.trader_id,
7130 position.strategy_id,
7131 position.instrument_id,
7132 position.id,
7133 position.account_id,
7134 PositionAdjustmentType::Funding,
7135 Some(dec!(5)),
7136 None,
7137 None,
7138 uuid4(),
7139 UnixNanos::from(3),
7140 UnixNanos::from(3),
7141 ));
7142
7143 assert_eq!(position.side, PositionSide::Long);
7144 assert_eq!(position.ts_closed, Some(UnixNanos::from(2)));
7145 assert!(!position.is_open());
7146 assert!(!position.is_closed());
7147 }
7148
7149 #[rstest]
7150 fn test_voided_fill_rebate_leaves_no_residual_commission(audusd_sim: CurrencyPair) {
7151 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
7152 let position_id = PositionId::from("P-VOID-REBATE");
7153 let fill1 = OrderFilledSpec::builder()
7154 .instrument_id(instrument.id())
7155 .client_order_id(ClientOrderId::from("O-1"))
7156 .trade_id(TradeId::from("T-1"))
7157 .order_side(OrderSide::Buy)
7158 .last_qty(Quantity::from(10))
7159 .last_px(Price::from("1.00000"))
7160 .currency(Currency::USD())
7161 .position_id(position_id)
7162 .ts_event(UnixNanos::from(1))
7163 .build();
7164 let rebate = Money::from("-2.00 USD");
7165 let fill2 = OrderFilledSpec::builder()
7166 .instrument_id(instrument.id())
7167 .client_order_id(ClientOrderId::from("O-2"))
7168 .trade_id(TradeId::from("T-2"))
7169 .order_side(OrderSide::Buy)
7170 .last_qty(Quantity::from(10))
7171 .last_px(Price::from("1.00000"))
7172 .currency(Currency::USD())
7173 .commission(rebate)
7174 .position_id(position_id)
7175 .ts_event(UnixNanos::from(2))
7176 .build();
7177 let void2 = matching_fill_void(&fill2, Quantity::from(10), Some(rebate));
7178 let mut position = Position::new(&instrument, fill1);
7179 position.apply(&fill2);
7180
7181 position
7182 .apply_fill_void(void2, Quantity::from(10), Some(rebate))
7183 .unwrap();
7184
7185 assert_eq!(position.quantity, Quantity::from(10));
7186 assert!(position.commissions().is_empty());
7187 assert_eq!(position.realized_pnl, Some(Money::from("0.00 USD")));
7188 }
7189
7190 #[rstest]
7191 fn test_voided_fill_commission_accumulates_into_realized_pnl(audusd_sim: CurrencyPair) {
7192 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
7193 let position_id = PositionId::from("P-SURVIVING-QUOTE");
7194 let fill1 = OrderFilledSpec::builder()
7195 .instrument_id(instrument.id())
7196 .client_order_id(ClientOrderId::from("O-1"))
7197 .trade_id(TradeId::from("T-1"))
7198 .order_side(OrderSide::Buy)
7199 .last_qty(Quantity::from(10))
7200 .last_px(Price::from("1.00000"))
7201 .currency(Currency::USD())
7202 .commission(Money::from("2.00 USD"))
7203 .position_id(position_id)
7204 .ts_event(UnixNanos::from(1))
7205 .build();
7206 let fill2 = OrderFilledSpec::builder()
7207 .instrument_id(instrument.id())
7208 .client_order_id(ClientOrderId::from("O-2"))
7209 .trade_id(TradeId::from("T-2"))
7210 .order_side(OrderSide::Buy)
7211 .last_qty(Quantity::from(10))
7212 .last_px(Price::from("2.00000"))
7213 .currency(Currency::USD())
7214 .commission(Money::from("1.00 USD"))
7215 .position_id(position_id)
7216 .ts_event(UnixNanos::from(2))
7217 .build();
7218 let void2 = matching_fill_void(&fill2, Quantity::from(10), None);
7219 let mut position = Position::new(&instrument, fill1);
7220 position.apply(&fill2);
7221
7222 position
7223 .apply_fill_void(void2, Quantity::from(10), None)
7224 .unwrap();
7225
7226 assert_eq!(position.quantity, Quantity::from(10));
7227 assert_eq!(position.commissions(), vec![Money::from("3.00 USD")]);
7228 assert_eq!(position.realized_pnl, Some(Money::from("-3.00 USD")));
7229 }
7230
7231 #[rstest]
7232 fn test_voided_fill_base_commission_preserves_avg_px_open(audusd_sim: CurrencyPair) {
7233 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
7234 let base_currency = instrument.base_currency().unwrap();
7235 let position_id = PositionId::from("P-SURVIVING-BASE");
7236 let fill1 = OrderFilledSpec::builder()
7237 .instrument_id(instrument.id())
7238 .client_order_id(ClientOrderId::from("O-1"))
7239 .trade_id(TradeId::from("T-1"))
7240 .order_side(OrderSide::Buy)
7241 .last_qty(Quantity::from(10))
7242 .last_px(Price::from("1.00000"))
7243 .currency(Currency::USD())
7244 .commission(Money::new(2.0, base_currency))
7245 .position_id(position_id)
7246 .ts_event(UnixNanos::from(1))
7247 .build();
7248 let fill2 = OrderFilledSpec::builder()
7249 .instrument_id(instrument.id())
7250 .client_order_id(ClientOrderId::from("O-2"))
7251 .trade_id(TradeId::from("T-2"))
7252 .order_side(OrderSide::Buy)
7253 .last_qty(Quantity::from(10))
7254 .last_px(Price::from("2.00000"))
7255 .currency(Currency::USD())
7256 .commission(Money::new(1.0, base_currency))
7257 .position_id(position_id)
7258 .ts_event(UnixNanos::from(2))
7259 .build();
7260 let void2 = matching_fill_void(&fill2, Quantity::from(10), None);
7261 let mut position = Position::new(&instrument, fill1);
7262 position.apply(&fill2);
7263
7264 position
7265 .apply_fill_void(void2, Quantity::from(10), None)
7266 .unwrap();
7267
7268 assert_eq!(position.quantity, Quantity::from(7));
7269 assert_eq!(position.avg_px_open, 1.0);
7270 assert_eq!(position.commissions(), vec![Money::new(3.0, base_currency)]);
7271 }
7272
7273 #[rstest]
7274 fn test_fill_fragments_requires_matching_order_and_trade(audusd_sim: CurrencyPair) {
7275 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
7276 let position_id = PositionId::from("P-FRAGMENTS");
7277 let fill1 = OrderFilledSpec::builder()
7278 .instrument_id(instrument.id())
7279 .client_order_id(ClientOrderId::from("O-1"))
7280 .trade_id(TradeId::from("T-1"))
7281 .order_side(OrderSide::Buy)
7282 .last_qty(Quantity::from(10))
7283 .last_px(Price::from("1.00000"))
7284 .currency(Currency::USD())
7285 .position_id(position_id)
7286 .ts_event(UnixNanos::from(1))
7287 .build();
7288 let fill2 = OrderFilledSpec::builder()
7289 .instrument_id(instrument.id())
7290 .client_order_id(ClientOrderId::from("O-1"))
7291 .trade_id(TradeId::from("T-2"))
7292 .order_side(OrderSide::Buy)
7293 .last_qty(Quantity::from(10))
7294 .last_px(Price::from("1.00000"))
7295 .currency(Currency::USD())
7296 .position_id(position_id)
7297 .ts_event(UnixNanos::from(2))
7298 .build();
7299 let mut position = Position::new(&instrument, fill1);
7300 position.apply(&fill2);
7301
7302 let fragments = position.fill_fragments(ClientOrderId::from("O-1"), TradeId::from("T-1"));
7303
7304 assert_eq!(fragments.len(), 1);
7305 assert_eq!(fragments[0].trade_id, TradeId::from("T-1"));
7306 assert!(
7307 position
7308 .fill_fragments(ClientOrderId::from("O-2"), TradeId::from("T-1"))
7309 .is_empty()
7310 );
7311 }
7312
7313 #[rstest]
7314 fn test_apply_fill_void_scopes_staleness_check_to_same_trade(audusd_sim: CurrencyPair) {
7315 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
7316 let position_id = PositionId::from("P-VOID-TRADES");
7317 let fill1 = OrderFilledSpec::builder()
7318 .instrument_id(instrument.id())
7319 .client_order_id(ClientOrderId::from("O-1"))
7320 .trade_id(TradeId::from("T-1"))
7321 .order_side(OrderSide::Buy)
7322 .last_qty(Quantity::from(10))
7323 .last_px(Price::from("1.00000"))
7324 .currency(Currency::USD())
7325 .position_id(position_id)
7326 .ts_event(UnixNanos::from(1))
7327 .build();
7328 let fill2 = OrderFilledSpec::builder()
7329 .instrument_id(instrument.id())
7330 .client_order_id(ClientOrderId::from("O-1"))
7331 .trade_id(TradeId::from("T-2"))
7332 .order_side(OrderSide::Buy)
7333 .last_qty(Quantity::from(10))
7334 .last_px(Price::from("1.00000"))
7335 .currency(Currency::USD())
7336 .position_id(position_id)
7337 .ts_event(UnixNanos::from(2))
7338 .build();
7339 let void1 = matching_fill_void(&fill1, Quantity::from(6), None);
7340 let void2 = matching_fill_void(&fill2, Quantity::from(2), None);
7341 let mut position = Position::new(&instrument, fill1);
7342 position.apply(&fill2);
7343 position
7344 .apply_fill_void(void1, Quantity::from(6), None)
7345 .unwrap();
7346
7347 position
7348 .apply_fill_void(void2, Quantity::from(2), None)
7349 .unwrap();
7350
7351 assert_eq!(position.quantity, Quantity::from(12));
7352 assert_eq!(position.fill_voids.len(), 2);
7353 }
7354
7355 #[rstest]
7356 fn test_apply_fill_void_removes_quantity_from_voided_trade_only(audusd_sim: CurrencyPair) {
7357 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
7358 let position_id = PositionId::from("P-VOID-ATTRIBUTION");
7359 let fill1 = OrderFilledSpec::builder()
7360 .instrument_id(instrument.id())
7361 .client_order_id(ClientOrderId::from("O-1"))
7362 .trade_id(TradeId::from("T-1"))
7363 .order_side(OrderSide::Buy)
7364 .last_qty(Quantity::from(10))
7365 .last_px(Price::from("1.00000"))
7366 .currency(Currency::USD())
7367 .position_id(position_id)
7368 .ts_event(UnixNanos::from(1))
7369 .build();
7370 let fill2 = OrderFilledSpec::builder()
7371 .instrument_id(instrument.id())
7372 .client_order_id(ClientOrderId::from("O-1"))
7373 .trade_id(TradeId::from("T-2"))
7374 .order_side(OrderSide::Buy)
7375 .last_qty(Quantity::from(10))
7376 .last_px(Price::from("2.00000"))
7377 .currency(Currency::USD())
7378 .position_id(position_id)
7379 .ts_event(UnixNanos::from(2))
7380 .build();
7381 let void1 = matching_fill_void(&fill1, Quantity::from(4), None);
7382 let mut position = Position::new(&instrument, fill1);
7383 position.apply(&fill2);
7384
7385 position
7386 .apply_fill_void(void1, Quantity::from(4), None)
7387 .unwrap();
7388
7389 assert_eq!(position.quantity, Quantity::from(16));
7390 assert_eq!(position.avg_px_open, 1.625);
7391 }
7392
7393 #[rstest]
7394 fn test_purge_events_for_order_retains_other_order_fill_voids(audusd_sim: CurrencyPair) {
7395 let instrument = InstrumentAny::CurrencyPair(audusd_sim);
7396 let position_id = PositionId::from("P-PURGE-VOID");
7397 let fill1 = OrderFilledSpec::builder()
7398 .instrument_id(instrument.id())
7399 .client_order_id(ClientOrderId::from("O-1"))
7400 .trade_id(TradeId::from("T-1"))
7401 .order_side(OrderSide::Buy)
7402 .last_qty(Quantity::from(10))
7403 .last_px(Price::from("1.00000"))
7404 .currency(Currency::USD())
7405 .position_id(position_id)
7406 .ts_event(UnixNanos::from(1))
7407 .build();
7408 let fill2 = OrderFilledSpec::builder()
7409 .instrument_id(instrument.id())
7410 .client_order_id(ClientOrderId::from("O-2"))
7411 .trade_id(TradeId::from("T-2"))
7412 .order_side(OrderSide::Buy)
7413 .last_qty(Quantity::from(10))
7414 .last_px(Price::from("1.00000"))
7415 .currency(Currency::USD())
7416 .position_id(position_id)
7417 .ts_event(UnixNanos::from(2))
7418 .build();
7419 let void1 = matching_fill_void(&fill1, Quantity::from(2), None);
7420 let void2 = matching_fill_void(&fill2, Quantity::from(3), None);
7421 let mut position = Position::new(&instrument, fill1);
7422 position.apply(&fill2);
7423 position
7424 .apply_fill_void(void1, Quantity::from(2), None)
7425 .unwrap();
7426 position
7427 .apply_fill_void(void2, Quantity::from(3), None)
7428 .unwrap();
7429
7430 assert_eq!(position.fill_voids.len(), 2);
7431
7432 position.purge_events_for_order(ClientOrderId::from("O-1"));
7433
7434 assert_eq!(position.fill_voids.len(), 1);
7435 assert_eq!(
7436 position.fill_voids[0].event.client_order_id,
7437 ClientOrderId::from("O-2")
7438 );
7439 assert_eq!(position.fill_voids[0].voided_qty, Quantity::from(3));
7440 }
7441
7442 #[rstest]
7443 fn test_purge_events_for_order_clears_adjustments_when_flat() {
7444 let btc_usdt = currency_pair_btcusdt();
7446 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
7447
7448 let order = OrderTestBuilder::new(OrderType::Market)
7449 .instrument_id(btc_usdt.id())
7450 .side(OrderSide::Buy)
7451 .quantity(Quantity::from("1.0"))
7452 .build();
7453
7454 let fill = TestOrderEventStubs::filled(
7455 &order,
7456 &btc_usdt,
7457 Some(TradeId::new("1")),
7458 None,
7459 Some(Price::from("50000.0")),
7460 Some(Quantity::from("1.0")),
7461 None,
7462 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
7463 None,
7464 None,
7465 );
7466
7467 let mut position = Position::new(&btc_usdt, fill.into());
7468 assert_eq!(position.adjustments.len(), 1, "Should have 1 adjustment");
7469 assert_eq!(position.events.len(), 1);
7470
7471 position.purge_events_for_order(order.client_order_id());
7473
7474 assert_eq!(position.side, PositionSide::Flat);
7475 assert_eq!(position.events.len(), 0, "Events should be cleared");
7476 assert_eq!(
7477 position.adjustments.len(),
7478 0,
7479 "Adjustments should be cleared when position goes flat"
7480 );
7481 assert_eq!(position.quantity, Quantity::zero(btc_usdt.size_precision()));
7482 }
7483
7484 #[rstest]
7485 fn test_purge_events_for_order_clears_adjustments_on_rebuild() {
7486 let btc_usdt = currency_pair_btcusdt();
7488 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
7489
7490 let order1 = OrderTestBuilder::new(OrderType::Market)
7492 .instrument_id(btc_usdt.id())
7493 .side(OrderSide::Buy)
7494 .quantity(Quantity::from("1.0"))
7495 .client_order_id(ClientOrderId::new("O-001"))
7496 .build();
7497
7498 let fill1 = TestOrderEventStubs::filled(
7499 &order1,
7500 &btc_usdt,
7501 Some(TradeId::new("1")),
7502 None,
7503 Some(Price::from("50000.0")),
7504 Some(Quantity::from("1.0")),
7505 None,
7506 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
7507 None,
7508 None,
7509 );
7510
7511 let mut position = Position::new(&btc_usdt, fill1.into());
7512 assert_eq!(position.adjustments.len(), 1);
7513
7514 let order2 = OrderTestBuilder::new(OrderType::Market)
7516 .instrument_id(btc_usdt.id())
7517 .side(OrderSide::Buy)
7518 .quantity(Quantity::from("2.0"))
7519 .client_order_id(ClientOrderId::new("O-002"))
7520 .build();
7521
7522 let fill2 = TestOrderEventStubs::filled(
7523 &order2,
7524 &btc_usdt,
7525 Some(TradeId::new("2")),
7526 None,
7527 Some(Price::from("51000.0")),
7528 Some(Quantity::from("2.0")),
7529 None,
7530 Some(Money::new(0.002, btc_usdt.base_currency().unwrap())),
7531 None,
7532 None,
7533 );
7534
7535 position.apply(&fill2.into());
7536 assert_eq!(position.adjustments.len(), 2, "Should have 2 adjustments");
7537 assert_eq!(position.events.len(), 2);
7538
7539 position.purge_events_for_order(order1.client_order_id());
7541
7542 assert_eq!(position.events.len(), 1, "Should have 1 remaining event");
7543 assert_eq!(
7544 position.adjustments.len(),
7545 1,
7546 "Should have only the adjustment from remaining fill"
7547 );
7548 assert_eq!(
7549 position.adjustments[0].quantity_change,
7550 Some(rust_decimal_macros::dec!(-0.002)),
7551 "Should be the adjustment from order2"
7552 );
7553 assert!(
7554 (position.quantity.as_f64() - 1.998).abs() < 1e-9,
7555 "Quantity should be 2.0 - 0.002 commission"
7556 );
7557 }
7558
7559 fn btcusdt_fill(trade_id: &str, client_order_id: &str, quantity: &str) -> OrderFilled {
7560 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
7561 let order = OrderTestBuilder::new(OrderType::Market)
7562 .instrument_id(instrument.id())
7563 .side(OrderSide::Buy)
7564 .quantity(Quantity::from(quantity))
7565 .client_order_id(ClientOrderId::new(client_order_id))
7566 .build();
7567
7568 TestOrderEventStubs::filled(
7569 &order,
7570 &instrument,
7571 Some(TradeId::new(trade_id)),
7572 None,
7573 Some(Price::from("50000.0")),
7574 Some(Quantity::from(quantity)),
7575 None,
7576 Some(Money::new(1.0, instrument.quote_currency())),
7577 None,
7578 None,
7579 )
7580 .into()
7581 }
7582
7583 fn funding_payment(position: &Position, pnl: f64) -> PositionAdjusted {
7584 PositionAdjusted::new(
7585 position.trader_id,
7586 position.strategy_id,
7587 position.instrument_id,
7588 position.id,
7589 position.account_id,
7590 PositionAdjustmentType::Funding,
7591 None,
7592 Some(Money::new(pnl, position.settlement_currency)),
7593 None,
7594 uuid4(),
7595 UnixNanos::from(5),
7596 UnixNanos::from(5),
7597 )
7598 }
7599
7600 fn btcusdt_position() -> Position {
7601 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
7602 Position::new(&instrument, btcusdt_fill("1", "O-001", "1.0"))
7603 }
7604
7605 #[rstest]
7606 fn test_revert_last_adjustment_restores_prior_state() {
7607 let mut position = btcusdt_position();
7608 let before = serde_json::to_value(&position).unwrap();
7609 let prior = position.clone_without_events();
7610 position.apply_adjustment(funding_payment(&position, -10.0));
7611
7612 position.revert_last_adjustment(&prior).unwrap();
7613
7614 assert_eq!(serde_json::to_value(&position).unwrap(), before);
7615 }
7616
7617 fn assert_reverts_to_snapshot(mut position: Position, payment: PositionAdjusted) {
7618 let before = serde_json::to_value(&position).unwrap();
7619 let prior = position.clone_without_events();
7620 position.apply_adjustment(payment);
7621
7622 position.revert_last_adjustment(&prior).unwrap();
7623
7624 assert_eq!(serde_json::to_value(&position).unwrap(), before);
7625 }
7626
7627 #[rstest]
7628 fn test_revert_last_adjustment_after_earlier_adjustment() {
7629 let mut position = btcusdt_position();
7630 position.apply_adjustment(funding_payment(&position, -10.0));
7631 let mut payment = funding_payment(&position, -5.0);
7632 payment.ts_event = UnixNanos::from(6);
7633
7634 assert_reverts_to_snapshot(position, payment);
7635 }
7636
7637 #[rstest]
7638 fn test_revert_last_adjustment_after_zero_value_adjustment_at_same_time() {
7639 let mut position = btcusdt_position();
7640 let prior = position.clone_without_events();
7641 let mut zero_payment = funding_payment(&position, 0.0);
7642 zero_payment.ts_event = position.ts_last;
7643 position.apply_adjustment(zero_payment);
7644 let before = serde_json::to_value(&position).unwrap();
7645 position.apply_adjustment(funding_payment(&position, -5.0));
7646
7647 position.revert_last_adjustment(&prior).unwrap();
7649
7650 assert_eq!(serde_json::to_value(&position).unwrap(), before);
7651 }
7652
7653 #[rstest]
7654 #[case::partial("0.2", None)]
7655 #[case::full("0.5", Some(1.0))]
7656 fn test_revert_last_adjustment_after_voided_fill(
7657 #[case] voided_qty: &str,
7658 #[case] commission_voided: Option<f64>,
7659 ) {
7660 let mut position = btcusdt_position();
7661 let mut fill = btcusdt_fill("2", "O-002", "0.5");
7662 fill.ts_event = UnixNanos::from(3);
7663 position.apply(&fill);
7664 let commission_voided =
7665 commission_voided.map(|amount| Money::new(amount, position.settlement_currency));
7666 position
7667 .apply_fill_void(
7668 matching_fill_void(&fill, Quantity::from(voided_qty), commission_voided),
7669 Quantity::from(voided_qty),
7670 commission_voided,
7671 )
7672 .unwrap();
7673 let payment = funding_payment(&position, -5.0);
7674
7675 assert_reverts_to_snapshot(position, payment);
7676 }
7677
7678 #[rstest]
7679 fn test_revert_last_adjustment_after_purge() {
7680 let mut position = btcusdt_position();
7681 let mut fill = btcusdt_fill("2", "O-002", "0.5");
7682 fill.ts_event = UnixNanos::from(3);
7683 position.apply(&fill);
7684 position.apply_adjustment(funding_payment(&position, -10.0));
7685 position.purge_events_for_order(ClientOrderId::new("O-002"));
7686 let mut payment = funding_payment(&position, -5.0);
7687 payment.ts_event = UnixNanos::from(6);
7688
7689 assert_reverts_to_snapshot(position, payment);
7690 }
7691
7692 #[rstest]
7693 fn test_revert_last_adjustment_after_reopening() {
7694 let mut position = btcusdt_position();
7695 let mut close = btcusdt_fill("2", "O-002", "1.0");
7696 close.order_side = OrderSide::Sell;
7697 close.ts_event = UnixNanos::from(2);
7698 position.apply(&close);
7699 let mut reopen = btcusdt_fill("3", "O-003", "0.5");
7700 reopen.ts_event = UnixNanos::from(3);
7701 position.apply(&reopen);
7702 let payment = funding_payment(&position, -5.0);
7703
7704 assert_reverts_to_snapshot(position, payment);
7705 }
7706
7707 #[rstest]
7708 fn test_revert_last_adjustment_rejects_snapshot_of_another_position() {
7709 let mut position = btcusdt_position();
7710 let mut prior = position.clone_without_events();
7711 prior.id = PositionId::new("P-OTHER");
7712 position.apply_adjustment(funding_payment(&position, -10.0));
7713 let after = serde_json::to_value(&position).unwrap();
7714
7715 let error = position.revert_last_adjustment(&prior).unwrap_err();
7716
7717 assert!(error.to_string().contains("prior.id"), "{error}");
7718 assert_eq!(serde_json::to_value(&position).unwrap(), after);
7719 }
7720
7721 #[rstest]
7722 fn test_revert_last_adjustment_rejects_after_later_fill() {
7723 let mut position = btcusdt_position();
7724 let prior = position.clone_without_events();
7725 position.apply_adjustment(funding_payment(&position, -10.0));
7726 position.apply(&btcusdt_fill("2", "O-002", "0.5"));
7727 let after = serde_json::to_value(&position).unwrap();
7728
7729 let error = position.revert_last_adjustment(&prior).unwrap_err();
7730
7731 assert!(
7732 error.to_string().contains("not its latest adjustment"),
7733 "{error}"
7734 );
7735 assert_eq!(serde_json::to_value(&position).unwrap(), after);
7736 }
7737
7738 #[rstest]
7739 fn test_revert_last_adjustment_rejects_stale_snapshot() {
7740 let mut position = btcusdt_position();
7741 let prior = position.clone_without_events();
7742 position.apply_adjustment(funding_payment(&position, -10.0));
7743 position.apply_adjustment(funding_payment(&position, -5.0));
7744 let after = serde_json::to_value(&position).unwrap();
7745
7746 let error = position.revert_last_adjustment(&prior).unwrap_err();
7747
7748 assert!(error.to_string().contains("predates adjustment"), "{error}");
7749 assert_eq!(serde_json::to_value(&position).unwrap(), after);
7750 }
7751
7752 #[rstest]
7753 fn test_revert_last_adjustment_rejects_snapshot_before_zero_value_adjustment() {
7754 let mut position = btcusdt_position();
7755 let prior = position.clone_without_events();
7756 position.apply_adjustment(funding_payment(&position, 0.0));
7757 let mut later_payment = funding_payment(&position, -5.0);
7758 later_payment.ts_event = UnixNanos::from(6);
7759 later_payment.ts_init = UnixNanos::from(6);
7760 position.apply_adjustment(later_payment);
7761 let after = serde_json::to_value(&position).unwrap();
7762
7763 let error = position.revert_last_adjustment(&prior).unwrap_err();
7764
7765 assert!(error.to_string().contains("predates adjustment"), "{error}");
7766 assert_eq!(serde_json::to_value(&position).unwrap(), after);
7767 }
7768
7769 #[rstest]
7770 fn test_revert_last_adjustment_rejects_snapshot_before_zero_value_adjustment_and_voided_fill() {
7771 let mut position = btcusdt_position();
7772 let prior = position.clone_without_events();
7773 position.apply_adjustment(funding_payment(&position, 0.0));
7774 let mut fill = btcusdt_fill("2", "O-002", "0.5");
7775 fill.ts_event = UnixNanos::from(6);
7776 position.apply(&fill);
7777 let commission = Money::new(1.0, position.settlement_currency);
7778 position
7779 .apply_fill_void(
7780 matching_fill_void(&fill, Quantity::from("0.5"), Some(commission)),
7781 Quantity::from("0.5"),
7782 Some(commission),
7783 )
7784 .unwrap();
7785 let mut later_payment = funding_payment(&position, -5.0);
7786 later_payment.ts_event = UnixNanos::from(7);
7787 position.apply_adjustment(later_payment);
7788 let after = serde_json::to_value(&position).unwrap();
7789
7790 let error = position.revert_last_adjustment(&prior).unwrap_err();
7791
7792 assert!(error.to_string().contains("predates adjustment"), "{error}");
7793 assert_eq!(serde_json::to_value(&position).unwrap(), after);
7794 }
7795
7796 #[rstest]
7797 fn test_revert_last_adjustment_rejects_snapshot_with_pnl_at_money_bounds() {
7798 let mut position = btcusdt_position();
7799 let mut prior = position.clone_without_events();
7800 prior.realized_pnl = Some(Money::from_raw(MONEY_RAW_MAX, position.settlement_currency));
7801 position.apply_adjustment(funding_payment(&position, 10.0));
7802 let after = serde_json::to_value(&position).unwrap();
7803
7804 let error = position.revert_last_adjustment(&prior).unwrap_err();
7805
7806 assert!(
7807 error.to_string().contains("cannot take the PnL change"),
7808 "{error}"
7809 );
7810 assert_eq!(serde_json::to_value(&position).unwrap(), after);
7811 }
7812
7813 #[rstest]
7814 #[case::size_precision("prior.size_precision")]
7815 #[case::settlement_currency("prior.settlement_currency")]
7816 fn test_revert_last_adjustment_rejects_snapshot_of_other_instrument_config(
7817 #[case] field: &str,
7818 ) {
7819 let mut position = btcusdt_position();
7820 let mut prior = position.clone_without_events();
7821 match field {
7822 "prior.size_precision" => prior.size_precision += 1,
7823 _ => prior.settlement_currency = Currency::USD(),
7824 }
7825 position.apply_adjustment(funding_payment(&position, -10.0));
7826 let after = serde_json::to_value(&position).unwrap();
7827
7828 let error = position.revert_last_adjustment(&prior).unwrap_err();
7829
7830 assert!(error.to_string().contains(field), "{error}");
7831 assert_eq!(serde_json::to_value(&position).unwrap(), after);
7832 }
7833
7834 #[rstest]
7835 fn test_revert_last_adjustment_rejects_snapshot_with_other_pnl_currency() {
7836 let mut position = btcusdt_position();
7837 let mut prior = position.clone_without_events();
7838 prior.realized_pnl = Some(Money::new(1.0, Currency::USD()));
7839 position.apply_adjustment(funding_payment(&position, -10.0));
7840 let after = serde_json::to_value(&position).unwrap();
7841
7842 let error = position.revert_last_adjustment(&prior).unwrap_err();
7843
7844 assert!(
7845 error.to_string().contains("cannot take the PnL change"),
7846 "{error}"
7847 );
7848 assert_eq!(serde_json::to_value(&position).unwrap(), after);
7849 }
7850
7851 #[rstest]
7852 fn test_purge_events_preserves_manual_adjustments() {
7853 let btc_usdt = currency_pair_btcusdt();
7855 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
7856
7857 let order1 = OrderTestBuilder::new(OrderType::Market)
7859 .instrument_id(btc_usdt.id())
7860 .side(OrderSide::Buy)
7861 .quantity(Quantity::from("1.0"))
7862 .client_order_id(ClientOrderId::new("O-001"))
7863 .build();
7864
7865 let fill1 = TestOrderEventStubs::filled(
7866 &order1,
7867 &btc_usdt,
7868 Some(TradeId::new("1")),
7869 None,
7870 Some(Price::from("50000.0")),
7871 Some(Quantity::from("1.0")),
7872 None,
7873 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
7874 None,
7875 None,
7876 );
7877
7878 let mut position = Position::new(&btc_usdt, fill1.into());
7879 assert_eq!(position.adjustments.len(), 1);
7880
7881 let funding_adjustment = PositionAdjusted::new(
7883 position.trader_id,
7884 position.strategy_id,
7885 position.instrument_id,
7886 position.id,
7887 position.account_id,
7888 PositionAdjustmentType::Funding,
7889 None,
7890 Some(Money::new(10.0, btc_usdt.quote_currency())),
7891 None, uuid4(),
7893 UnixNanos::default(),
7894 UnixNanos::default(),
7895 );
7896 position.apply_adjustment(funding_adjustment);
7897 assert_eq!(position.adjustments.len(), 2);
7898
7899 let order2 = OrderTestBuilder::new(OrderType::Market)
7901 .instrument_id(btc_usdt.id())
7902 .side(OrderSide::Buy)
7903 .quantity(Quantity::from("2.0"))
7904 .client_order_id(ClientOrderId::new("O-002"))
7905 .build();
7906
7907 let fill2 = TestOrderEventStubs::filled(
7908 &order2,
7909 &btc_usdt,
7910 Some(TradeId::new("2")),
7911 None,
7912 Some(Price::from("51000.0")),
7913 Some(Quantity::from("2.0")),
7914 None,
7915 Some(Money::new(0.002, btc_usdt.base_currency().unwrap())),
7916 None,
7917 None,
7918 );
7919
7920 position.apply(&fill2.into());
7921 assert_eq!(
7922 position.adjustments.len(),
7923 3,
7924 "Should have 3 adjustments: 2 commissions + 1 funding"
7925 );
7926
7927 position.purge_events_for_order(order1.client_order_id());
7929
7930 assert_eq!(position.events.len(), 1, "Should have 1 remaining event");
7931 assert_eq!(
7932 position.adjustments.len(),
7933 2,
7934 "Should have funding adjustment + commission from remaining fill"
7935 );
7936
7937 let has_funding = position.adjustments.iter().any(|adj| {
7939 adj.adjustment_type == PositionAdjustmentType::Funding
7940 && adj.pnl_change == Some(Money::new(10.0, btc_usdt.quote_currency()))
7941 });
7942 assert!(has_funding, "Funding adjustment should be preserved");
7943
7944 assert_eq!(
7947 position.realized_pnl,
7948 Some(Money::new(10.0, btc_usdt.quote_currency())),
7949 "Realized PnL should be the funding payment only (commission is in BTC, not USDT)"
7950 );
7951 }
7952
7953 #[rstest]
7954 fn test_position_commission_affects_buy_and_sell_qty() {
7955 let btc_usdt = currency_pair_btcusdt();
7957 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
7958
7959 let buy_order = OrderTestBuilder::new(OrderType::Market)
7960 .instrument_id(btc_usdt.id())
7961 .side(OrderSide::Buy)
7962 .quantity(Quantity::from("1.0"))
7963 .build();
7964
7965 let fill = TestOrderEventStubs::filled(
7967 &buy_order,
7968 &btc_usdt,
7969 Some(TradeId::new("1")),
7970 None,
7971 Some(Price::from("50000.0")),
7972 Some(Quantity::from("1.0")),
7973 None,
7974 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
7975 None,
7976 None,
7977 );
7978
7979 let position = Position::new(&btc_usdt, fill.into());
7980
7981 assert!(
7983 (position.buy_qty.as_f64() - 1.0).abs() < 1e-9,
7984 "buy_qty should be 1.0 (order fill amount), was {}",
7985 position.buy_qty.as_f64()
7986 );
7987
7988 assert!(
7990 (position.quantity.as_f64() - 0.999).abs() < 1e-9,
7991 "position.quantity should be 0.999 (1.0 - 0.001 commission), was {}",
7992 position.quantity.as_f64()
7993 );
7994
7995 assert_eq!(position.adjustments.len(), 1);
7997 assert_eq!(
7998 position.adjustments[0].quantity_change,
7999 Some(rust_decimal_macros::dec!(-0.001))
8000 );
8001 }
8002
8003 #[rstest]
8004 fn test_position_perpetual_commission_no_adjustment() {
8005 let eth_perp = crypto_perpetual_ethusdt();
8007 let eth_perp = InstrumentAny::CryptoPerpetual(eth_perp);
8008
8009 let order = OrderTestBuilder::new(OrderType::Market)
8010 .instrument_id(eth_perp.id())
8011 .side(OrderSide::Buy)
8012 .quantity(Quantity::from("1.0"))
8013 .build();
8014
8015 let fill = TestOrderEventStubs::filled(
8017 &order,
8018 ð_perp,
8019 Some(TradeId::new("1")),
8020 None,
8021 Some(Price::from("3000.0")),
8022 Some(Quantity::from("1.0")),
8023 None,
8024 Some(Money::new(0.001, eth_perp.base_currency().unwrap())),
8025 None,
8026 None,
8027 );
8028
8029 let position = Position::new(ð_perp, fill.into());
8030
8031 assert!(
8033 (position.quantity.as_f64() - 1.0).abs() < 1e-9,
8034 "Perpetual position should be 1.0 contracts (no adjustment), was {}",
8035 position.quantity.as_f64()
8036 );
8037
8038 assert!(
8040 (position.signed_qty - 1.0).abs() < 1e-9,
8041 "Signed qty should be 1.0, was {}",
8042 position.signed_qty
8043 );
8044 }
8045
8046 #[rstest]
8047 fn test_signed_decimal_qty_long(stub_position_long: Position) {
8048 let signed_qty = stub_position_long.signed_decimal_qty();
8049 assert!(signed_qty > Decimal::ZERO);
8050 assert_eq!(
8051 signed_qty,
8052 Decimal::try_from(stub_position_long.signed_qty).unwrap()
8053 );
8054 }
8055
8056 #[rstest]
8057 fn test_signed_decimal_qty_short(stub_position_short: Position) {
8058 let signed_qty = stub_position_short.signed_decimal_qty();
8059 assert!(signed_qty < Decimal::ZERO);
8060 assert_eq!(
8061 signed_qty,
8062 Decimal::try_from(stub_position_short.signed_qty).unwrap()
8063 );
8064 }
8065
8066 #[rstest]
8067 fn test_signed_decimal_qty_flat(audusd_sim: CurrencyPair) {
8068 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
8069 let order = OrderTestBuilder::new(OrderType::Market)
8070 .instrument_id(audusd_sim.id())
8071 .side(OrderSide::Buy)
8072 .quantity(Quantity::from(100_000))
8073 .build();
8074 let fill = TestOrderEventStubs::filled(
8075 &order,
8076 &audusd_sim,
8077 Some(TradeId::new("1")),
8078 None,
8079 Some(Price::from("1.00001")),
8080 None,
8081 None,
8082 None,
8083 None,
8084 None,
8085 );
8086 let mut position = Position::new(&audusd_sim, fill.into());
8087
8088 let close_order = OrderTestBuilder::new(OrderType::Market)
8089 .instrument_id(audusd_sim.id())
8090 .side(OrderSide::Sell)
8091 .quantity(Quantity::from(100_000))
8092 .build();
8093 let close_fill = TestOrderEventStubs::filled(
8094 &close_order,
8095 &audusd_sim,
8096 Some(TradeId::new("2")),
8097 None,
8098 Some(Price::from("1.00002")),
8099 None,
8100 None,
8101 None,
8102 None,
8103 None,
8104 );
8105 position.apply(&close_fill.into());
8106
8107 assert_eq!(position.side, PositionSide::Flat);
8108 assert_eq!(position.signed_decimal_qty(), Decimal::ZERO);
8109 }
8110
8111 #[rstest]
8112 fn test_position_flat_with_floating_point_precision_edge_case() {
8113 let btc_usdt = currency_pair_btcusdt();
8117 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
8118
8119 let order1 = OrderTestBuilder::new(OrderType::Market)
8120 .instrument_id(btc_usdt.id())
8121 .side(OrderSide::Buy)
8122 .quantity(Quantity::from("0.123456789"))
8123 .build();
8124 let fill1 = TestOrderEventStubs::filled(
8125 &order1,
8126 &btc_usdt,
8127 Some(TradeId::new("1")),
8128 None,
8129 Some(Price::from("50000.00")),
8130 None,
8131 None,
8132 None,
8133 None,
8134 None,
8135 );
8136 let mut position = Position::new(&btc_usdt, fill1.into());
8137
8138 assert_eq!(position.side, PositionSide::Long);
8139 assert!(position.quantity.is_positive());
8140
8141 let order2 = OrderTestBuilder::new(OrderType::Market)
8142 .instrument_id(btc_usdt.id())
8143 .side(OrderSide::Sell)
8144 .quantity(Quantity::from("0.123456789"))
8145 .build();
8146 let fill2 = TestOrderEventStubs::filled(
8147 &order2,
8148 &btc_usdt,
8149 Some(TradeId::new("2")),
8150 None,
8151 Some(Price::from("50000.00")),
8152 None,
8153 None,
8154 None,
8155 None,
8156 None,
8157 );
8158 position.apply(&fill2.into());
8159
8160 assert_eq!(
8161 position.side,
8162 PositionSide::Flat,
8163 "Position should be FLAT, not {:?}",
8164 position.side
8165 );
8166 assert!(
8167 position.quantity.is_zero(),
8168 "Quantity should be zero, was {}",
8169 position.quantity
8170 );
8171 assert_eq!(
8172 position.signed_qty, 0.0,
8173 "signed_qty should be normalized to 0.0, was {}",
8174 position.signed_qty
8175 );
8176 assert!(position.is_closed());
8177 }
8178
8179 #[rstest]
8180 #[case(OrderSide::Buy, OrderSide::Sell, "162.50", "176.50", 171.5)]
8181 #[case(OrderSide::Sell, OrderSide::Buy, "140.00", "126.00", 131.0)]
8182 fn test_position_exact_close_after_partial_fills_preserves_open_average(
8183 #[case] entry: OrderSide,
8184 #[case] exit: OrderSide,
8185 #[case] first_close_px: &str,
8186 #[case] final_close_px: &str,
8187 #[case] expected_avg_close: f64,
8188 ) {
8189 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
8190 let position_id = PositionId::new("P-PARTIAL-CLOSE");
8191 let open_order = OrderTestBuilder::new(OrderType::Market)
8192 .instrument_id(instrument.id())
8193 .client_order_id(ClientOrderId::new("O-OPEN"))
8194 .side(entry)
8195 .quantity(Quantity::from("0.7"))
8196 .build();
8197 let open_fill = TestOrderEventStubs::filled(
8198 &open_order,
8199 &instrument,
8200 Some(TradeId::new("T-OPEN")),
8201 Some(position_id),
8202 Some(Price::from("151.25")),
8203 None,
8204 None,
8205 Some(Money::from("0 USDT")),
8206 Some(UnixNanos::from(1_000)),
8207 None,
8208 );
8209 let mut position = Position::new(&instrument, open_fill.into());
8210
8211 for (client_order_id, trade_id, quantity, price, ts_event) in [
8212 ("O-CLOSE-1", "T-CLOSE-1", "0.25", first_close_px, 1_100),
8213 ("O-CLOSE-2", "T-CLOSE-2", "0.45", final_close_px, 1_250),
8214 ] {
8215 let close_order = OrderTestBuilder::new(OrderType::Market)
8216 .instrument_id(instrument.id())
8217 .client_order_id(ClientOrderId::new(client_order_id))
8218 .side(exit)
8219 .quantity(Quantity::from(quantity))
8220 .build();
8221 let close_fill = TestOrderEventStubs::filled(
8222 &close_order,
8223 &instrument,
8224 Some(TradeId::new(trade_id)),
8225 Some(position_id),
8226 Some(Price::from(price)),
8227 None,
8228 None,
8229 Some(Money::from("0 USDT")),
8230 Some(UnixNanos::from(ts_event)),
8231 None,
8232 );
8233 position.apply(&close_fill.into());
8234 }
8235
8236 assert_eq!(position.entry, entry);
8237 assert_eq!(position.side, PositionSide::Flat);
8238 assert_eq!(position.signed_qty, 0.0);
8239 assert_eq!(position.quantity, Quantity::zero(6));
8240 assert_eq!(position.peak_qty, Quantity::from("0.7"));
8241 assert_eq!(position.buy_qty, Quantity::from("0.7"));
8242 assert_eq!(position.sell_qty, Quantity::from("0.7"));
8243 assert_eq!(position.avg_px_open, 151.25);
8244 assert_eq!(position.avg_px_close, Some(expected_avg_close));
8245 assert_eq!(position.realized_return, 0.133_884_297_520_661_17);
8246 assert_eq!(position.realized_pnl, Some(Money::from("14.17500000 USDT")));
8247 assert_eq!(position.commissions(), vec![Money::from("0 USDT")]);
8248 assert_eq!(position.opening_order_id, ClientOrderId::new("O-OPEN"));
8249 assert_eq!(
8250 position.closing_order_id,
8251 Some(ClientOrderId::new("O-CLOSE-2"))
8252 );
8253 assert_eq!(position.ts_opened, UnixNanos::from(1_000));
8254 assert_eq!(position.ts_last, UnixNanos::from(1_250));
8255 assert_eq!(position.ts_closed, Some(UnixNanos::from(1_250)));
8256 assert_eq!(position.duration_ns, DurationNanos::new(250));
8257 assert_eq!(position.event_count(), 3);
8258 assert!(position.is_closed());
8259 }
8260
8261 #[rstest]
8262 #[case(
8263 OrderSide::Buy,
8264 OrderSide::Sell,
8265 "140.00",
8266 "126.00",
8267 PositionSide::Short,
8268 -0.000_001
8269 )]
8270 #[case(
8271 OrderSide::Sell,
8272 OrderSide::Buy,
8273 "162.50",
8274 "176.50",
8275 PositionSide::Long,
8276 0.000_001
8277 )]
8278 fn test_position_true_reversal_uses_fill_price(
8279 #[case] entry: OrderSide,
8280 #[case] exit: OrderSide,
8281 #[case] first_close_px: &str,
8282 #[case] reversal_px: &str,
8283 #[case] expected_side: PositionSide,
8284 #[case] expected_signed_qty: f64,
8285 ) {
8286 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
8287 let position_id = PositionId::new("P-REVERSAL");
8288 let open_order = OrderTestBuilder::new(OrderType::Market)
8289 .instrument_id(instrument.id())
8290 .client_order_id(ClientOrderId::new("O-REVERSAL-OPEN"))
8291 .side(entry)
8292 .quantity(Quantity::from("0.7"))
8293 .build();
8294 let open_fill = TestOrderEventStubs::filled(
8295 &open_order,
8296 &instrument,
8297 Some(TradeId::new("T-REVERSAL-OPEN")),
8298 Some(position_id),
8299 Some(Price::from("151.25")),
8300 None,
8301 None,
8302 Some(Money::from("0 USDT")),
8303 Some(UnixNanos::from(2_000)),
8304 None,
8305 );
8306 let mut position = Position::new(&instrument, open_fill.into());
8307
8308 let close_order = OrderTestBuilder::new(OrderType::Market)
8309 .instrument_id(instrument.id())
8310 .client_order_id(ClientOrderId::new("O-REVERSAL-CLOSE"))
8311 .side(exit)
8312 .quantity(Quantity::from("0.25"))
8313 .build();
8314 let close_fill = TestOrderEventStubs::filled(
8315 &close_order,
8316 &instrument,
8317 Some(TradeId::new("T-REVERSAL-CLOSE")),
8318 Some(position_id),
8319 Some(Price::from(first_close_px)),
8320 None,
8321 None,
8322 Some(Money::from("0 USDT")),
8323 Some(UnixNanos::from(2_050)),
8324 None,
8325 );
8326 position.apply(&close_fill.into());
8327
8328 let reversal_order = OrderTestBuilder::new(OrderType::Market)
8329 .instrument_id(instrument.id())
8330 .client_order_id(ClientOrderId::new("O-REVERSAL"))
8331 .side(exit)
8332 .quantity(Quantity::from("0.450001"))
8333 .build();
8334 let reversal_fill = TestOrderEventStubs::filled(
8335 &reversal_order,
8336 &instrument,
8337 Some(TradeId::new("T-REVERSAL")),
8338 Some(position_id),
8339 Some(Price::from(reversal_px)),
8340 None,
8341 None,
8342 Some(Money::from("0 USDT")),
8343 Some(UnixNanos::from(2_100)),
8344 None,
8345 );
8346 position.apply(&reversal_fill.into());
8347
8348 assert_eq!(position.entry, exit);
8349 assert_eq!(position.side, expected_side);
8350 assert!((position.signed_qty - expected_signed_qty).abs() < 1e-12);
8351 assert_eq!(position.quantity, Quantity::from("0.000001"));
8352 assert_eq!(position.peak_qty, Quantity::from("0.7"));
8353 assert_eq!(position.avg_px_open, Price::from(reversal_px).as_f64());
8354 assert_eq!(
8355 position.realized_pnl,
8356 Some(Money::from("-14.17500000 USDT"))
8357 );
8358 assert_eq!(position.commissions(), vec![Money::from("0 USDT")]);
8359 assert_eq!(
8360 position.opening_order_id,
8361 ClientOrderId::new("O-REVERSAL-OPEN")
8362 );
8363 assert_eq!(position.closing_order_id, None);
8364 assert_eq!(position.ts_opened, UnixNanos::from(2_000));
8365 assert_eq!(position.ts_last, UnixNanos::from(2_100));
8366 assert_eq!(position.ts_closed, None);
8367 assert_eq!(position.duration_ns, DurationNanos::default());
8368 assert_eq!(position.event_count(), 3);
8369 assert!(position.is_open());
8370 }
8371
8372 #[rstest]
8373 #[case(OrderSide::Buy, OrderSide::Sell)]
8374 #[case(OrderSide::Sell, OrderSide::Buy)]
8375 fn test_position_reversal_starts_new_close_episode(
8376 #[case] entry: OrderSide,
8377 #[case] exit: OrderSide,
8378 ) {
8379 let (
8380 open_px,
8381 first_close_px,
8382 reversal_px,
8383 expected_side,
8384 new_first_close_px,
8385 new_final_close_px,
8386 expected_avg_close,
8387 expected_return,
8388 ) = if entry == OrderSide::Buy {
8389 (
8390 "100.00",
8391 "110.00",
8392 "120.00",
8393 PositionSide::Short,
8394 "110.00",
8395 "90.00",
8396 95.0,
8397 0.208_333_333_333_333_34,
8398 )
8399 } else {
8400 (
8401 "120.00",
8402 "110.00",
8403 "100.00",
8404 PositionSide::Long,
8405 "110.00",
8406 "130.00",
8407 125.0,
8408 0.25,
8409 )
8410 };
8411 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
8412 let position_id = PositionId::new("P-REVERSAL-EPISODE");
8413 let open_order = OrderTestBuilder::new(OrderType::Market)
8414 .instrument_id(instrument.id())
8415 .client_order_id(ClientOrderId::new("O-OPEN"))
8416 .side(entry)
8417 .quantity(Quantity::from("10"))
8418 .build();
8419 let open_fill = TestOrderEventStubs::filled(
8420 &open_order,
8421 &instrument,
8422 Some(TradeId::new("T-OPEN")),
8423 Some(position_id),
8424 Some(Price::from(open_px)),
8425 None,
8426 None,
8427 Some(Money::from("1 USDT")),
8428 Some(UnixNanos::from(3_000)),
8429 None,
8430 );
8431 let mut position = Position::new(&instrument, open_fill.into());
8432
8433 for (client_order_id, trade_id, quantity, price, ts_event) in [
8434 ("O-CLOSE", "T-CLOSE", "4", first_close_px, 3_100),
8435 ("O-REVERSE", "T-REVERSE", "8", reversal_px, 3_200),
8436 ] {
8437 let order = OrderTestBuilder::new(OrderType::Market)
8438 .instrument_id(instrument.id())
8439 .client_order_id(ClientOrderId::new(client_order_id))
8440 .side(exit)
8441 .quantity(Quantity::from(quantity))
8442 .build();
8443 let fill = TestOrderEventStubs::filled(
8444 &order,
8445 &instrument,
8446 Some(TradeId::new(trade_id)),
8447 Some(position_id),
8448 Some(Price::from(price)),
8449 None,
8450 None,
8451 Some(Money::from("1 USDT")),
8452 Some(UnixNanos::from(ts_event)),
8453 None,
8454 );
8455 position.apply(&fill.into());
8456 }
8457
8458 assert_eq!(position.side, expected_side);
8459 assert_eq!(position.quantity, Quantity::from("2"));
8460 assert_eq!(position.avg_px_open, Price::from(reversal_px).as_f64());
8461 assert_eq!(position.avg_px_close, None);
8462 assert_eq!(position.realized_return, 0.0);
8463 if expected_side == PositionSide::Long {
8464 assert_eq!(position.buy_qty, Quantity::from("2"));
8465 assert_eq!(position.sell_qty, Quantity::from("0"));
8466 } else {
8467 assert_eq!(position.buy_qty, Quantity::from("0"));
8468 assert_eq!(position.sell_qty, Quantity::from("2"));
8469 }
8470 assert_eq!(position.realized_pnl, Some(Money::from("157 USDT")));
8471
8472 for (client_order_id, trade_id, quantity, price, ts_event) in [
8473 (
8474 "O-NEW-CLOSE-1",
8475 "T-NEW-CLOSE-1",
8476 "0.5",
8477 new_first_close_px,
8478 3_300,
8479 ),
8480 (
8481 "O-NEW-CLOSE-2",
8482 "T-NEW-CLOSE-2",
8483 "1.5",
8484 new_final_close_px,
8485 3_400,
8486 ),
8487 ] {
8488 let order = OrderTestBuilder::new(OrderType::Market)
8489 .instrument_id(instrument.id())
8490 .client_order_id(ClientOrderId::new(client_order_id))
8491 .side(entry)
8492 .quantity(Quantity::from(quantity))
8493 .build();
8494 let fill = TestOrderEventStubs::filled(
8495 &order,
8496 &instrument,
8497 Some(TradeId::new(trade_id)),
8498 Some(position_id),
8499 Some(Price::from(price)),
8500 None,
8501 None,
8502 Some(Money::from("1 USDT")),
8503 Some(UnixNanos::from(ts_event)),
8504 None,
8505 );
8506 position.apply(&fill.into());
8507 }
8508
8509 assert_eq!(position.side, PositionSide::Flat);
8510 assert_eq!(position.quantity, Quantity::zero(6));
8511 assert_eq!(position.buy_qty, Quantity::from("2"));
8512 assert_eq!(position.sell_qty, Quantity::from("2"));
8513 assert_eq!(position.avg_px_close, Some(expected_avg_close));
8514 assert_eq!(position.realized_return, expected_return);
8515 assert_eq!(position.realized_pnl, Some(Money::from("205 USDT")));
8516 assert_eq!(position.commissions(), vec![Money::from("5 USDT")]);
8517 }
8518
8519 #[rstest]
8520 fn test_position_adjustment_floating_point_precision_edge_case() {
8521 let btc_usdt = currency_pair_btcusdt();
8523 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
8524
8525 let order = OrderTestBuilder::new(OrderType::Market)
8526 .instrument_id(btc_usdt.id())
8527 .side(OrderSide::Buy)
8528 .quantity(Quantity::from("1.0"))
8529 .build();
8530 let fill = TestOrderEventStubs::filled(
8531 &order,
8532 &btc_usdt,
8533 Some(TradeId::new("1")),
8534 None,
8535 Some(Price::from("50000.00")),
8536 None,
8537 None,
8538 None,
8539 None,
8540 None,
8541 );
8542 let mut position = Position::new(&btc_usdt, fill.into());
8543
8544 let adjustment = PositionAdjusted::new(
8545 position.trader_id,
8546 position.strategy_id,
8547 position.instrument_id,
8548 position.id,
8549 position.account_id,
8550 PositionAdjustmentType::Commission,
8551 Some(Decimal::from_str("-1.0").unwrap()),
8552 None,
8553 None,
8554 uuid4(),
8555 UnixNanos::default(),
8556 UnixNanos::default(),
8557 );
8558 position.apply_adjustment(adjustment);
8559
8560 assert_eq!(
8561 position.side,
8562 PositionSide::Flat,
8563 "Position should be FLAT after zeroing adjustment"
8564 );
8565 assert!(
8566 position.quantity.is_zero(),
8567 "Quantity should be zero after adjustment"
8568 );
8569 assert_eq!(
8570 position.signed_qty, 0.0,
8571 "signed_qty should be normalized to 0.0"
8572 );
8573 }
8574
8575 #[rstest]
8576 fn test_position_spot_buy_partial_fills_with_base_commission() {
8577 let eth_usdt = currency_pair_ethusdt();
8580 let eth_usdt = InstrumentAny::CurrencyPair(eth_usdt);
8581
8582 let order1 = OrderTestBuilder::new(OrderType::Market)
8583 .instrument_id(eth_usdt.id())
8584 .side(OrderSide::Buy)
8585 .quantity(Quantity::from("0.00350"))
8586 .build();
8587
8588 let fill1 = TestOrderEventStubs::filled(
8589 &order1,
8590 ð_usdt,
8591 Some(TradeId::new("1")),
8592 None,
8593 Some(Price::from("2042.69")),
8594 Some(Quantity::from("0.00350")),
8595 None,
8596 Some(Money::new(0.00001, eth_usdt.base_currency().unwrap())),
8597 None,
8598 None,
8599 );
8600
8601 let mut position = Position::new(ð_usdt, fill1.into());
8602
8603 assert_eq!(position.quantity, Quantity::from("0.00349"));
8604 assert!((position.signed_qty - 0.00349).abs() < 1e-9);
8605 assert_eq!(position.side, PositionSide::Long);
8606 assert_eq!(position.adjustments.len(), 1);
8607 assert_eq!(
8608 position.adjustments[0].quantity_change,
8609 Some(rust_decimal_macros::dec!(-0.00001))
8610 );
8611
8612 let order2 = OrderTestBuilder::new(OrderType::Market)
8613 .instrument_id(eth_usdt.id())
8614 .side(OrderSide::Buy)
8615 .quantity(Quantity::from("0.00350"))
8616 .build();
8617
8618 let fill2 = TestOrderEventStubs::filled(
8619 &order2,
8620 ð_usdt,
8621 Some(TradeId::new("2")),
8622 None,
8623 Some(Price::from("2042.69")),
8624 Some(Quantity::from("0.00350")),
8625 None,
8626 Some(Money::new(0.00001, eth_usdt.base_currency().unwrap())),
8627 None,
8628 None,
8629 );
8630
8631 position.apply(&fill2.into());
8632
8633 assert_eq!(position.quantity, Quantity::from("0.00698"));
8634 assert!((position.signed_qty - 0.00698).abs() < 1e-9);
8635 assert_eq!(position.adjustments.len(), 2);
8636
8637 let order3 = OrderTestBuilder::new(OrderType::Market)
8638 .instrument_id(eth_usdt.id())
8639 .side(OrderSide::Buy)
8640 .quantity(Quantity::from("0.00300"))
8641 .build();
8642
8643 let fill3 = TestOrderEventStubs::filled(
8644 &order3,
8645 ð_usdt,
8646 Some(TradeId::new("3")),
8647 None,
8648 Some(Price::from("2042.69")),
8649 Some(Quantity::from("0.00300")),
8650 None,
8651 Some(Money::new(0.00001, eth_usdt.base_currency().unwrap())),
8652 None,
8653 None,
8654 );
8655
8656 position.apply(&fill3.into());
8657
8658 assert_eq!(position.quantity, Quantity::from("0.00997"));
8661 assert!((position.signed_qty - 0.00997).abs() < 1e-9);
8662 assert_eq!(position.side, PositionSide::Long);
8663 assert_eq!(position.adjustments.len(), 3);
8664
8665 assert_eq!(position.buy_qty, Quantity::from("0.01000"));
8667 }
8668
8669 #[rstest]
8670 fn test_position_spot_sell_partial_fills_with_base_commission() {
8671 let btc_usdt = currency_pair_btcusdt();
8672 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
8673
8674 let order1 = OrderTestBuilder::new(OrderType::Market)
8675 .instrument_id(btc_usdt.id())
8676 .side(OrderSide::Sell)
8677 .quantity(Quantity::from("0.5"))
8678 .build();
8679
8680 let fill1 = TestOrderEventStubs::filled(
8681 &order1,
8682 &btc_usdt,
8683 Some(TradeId::new("1")),
8684 None,
8685 Some(Price::from("50000.0")),
8686 Some(Quantity::from("0.5")),
8687 None,
8688 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
8689 None,
8690 None,
8691 );
8692
8693 let mut position = Position::new(&btc_usdt, fill1.into());
8694
8695 assert!((position.signed_qty - (-0.501)).abs() < 1e-9);
8697 assert_eq!(position.side, PositionSide::Short);
8698 assert_eq!(position.adjustments.len(), 1);
8699
8700 let order2 = OrderTestBuilder::new(OrderType::Market)
8701 .instrument_id(btc_usdt.id())
8702 .side(OrderSide::Sell)
8703 .quantity(Quantity::from("0.5"))
8704 .build();
8705
8706 let fill2 = TestOrderEventStubs::filled(
8707 &order2,
8708 &btc_usdt,
8709 Some(TradeId::new("2")),
8710 None,
8711 Some(Price::from("50000.0")),
8712 Some(Quantity::from("0.5")),
8713 None,
8714 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
8715 None,
8716 None,
8717 );
8718
8719 position.apply(&fill2.into());
8720
8721 assert!((position.signed_qty - (-1.002)).abs() < 1e-9);
8723 assert!((position.quantity.as_f64() - 1.002).abs() < 1e-9);
8724 assert_eq!(position.adjustments.len(), 2);
8725 assert_eq!(position.sell_qty, Quantity::from("1.0"));
8726 }
8727
8728 #[rstest]
8729 fn test_position_spot_round_trip_close_flat_with_quote_commission() {
8730 let eth_usdt = currency_pair_ethusdt();
8731 let eth_usdt = InstrumentAny::CurrencyPair(eth_usdt);
8732
8733 let buy_order = OrderTestBuilder::new(OrderType::Market)
8734 .instrument_id(eth_usdt.id())
8735 .side(OrderSide::Buy)
8736 .quantity(Quantity::from("1.00000"))
8737 .build();
8738
8739 let buy_fill = TestOrderEventStubs::filled(
8740 &buy_order,
8741 ð_usdt,
8742 Some(TradeId::new("1")),
8743 None,
8744 Some(Price::from("2000.00")),
8745 Some(Quantity::from("1.00000")),
8746 None,
8747 Some(Money::new(0.001, eth_usdt.base_currency().unwrap())),
8748 None,
8749 None,
8750 );
8751
8752 let mut position = Position::new(ð_usdt, buy_fill.into());
8753
8754 assert_eq!(position.quantity, Quantity::from("0.99900"));
8756 assert_eq!(position.side, PositionSide::Long);
8757
8758 let sell_order = OrderTestBuilder::new(OrderType::Market)
8759 .instrument_id(eth_usdt.id())
8760 .side(OrderSide::Sell)
8761 .quantity(Quantity::from("0.99900"))
8762 .build();
8763
8764 let sell_fill = TestOrderEventStubs::filled(
8765 &sell_order,
8766 ð_usdt,
8767 Some(TradeId::new("2")),
8768 None,
8769 Some(Price::from("2100.00")),
8770 Some(Quantity::from("0.99900")),
8771 None,
8772 Some(Money::new(2.0, Currency::USDT())),
8773 None,
8774 None,
8775 );
8776
8777 position.apply(&sell_fill.into());
8778
8779 assert_eq!(position.side, PositionSide::Flat);
8780 assert_eq!(position.signed_qty, 0.0);
8781 assert!(position.is_closed());
8782 assert_eq!(position.adjustments.len(), 1);
8784
8785 let realized = position.realized_pnl.unwrap().as_f64();
8787 assert!(
8788 (realized - 97.9).abs() < 0.01,
8789 "Realized PnL should be ~97.90 USDT, was {realized}"
8790 );
8791 }
8792
8793 #[rstest]
8794 fn test_position_spot_commission_accumulation_multiple_partial_fills() {
8795 let eth_usdt = currency_pair_ethusdt();
8796 let eth_usdt = InstrumentAny::CurrencyPair(eth_usdt);
8797
8798 let order1 = OrderTestBuilder::new(OrderType::Market)
8799 .instrument_id(eth_usdt.id())
8800 .side(OrderSide::Buy)
8801 .quantity(Quantity::from("0.50000"))
8802 .build();
8803
8804 let fill1 = TestOrderEventStubs::filled(
8805 &order1,
8806 ð_usdt,
8807 Some(TradeId::new("1")),
8808 None,
8809 Some(Price::from("2000.00")),
8810 Some(Quantity::from("0.50000")),
8811 None,
8812 Some(Money::new(0.0005, eth_usdt.base_currency().unwrap())),
8813 None,
8814 None,
8815 );
8816
8817 let mut position = Position::new(ð_usdt, fill1.into());
8818
8819 let order2 = OrderTestBuilder::new(OrderType::Market)
8820 .instrument_id(eth_usdt.id())
8821 .side(OrderSide::Buy)
8822 .quantity(Quantity::from("0.50000"))
8823 .build();
8824
8825 let fill2 = TestOrderEventStubs::filled(
8826 &order2,
8827 ð_usdt,
8828 Some(TradeId::new("2")),
8829 None,
8830 Some(Price::from("2010.00")),
8831 Some(Quantity::from("0.50000")),
8832 None,
8833 Some(Money::new(0.0005, eth_usdt.base_currency().unwrap())),
8834 None,
8835 None,
8836 );
8837
8838 position.apply(&fill2.into());
8839
8840 assert_eq!(position.quantity, Quantity::from("0.99900"));
8842 assert_eq!(position.buy_qty, Quantity::from("1.00000"));
8843
8844 assert_eq!(position.adjustments.len(), 2);
8845 for adj in &position.adjustments {
8846 assert_eq!(adj.adjustment_type, PositionAdjustmentType::Commission);
8847 assert_eq!(
8848 adj.quantity_change,
8849 Some(rust_decimal_macros::dec!(-0.0005))
8850 );
8851 }
8852
8853 let commissions = position.commissions();
8854 assert_eq!(commissions.len(), 1);
8855 let eth_commission = commissions[0];
8856 assert!(
8857 (eth_commission.as_f64() - 0.001).abs() < 1e-9,
8858 "Total ETH commission should be 0.001, was {}",
8859 eth_commission.as_f64()
8860 );
8861 }
8862
8863 #[rstest]
8864 fn test_position_apply_fill_with_earlier_timestamp_adjusts_ts_opened(audusd_sim: CurrencyPair) {
8865 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
8866 let order1 = OrderTestBuilder::new(OrderType::Market)
8867 .instrument_id(audusd_sim.id())
8868 .side(OrderSide::Buy)
8869 .quantity(Quantity::from(100_000))
8870 .build();
8871 let order2 = OrderTestBuilder::new(OrderType::Market)
8872 .instrument_id(audusd_sim.id())
8873 .side(OrderSide::Buy)
8874 .quantity(Quantity::from(100_000))
8875 .build();
8876
8877 let fill1 = TestOrderEventStubs::filled(
8879 &order1,
8880 &audusd_sim,
8881 Some(TradeId::new("t1")),
8882 None,
8883 Some(Price::from("1.00001")),
8884 None,
8885 None,
8886 None,
8887 Some(UnixNanos::from(2_000u64)),
8888 None,
8889 );
8890 let mut position = Position::new(&audusd_sim, fill1.into());
8891 assert_eq!(position.ts_opened, UnixNanos::from(2_000u64));
8892
8893 let fill2 = TestOrderEventStubs::filled(
8895 &order2,
8896 &audusd_sim,
8897 Some(TradeId::new("t2")),
8898 None,
8899 Some(Price::from("1.00002")),
8900 None,
8901 None,
8902 None,
8903 Some(UnixNanos::from(1_000u64)),
8904 None,
8905 );
8906
8907 position.apply(&fill2.into());
8909 assert_eq!(position.ts_opened, UnixNanos::from(2_000u64));
8910 assert_eq!(position.opening_order_id, order1.client_order_id());
8911 assert_eq!(position.events.len(), 2);
8912 }
8913
8914 #[rstest]
8915 fn test_position_close_before_open_clamps_duration(audusd_sim: CurrencyPair) {
8916 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
8917 let opening_order = OrderTestBuilder::new(OrderType::Market)
8918 .instrument_id(audusd_sim.id())
8919 .side(OrderSide::Buy)
8920 .quantity(Quantity::from(100_000))
8921 .build();
8922 let closing_order = OrderTestBuilder::new(OrderType::Market)
8923 .instrument_id(audusd_sim.id())
8924 .side(OrderSide::Sell)
8925 .quantity(Quantity::from(100_000))
8926 .build();
8927 let opening_fill = TestOrderEventStubs::filled(
8928 &opening_order,
8929 &audusd_sim,
8930 Some(TradeId::new("OPEN")),
8931 None,
8932 Some(Price::from("1.00001")),
8933 None,
8934 None,
8935 None,
8936 Some(UnixNanos::from(2_000u64)),
8937 None,
8938 );
8939 let closing_fill = TestOrderEventStubs::filled(
8940 &closing_order,
8941 &audusd_sim,
8942 Some(TradeId::new("CLOSE")),
8943 None,
8944 Some(Price::from("1.00002")),
8945 None,
8946 None,
8947 None,
8948 Some(UnixNanos::from(1_000u64)),
8949 None,
8950 );
8951 let mut position = Position::new(&audusd_sim, opening_fill.into());
8952
8953 position.apply(&closing_fill.into());
8954
8955 assert_eq!(position.side, PositionSide::Flat);
8956 assert_eq!(position.ts_opened, UnixNanos::from(2_000u64));
8957 assert_eq!(position.ts_closed, Some(UnixNanos::from(1_000u64)));
8958 assert_eq!(position.duration_ns, DurationNanos::default());
8959 assert_eq!(
8960 position.closing_order_id,
8961 Some(closing_order.client_order_id())
8962 );
8963 }
8964
8965 #[rstest]
8966 fn test_position_commissions_multi_currency_insertion_order(audusd_sim: CurrencyPair) {
8967 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
8972 let order_template = OrderTestBuilder::new(OrderType::Market)
8973 .instrument_id(audusd_sim.id())
8974 .side(OrderSide::Buy)
8975 .quantity(Quantity::from(100_000))
8976 .build();
8977
8978 let fill_usd = TestOrderEventStubs::filled(
8979 &order_template,
8980 &audusd_sim,
8981 Some(TradeId::new("t1")),
8982 None,
8983 Some(Price::from("1.00001")),
8984 None,
8985 None,
8986 Some(Money::from("1.0 USD")),
8987 None,
8988 None,
8989 );
8990 let mut position = Position::new(&audusd_sim, fill_usd.into());
8991
8992 let fill_usdt = TestOrderEventStubs::filled(
8993 &order_template,
8994 &audusd_sim,
8995 Some(TradeId::new("t2")),
8996 None,
8997 Some(Price::from("1.00001")),
8998 None,
8999 None,
9000 Some(Money::from("2.0 USDT")),
9001 None,
9002 None,
9003 );
9004 position.apply(&fill_usdt.into());
9005
9006 let fill_usd_again = TestOrderEventStubs::filled(
9007 &order_template,
9008 &audusd_sim,
9009 Some(TradeId::new("t3")),
9010 None,
9011 Some(Price::from("1.00001")),
9012 None,
9013 None,
9014 Some(Money::from("0.5 USD")),
9015 None,
9016 None,
9017 );
9018 position.apply(&fill_usd_again.into());
9019
9020 let fill_btc = TestOrderEventStubs::filled(
9021 &order_template,
9022 &audusd_sim,
9023 Some(TradeId::new("t4")),
9024 None,
9025 Some(Price::from("1.00001")),
9026 None,
9027 None,
9028 Some(Money::from("0.0001 BTC")),
9029 None,
9030 None,
9031 );
9032 position.apply(&fill_btc.into());
9033
9034 assert_eq!(
9037 position.commissions(),
9038 vec![
9039 Money::from("1.5 USD"),
9040 Money::from("2.0 USDT"),
9041 Money::from("0.0001 BTC"),
9042 ]
9043 );
9044 }
9045
9046 #[rstest]
9047 fn test_fold_net_position_empty() {
9048 let (net_qty, net_px) = fold_net_position(&[]);
9049 assert_eq!(net_qty, Decimal::ZERO);
9050 assert_eq!(net_px, Decimal::ZERO);
9051 }
9052
9053 #[rstest]
9054 fn test_fold_net_position_single_long() {
9055 let legs = [(dec!(100), dec!(1.5), 1u64)];
9056 let (net_qty, net_px) = fold_net_position(&legs);
9057 assert_eq!(net_qty, dec!(100));
9058 assert_eq!(net_px, dec!(1.5));
9059 }
9060
9061 #[rstest]
9062 fn test_fold_net_position_single_short() {
9063 let legs = [(dec!(-100), dec!(1.5), 1u64)];
9064 let (net_qty, net_px) = fold_net_position(&legs);
9065 assert_eq!(net_qty, dec!(-100));
9066 assert_eq!(net_px, dec!(1.5));
9067 }
9068
9069 #[rstest]
9070 fn test_fold_net_position_same_side_weighted_average() {
9071 let legs = [(dec!(100), dec!(1.0), 1u64), (dec!(200), dec!(0.5), 2u64)];
9073 let (net_qty, net_px) = fold_net_position(&legs);
9074 assert_eq!(net_qty, dec!(300));
9075 assert_eq!(net_px, dec!(200) / dec!(300));
9077 }
9078
9079 #[rstest]
9080 fn test_fold_net_position_partial_close_preserves_avg() {
9081 let legs = [
9083 (dec!(300), dec!(0.80), 1u64),
9084 (dec!(-100), dec!(1.00), 2u64),
9085 ];
9086 let (net_qty, net_px) = fold_net_position(&legs);
9087 assert_eq!(net_qty, dec!(200));
9088 assert_eq!(net_px, dec!(0.80));
9089 }
9090
9091 #[rstest]
9092 fn test_fold_net_position_full_close() {
9093 let legs = [(dec!(100), dec!(1.0), 1u64), (dec!(-100), dec!(2.0), 2u64)];
9094 let (net_qty, net_px) = fold_net_position(&legs);
9095 assert_eq!(net_qty, Decimal::ZERO);
9096 assert_eq!(net_px, Decimal::ZERO);
9097 }
9098
9099 #[rstest]
9100 fn test_fold_net_position_single_flip_uses_flipping_price() {
9101 let legs = [
9103 (dec!(100), dec!(1.00), 1u64),
9104 (dec!(-50), dec!(2.00), 2u64),
9105 (dec!(-100), dec!(3.00), 3u64),
9106 ];
9107 let (net_qty, net_px) = fold_net_position(&legs);
9108 assert_eq!(net_qty, dec!(-50));
9109 assert_eq!(net_px, dec!(3.00));
9110 }
9111
9112 #[rstest]
9113 fn test_fold_net_position_double_flip() {
9114 let legs = [
9116 (dec!(50), dec!(1.00), 1u64),
9117 (dec!(-100), dec!(2.00), 2u64),
9118 (dec!(100), dec!(3.00), 3u64),
9119 ];
9120 let (net_qty, net_px) = fold_net_position(&legs);
9121 assert_eq!(net_qty, dec!(50));
9122 assert_eq!(net_px, dec!(3.00));
9123 }
9124
9125 #[rstest]
9126 fn test_fold_net_position_zero_quantity_legs_skipped() {
9127 let legs = [
9129 (dec!(100), dec!(1.0), 1u64),
9130 (Decimal::ZERO, dec!(99.0), 2u64),
9131 (dec!(50), dec!(2.0), 3u64),
9132 ];
9133 let (net_qty, net_px) = fold_net_position(&legs);
9134 assert_eq!(net_qty, dec!(150));
9135 assert_eq!(net_px, dec!(200) / dec!(150));
9137 }
9138
9139 #[rstest]
9140 fn test_fold_net_position_stable_sort_preserves_input_order_for_equal_ts() {
9141 let leg_a = (dec!(100), dec!(1.00), 1u64);
9143 let leg_b = (dec!(-100), dec!(2.00), 1u64);
9144
9145 let ab = [leg_a, leg_b];
9146 let ba = [leg_b, leg_a];
9147
9148 assert_eq!(fold_net_position(&ab), (Decimal::ZERO, Decimal::ZERO));
9150 assert_eq!(fold_net_position(&ba), (Decimal::ZERO, Decimal::ZERO));
9152
9153 let leg_c = (dec!(150), dec!(1.00), 1u64);
9155 let leg_d = (dec!(-100), dec!(2.00), 1u64);
9156 let cd = [leg_c, leg_d];
9157 let dc = [leg_d, leg_c];
9158 assert_eq!(fold_net_position(&cd), (dec!(50), dec!(1.00)));
9160 assert_eq!(fold_net_position(&dc), (dec!(50), dec!(1.00)));
9162 }
9163
9164 #[rstest]
9165 fn test_fold_net_position_close_then_reopen() {
9166 let legs = [
9168 (dec!(100), dec!(1.00), 1u64),
9169 (dec!(-100), dec!(1.50), 2u64),
9170 (dec!(50), dec!(3.00), 3u64),
9171 ];
9172 let (net_qty, net_px) = fold_net_position(&legs);
9173 assert_eq!(net_qty, dec!(50));
9174 assert_eq!(net_px, dec!(3.00));
9175 }
9176
9177 #[rstest]
9178 fn test_fold_net_position_orders_by_ts_opened() {
9179 let in_order = [
9181 (dec!(100), dec!(1.00), 1u64),
9182 (dec!(-50), dec!(2.00), 2u64),
9183 (dec!(-100), dec!(3.00), 3u64),
9184 ];
9185 let shuffled = [
9186 (dec!(-100), dec!(3.00), 3u64),
9187 (dec!(100), dec!(1.00), 1u64),
9188 (dec!(-50), dec!(2.00), 2u64),
9189 ];
9190 assert_eq!(fold_net_position(&in_order), fold_net_position(&shuffled));
9191 }
9192
9193 fn netting_reference(
9196 instrument: &InstrumentAny,
9197 fills: &[(OrderSide, u32, u32, u64)],
9198 ) -> (Decimal, Decimal) {
9199 let mut sorted_fills = fills.to_vec();
9200 sorted_fills.sort_by_key(|(_, _, _, ts)| *ts);
9201
9202 let mut position: Option<Position> = None;
9203
9204 for (idx, &(side, qty, px, ts)) in sorted_fills.iter().enumerate() {
9205 let order = OrderTestBuilder::new(OrderType::Market)
9206 .instrument_id(instrument.id())
9207 .side(side)
9208 .quantity(Quantity::from(qty))
9209 .build();
9210 let fill = TestOrderEventStubs::filled(
9211 &order,
9212 instrument,
9213 Some(TradeId::new(format!("T{idx}").as_str())),
9214 Some(PositionId::new("P-NET")),
9215 Some(Price::from(px.to_string().as_str())),
9216 None,
9217 None,
9218 Some(Money::new(0.0, instrument.quote_currency())),
9219 Some(UnixNanos::from(ts)),
9220 None,
9221 );
9222 let event: OrderFilled = fill.into();
9223 if let Some(p) = position.as_mut() {
9224 p.apply(&event);
9225 } else {
9226 position = Some(Position::new(instrument, event));
9227 }
9228 }
9229 let p = position.expect("at least one fill");
9230 let signed = Decimal::try_from(p.signed_qty).unwrap_or(Decimal::ZERO);
9231 let px = Decimal::try_from(p.avg_px_open).unwrap_or(Decimal::ZERO);
9232 (signed, px)
9233 }
9234
9235 fn hedging_legs(fills: &[(OrderSide, u32, u32, u64)]) -> Vec<(Decimal, Decimal, u64)> {
9237 fills
9238 .iter()
9239 .map(|&(side, qty, px, ts)| {
9240 let signed = if side == OrderSide::Buy {
9241 Decimal::from(qty)
9242 } else {
9243 -Decimal::from(qty)
9244 };
9245 (signed, Decimal::from(px), ts)
9246 })
9247 .collect()
9248 }
9249
9250 proptest! {
9251 #[rstest]
9257 fn prop_fold_matches_netting_replay(
9258 fills in proptest::collection::vec(
9259 (
9260 prop_oneof![Just(OrderSide::Buy), Just(OrderSide::Sell)],
9261 1u32..1_000u32,
9262 1u32..100u32,
9263 0u64..1_000_000u64,
9264 ),
9265 1..6,
9266 )
9267 ) {
9268 let mut seen_ts: AHashSet<u64> = AHashSet::new();
9271 for &(_, _, _, ts) in &fills {
9272 if !seen_ts.insert(ts) {
9273 prop_assume!(false);
9274 }
9275 }
9276
9277 let mut sorted_fills = fills.clone();
9281 sorted_fills.sort_by_key(|(_, _, _, ts)| *ts);
9282 let mut running: i64 = 0;
9283 let mut zero_mid = false;
9284
9285 for (idx, &(side, qty, _, _)) in sorted_fills.iter().enumerate() {
9286 let qty_i64 = i64::from(qty);
9287 let signed: i64 = if side == OrderSide::Buy {
9288 qty_i64
9289 } else {
9290 -qty_i64
9291 };
9292 running += signed;
9293 if idx + 1 < sorted_fills.len() && running == 0 {
9294 zero_mid = true;
9295 break;
9296 }
9297 }
9298 prop_assume!(!zero_mid);
9299
9300 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
9301 let (ref_qty, ref_px) = netting_reference(&instrument, &fills);
9302 let legs = hedging_legs(&fills);
9303 let (fold_qty, fold_px) = fold_net_position(&legs);
9304
9305 prop_assert_eq!(fold_qty, ref_qty);
9306
9307 if !ref_qty.is_zero() {
9311 let fold_px_f64 = fold_px.to_f64().unwrap_or(0.0);
9312 let ref_px_f64 = ref_px.to_f64().unwrap_or(0.0);
9313 let max_mag = fold_px_f64.abs().max(ref_px_f64.abs()).max(1.0);
9314 prop_assert!(
9315 (fold_px_f64 - ref_px_f64).abs() < 1e-9 * max_mag,
9316 "fold_px {fold_px_f64} vs ref_px {ref_px_f64}",
9317 );
9318 }
9319 }
9320 }
9321
9322 fn matching_fill_void(
9323 fill: &OrderFilled,
9324 voided_qty: Quantity,
9325 commission_voided: Option<Money>,
9326 ) -> OrderFillVoided {
9327 OrderFillVoidedSpec::builder()
9328 .trader_id(fill.trader_id)
9329 .strategy_id(fill.strategy_id)
9330 .instrument_id(fill.instrument_id)
9331 .client_order_id(fill.client_order_id)
9332 .venue_order_id(fill.venue_order_id)
9333 .account_id(fill.account_id)
9334 .trade_id(fill.trade_id)
9335 .voided_qty(voided_qty)
9336 .order_side(fill.order_side)
9337 .order_type(fill.order_type)
9338 .last_px(fill.last_px)
9339 .currency(fill.currency)
9340 .liquidity_side(fill.liquidity_side)
9341 .maybe_position_id(fill.position_id)
9342 .maybe_commission_voided(commission_voided)
9343 .build()
9344 }
9345}