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