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