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