1use std::{
22 fmt::Display,
23 hash::{Hash, Hasher},
24};
25
26use ahash::AHashSet;
27use indexmap::IndexMap;
28use nautilus_core::{
29 UUID4, UnixNanos,
30 correctness::{FAILED, check_equal, check_predicate_true},
31};
32use rust_decimal::{Decimal, prelude::ToPrimitive};
33use serde::{Deserialize, Serialize};
34
35use crate::{
36 enums::{InstrumentClass, OrderSide, OrderSideSpecified, PositionAdjustmentType, PositionSide},
37 events::{OrderFilled, PositionAdjusted},
38 identifiers::{
39 AccountId, ClientOrderId, InstrumentId, PositionId, StrategyId, Symbol, TradeId, TraderId,
40 Venue, VenueOrderId,
41 },
42 instruments::{Instrument, InstrumentAny},
43 types::{Currency, Money, Price, Quantity},
44};
45
46#[repr(C)]
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[cfg_attr(
53 feature = "python",
54 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
55)]
56#[cfg_attr(
57 feature = "python",
58 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
59)]
60pub struct Position {
61 pub events: Vec<OrderFilled>,
62 pub adjustments: Vec<PositionAdjusted>,
63 pub trader_id: TraderId,
64 pub strategy_id: StrategyId,
65 pub instrument_id: InstrumentId,
66 pub id: PositionId,
67 pub account_id: AccountId,
68 pub opening_order_id: ClientOrderId,
69 pub closing_order_id: Option<ClientOrderId>,
70 pub entry: OrderSide,
71 pub side: PositionSide,
72 pub signed_qty: f64,
73 pub quantity: Quantity,
74 pub peak_qty: Quantity,
75 pub price_precision: u8,
76 pub size_precision: u8,
77 pub multiplier: Quantity,
78 pub is_inverse: bool,
79 pub is_currency_pair: bool,
80 pub instrument_class: InstrumentClass,
81 pub base_currency: Option<Currency>,
82 pub quote_currency: Currency,
83 pub settlement_currency: Currency,
84 pub ts_init: UnixNanos,
85 pub ts_opened: UnixNanos,
86 pub ts_last: UnixNanos,
87 pub ts_closed: Option<UnixNanos>,
88 pub duration_ns: u64,
89 pub avg_px_open: f64,
90 pub avg_px_close: Option<f64>,
91 pub realized_return: f64,
92 pub realized_pnl: Option<Money>,
93 #[serde(with = "nautilus_core::serialization::sorted_hashset")]
94 pub trade_ids: AHashSet<TradeId>,
95 pub buy_qty: Quantity,
96 pub sell_qty: Quantity,
97 pub commissions: IndexMap<Currency, Money>,
98}
99
100impl Position {
101 #[must_use]
110 pub fn new(instrument: &InstrumentAny, fill: OrderFilled) -> Self {
111 check_equal(
112 &instrument.id(),
113 &fill.instrument_id,
114 "instrument.id()",
115 "fill.instrument_id",
116 )
117 .expect(FAILED);
118 assert_ne!(fill.order_side, OrderSide::NoOrderSide);
119
120 let position_id = fill.position_id.expect("No position ID to open `Position`");
121
122 let mut item = Self {
123 events: Vec::<OrderFilled>::new(),
124 adjustments: Vec::<PositionAdjusted>::new(),
125 trade_ids: AHashSet::<TradeId>::new(),
126 buy_qty: Quantity::zero(instrument.size_precision()),
127 sell_qty: Quantity::zero(instrument.size_precision()),
128 commissions: IndexMap::<Currency, Money>::new(),
129 trader_id: fill.trader_id,
130 strategy_id: fill.strategy_id,
131 instrument_id: fill.instrument_id,
132 id: position_id,
133 account_id: fill.account_id,
134 opening_order_id: fill.client_order_id,
135 closing_order_id: None,
136 entry: fill.order_side,
137 side: PositionSide::Flat,
138 signed_qty: 0.0,
139 quantity: fill.last_qty,
140 peak_qty: fill.last_qty,
141 price_precision: instrument.price_precision(),
142 size_precision: instrument.size_precision(),
143 multiplier: instrument.multiplier(),
144 is_inverse: instrument.is_inverse(),
145 is_currency_pair: matches!(instrument, InstrumentAny::CurrencyPair(_)),
146 instrument_class: instrument.instrument_class(),
147 base_currency: instrument.base_currency(),
148 quote_currency: instrument.quote_currency(),
149 settlement_currency: instrument.cost_currency(),
150 ts_init: fill.ts_init,
151 ts_opened: fill.ts_event,
152 ts_last: fill.ts_event,
153 ts_closed: None,
154 duration_ns: 0,
155 avg_px_open: fill.last_px.as_f64(),
156 avg_px_close: None,
157 realized_return: 0.0,
158 realized_pnl: None,
159 };
160 item.apply(&fill);
161 item
162 }
163
164 pub fn purge_events_for_order(&mut self, client_order_id: ClientOrderId) {
175 let filtered_events: Vec<OrderFilled> = self
176 .events
177 .iter()
178 .filter(|e| e.client_order_id != client_order_id)
179 .copied()
180 .collect();
181
182 let preserved_adjustments: Vec<PositionAdjusted> = self
185 .adjustments
186 .iter()
187 .filter(|adj| {
188 adj.adjustment_type != PositionAdjustmentType::Commission
191 })
192 .copied()
193 .collect();
194
195 if filtered_events.is_empty() {
197 log::warn!(
198 "Position {} has no fills remaining after purging order {}; consider closing the position instead",
199 self.id,
200 client_order_id
201 );
202 self.events.clear();
203 self.trade_ids.clear();
204 self.adjustments.clear();
205 self.buy_qty = Quantity::zero(self.size_precision);
206 self.sell_qty = Quantity::zero(self.size_precision);
207 self.commissions.clear();
208 self.signed_qty = 0.0;
209 self.quantity = Quantity::zero(self.size_precision);
210 self.side = PositionSide::Flat;
211 self.avg_px_close = None;
212 self.realized_pnl = None;
213 self.realized_return = 0.0;
214 self.ts_opened = UnixNanos::default();
215 self.ts_last = UnixNanos::default();
216 self.ts_closed = Some(UnixNanos::default());
217 self.duration_ns = 0;
218 return;
219 }
220
221 let position_id = self.id;
223 let size_precision = self.size_precision;
224
225 self.events = Vec::new();
227 self.trade_ids = AHashSet::new();
228 self.adjustments = Vec::new();
229 self.buy_qty = Quantity::zero(size_precision);
230 self.sell_qty = Quantity::zero(size_precision);
231 self.commissions.clear();
232 self.signed_qty = 0.0;
233 self.quantity = Quantity::zero(size_precision);
234 self.peak_qty = Quantity::zero(size_precision);
235 self.side = PositionSide::Flat;
236 self.avg_px_open = 0.0;
237 self.avg_px_close = None;
238 self.realized_pnl = None;
239 self.realized_return = 0.0;
240
241 let first_event = &filtered_events[0];
243 self.entry = first_event.order_side;
244 self.opening_order_id = first_event.client_order_id;
245 self.ts_opened = first_event.ts_event;
246 self.ts_init = first_event.ts_init;
247 self.closing_order_id = None;
248 self.ts_closed = None;
249 self.duration_ns = 0;
250
251 for event in filtered_events {
253 self.apply(&event);
254 }
255
256 for adjustment in preserved_adjustments {
258 self.apply_adjustment(adjustment);
259 }
260
261 log::info!(
262 "Purged fills for order {} from position {}; recalculated state: qty={}, signed_qty={}, side={:?}",
263 client_order_id,
264 position_id,
265 self.quantity,
266 self.signed_qty,
267 self.side
268 );
269 }
270
271 pub fn apply(&mut self, fill: &OrderFilled) {
277 check_predicate_true(
278 !self.trade_ids.contains(&fill.trade_id),
279 "`fill.trade_id` already contained in `trade_ids",
280 )
281 .expect(FAILED);
282
283 if fill.ts_event < self.ts_opened {
284 log::warn!(
285 "Fill ts_event {} for {} is before position ts_opened {}",
286 fill.ts_event,
287 self.id,
288 self.ts_opened,
289 );
290 }
291
292 if self.side == PositionSide::Flat {
293 self.events.clear();
295 self.trade_ids.clear();
296 self.adjustments.clear();
297 self.buy_qty = Quantity::zero(self.size_precision);
298 self.sell_qty = Quantity::zero(self.size_precision);
299 self.commissions.clear();
300 self.opening_order_id = fill.client_order_id;
301 self.closing_order_id = None;
302 self.peak_qty = Quantity::zero(self.size_precision);
303 self.ts_init = fill.ts_init;
304 self.ts_opened = fill.ts_event;
305 self.ts_closed = None;
306 self.duration_ns = 0;
307 self.avg_px_open = fill.last_px.as_f64();
308 self.avg_px_close = None;
309 self.realized_return = 0.0;
310 self.realized_pnl = None;
311 }
312
313 self.events.push(*fill);
314 self.trade_ids.insert(fill.trade_id);
315
316 if let Some(commission) = fill.commission {
318 let commission_currency = commission.currency;
319 if let Some(existing_commission) = self.commissions.get_mut(&commission_currency) {
320 *existing_commission = *existing_commission + commission;
321 } else {
322 self.commissions.insert(commission_currency, commission);
323 }
324 }
325
326 match fill.specified_side() {
328 OrderSideSpecified::Buy => {
329 self.handle_buy_order_fill(fill);
330 }
331 OrderSideSpecified::Sell => {
332 self.handle_sell_order_fill(fill);
333 }
334 }
335
336 if self.is_currency_pair
338 && let Some(commission) = fill.commission
339 && let Some(base_currency) = self.base_currency
340 && commission.currency == base_currency
341 {
342 let mut adjustment_id = fill.event_id.as_bytes();
343 adjustment_id[15] ^= 0x01;
344
345 let adjustment = PositionAdjusted::new(
346 self.trader_id,
347 self.strategy_id,
348 self.instrument_id,
349 self.id,
350 self.account_id,
351 PositionAdjustmentType::Commission,
352 Some(-commission.as_decimal()),
353 None,
354 Some(fill.client_order_id.inner()),
355 UUID4::from_bytes(adjustment_id),
356 fill.ts_event,
357 fill.ts_init,
358 );
359 self.apply_adjustment(adjustment);
360 }
361
362 self.quantity = Quantity::new(self.signed_qty.abs(), self.size_precision);
364 if self.quantity > self.peak_qty {
365 self.peak_qty = self.quantity;
366 }
367
368 if self.quantity.is_zero() {
369 self.side = PositionSide::Flat;
370 self.signed_qty = 0.0; self.closing_order_id = Some(fill.client_order_id);
372 self.ts_closed = Some(fill.ts_event);
373 self.duration_ns = if let Some(ts_closed) = self.ts_closed {
374 ts_closed.as_u64() - self.ts_opened.as_u64()
375 } else {
376 0
377 };
378 } else if self.signed_qty > 0.0 {
379 self.entry = OrderSide::Buy;
380 self.side = PositionSide::Long;
381 } else {
382 self.entry = OrderSide::Sell;
383 self.side = PositionSide::Short;
384 }
385
386 self.ts_last = fill.ts_event;
387
388 debug_assert!(
389 match self.side {
390 PositionSide::Long => self.signed_qty > 0.0,
391 PositionSide::Short => self.signed_qty < 0.0,
392 PositionSide::Flat => self.signed_qty == 0.0,
393 PositionSide::NoPositionSide => false,
394 },
395 "Invariant: position side must match signed_qty sign (side={:?}, signed_qty={})",
396 self.side,
397 self.signed_qty,
398 );
399 debug_assert!(
400 self.peak_qty >= self.quantity,
401 "Invariant: peak_qty must not be less than current quantity (peak={}, quantity={})",
402 self.peak_qty,
403 self.quantity,
404 );
405 }
406
407 fn handle_buy_order_fill(&mut self, fill: &OrderFilled) {
408 let mut realized_pnl = if let Some(commission) = fill.commission {
410 if commission.currency == self.settlement_currency {
411 -commission.as_f64()
412 } else {
413 0.0
414 }
415 } else {
416 0.0
417 };
418
419 let last_px = fill.last_px.as_f64();
420 let last_qty = fill.last_qty.as_f64();
421 let last_qty_object = fill.last_qty;
422
423 if self.signed_qty > 0.0 {
424 self.avg_px_open = self.calculate_avg_px_open_px(last_px, last_qty);
425 } else if self.signed_qty < 0.0 {
426 let avg_px_close = self.calculate_avg_px_close_px(last_px, last_qty);
428 self.avg_px_close = Some(avg_px_close);
429 self.realized_return = self
430 .calculate_return(self.avg_px_open, avg_px_close)
431 .unwrap_or_else(|e| {
432 log::error!("Error calculating return: {e}");
433 0.0
434 });
435 realized_pnl += self
436 .calculate_pnl_raw(self.avg_px_open, last_px, last_qty)
437 .unwrap_or_else(|e| {
438 log::error!("Error calculating PnL: {e}");
439 0.0
440 });
441 }
442
443 let current_pnl = self.realized_pnl.map_or(0.0, |p| p.as_f64());
444 self.realized_pnl = Some(Money::new(
445 current_pnl + realized_pnl,
446 self.settlement_currency,
447 ));
448
449 let was_short = self.signed_qty < 0.0;
450 self.signed_qty += last_qty;
451 self.buy_qty = self.buy_qty + last_qty_object;
452
453 if was_short && self.signed_qty > 0.0 {
455 self.avg_px_open = last_px;
456 }
457 }
458
459 fn handle_sell_order_fill(&mut self, fill: &OrderFilled) {
460 let mut realized_pnl = if let Some(commission) = fill.commission {
462 if commission.currency == self.settlement_currency {
463 -commission.as_f64()
464 } else {
465 0.0
466 }
467 } else {
468 0.0
469 };
470
471 let last_px = fill.last_px.as_f64();
472 let last_qty = fill.last_qty.as_f64();
473 let last_qty_object = fill.last_qty;
474
475 if self.signed_qty < 0.0 {
476 self.avg_px_open = self.calculate_avg_px_open_px(last_px, last_qty);
477 } else if self.signed_qty > 0.0 {
478 let avg_px_close = self.calculate_avg_px_close_px(last_px, last_qty);
480 self.avg_px_close = Some(avg_px_close);
481 self.realized_return = self
482 .calculate_return(self.avg_px_open, avg_px_close)
483 .unwrap_or_else(|e| {
484 log::error!("Error calculating return: {e}");
485 0.0
486 });
487 realized_pnl += self
488 .calculate_pnl_raw(self.avg_px_open, last_px, last_qty)
489 .unwrap_or_else(|e| {
490 log::error!("Error calculating PnL: {e}");
491 0.0
492 });
493 }
494
495 let current_pnl = self.realized_pnl.map_or(0.0, |p| p.as_f64());
496 self.realized_pnl = Some(Money::new(
497 current_pnl + realized_pnl,
498 self.settlement_currency,
499 ));
500
501 let was_long = self.signed_qty > 0.0;
502 self.signed_qty -= last_qty;
503 self.sell_qty = self.sell_qty + last_qty_object;
504
505 if was_long && self.signed_qty < 0.0 {
507 self.avg_px_open = last_px;
508 }
509 }
510
511 pub fn apply_adjustment(&mut self, adjustment: PositionAdjusted) {
524 if let Some(quantity_change) = adjustment.quantity_change {
526 self.signed_qty += quantity_change
527 .to_f64()
528 .expect("Failed to convert Decimal to f64");
529
530 self.quantity = Quantity::new(self.signed_qty.abs(), self.size_precision);
531
532 if self.quantity > self.peak_qty {
533 self.peak_qty = self.quantity;
534 }
535 }
536
537 if let Some(pnl_change) = adjustment.pnl_change {
539 self.realized_pnl = Some(match self.realized_pnl {
540 Some(current) => current + pnl_change,
541 None => pnl_change,
542 });
543 }
544
545 if self.quantity.is_zero() {
548 self.side = PositionSide::Flat;
549 self.signed_qty = 0.0; } else if self.signed_qty > 0.0 {
551 self.side = PositionSide::Long;
552
553 if self.entry == OrderSide::NoOrderSide {
554 self.entry = OrderSide::Buy;
555 }
556 } else {
557 self.side = PositionSide::Short;
558
559 if self.entry == OrderSide::NoOrderSide {
560 self.entry = OrderSide::Sell;
561 }
562 }
563
564 self.adjustments.push(adjustment);
565 self.ts_last = adjustment.ts_event;
566
567 debug_assert!(
568 match self.side {
569 PositionSide::Long => self.signed_qty > 0.0,
570 PositionSide::Short => self.signed_qty < 0.0,
571 PositionSide::Flat => self.signed_qty == 0.0,
572 PositionSide::NoPositionSide => false,
573 },
574 "Invariant: position side must match signed_qty sign (side={:?}, signed_qty={})",
575 self.side,
576 self.signed_qty,
577 );
578 debug_assert!(
579 self.peak_qty >= self.quantity,
580 "Invariant: peak_qty must not be less than current quantity (peak={}, quantity={})",
581 self.peak_qty,
582 self.quantity,
583 );
584 }
585
586 fn calculate_avg_px(
628 &self,
629 qty: f64,
630 avg_pg: f64,
631 last_px: f64,
632 last_qty: f64,
633 ) -> anyhow::Result<f64> {
634 debug_assert!(
637 qty >= 0.0 && last_qty >= 0.0,
638 "Invariant: average price calc requires non-negative quantities \
639 (qty={qty}, last_qty={last_qty})"
640 );
641
642 if qty == 0.0 && last_qty == 0.0 {
643 anyhow::bail!("Cannot calculate average price: both quantities are zero");
644 }
645
646 if last_qty == 0.0 {
647 anyhow::bail!("Cannot calculate average price: fill quantity is zero");
648 }
649
650 if qty == 0.0 {
651 return Ok(last_px);
652 }
653
654 let start_cost = avg_pg * qty;
655 let event_cost = last_px * last_qty;
656 let total_qty = qty + last_qty;
657
658 if total_qty <= 0.0 {
660 anyhow::bail!(
661 "Total quantity unexpectedly zero or negative in average price calculation: qty={qty}, last_qty={last_qty}, total_qty={total_qty}"
662 );
663 }
664
665 Ok((start_cost + event_cost) / total_qty)
666 }
667
668 fn calculate_avg_px_open_px(&self, last_px: f64, last_qty: f64) -> f64 {
669 self.calculate_avg_px(self.quantity.as_f64(), self.avg_px_open, last_px, last_qty)
670 .unwrap_or_else(|e| {
671 log::error!("Error calculating average open price: {e}");
672 last_px
673 })
674 }
675
676 fn calculate_avg_px_close_px(&self, last_px: f64, last_qty: f64) -> f64 {
677 let Some(avg_px_close) = self.avg_px_close else {
678 return last_px;
679 };
680 let closing_qty = if self.side == PositionSide::Long {
681 self.sell_qty
682 } else {
683 self.buy_qty
684 };
685 self.calculate_avg_px(closing_qty.as_f64(), avg_px_close, last_px, last_qty)
686 .unwrap_or_else(|e| {
687 log::error!("Error calculating average close price: {e}");
688 last_px
689 })
690 }
691
692 fn calculate_points(&self, avg_px_open: f64, avg_px_close: f64) -> f64 {
693 match self.side {
694 PositionSide::Long => avg_px_close - avg_px_open,
695 PositionSide::Short => avg_px_open - avg_px_close,
696 _ => 0.0, }
698 }
699
700 fn calculate_points_inverse(&self, avg_px_open: f64, avg_px_close: f64) -> anyhow::Result<f64> {
701 const EPSILON: f64 = 1e-15;
703
704 if avg_px_open.abs() < EPSILON {
706 anyhow::bail!(
707 "Cannot calculate inverse points: open price is zero or too small ({avg_px_open})"
708 );
709 }
710
711 if avg_px_close.abs() < EPSILON {
712 anyhow::bail!(
713 "Cannot calculate inverse points: close price is zero or too small ({avg_px_close})"
714 );
715 }
716
717 let inverse_open = 1.0 / avg_px_open;
718 let inverse_close = 1.0 / avg_px_close;
719 let result = match self.side {
720 PositionSide::Long => inverse_open - inverse_close,
721 PositionSide::Short => inverse_close - inverse_open,
722 _ => 0.0, };
724 Ok(result)
725 }
726
727 fn calculate_return(&self, avg_px_open: f64, avg_px_close: f64) -> anyhow::Result<f64> {
728 if avg_px_open == 0.0 {
730 anyhow::bail!(
731 "Cannot calculate return: open price is zero (close price: {avg_px_close})"
732 );
733 }
734 Ok(self.calculate_points(avg_px_open, avg_px_close) / avg_px_open)
735 }
736
737 fn calculate_pnl_raw(
738 &self,
739 avg_px_open: f64,
740 avg_px_close: f64,
741 quantity: f64,
742 ) -> anyhow::Result<f64> {
743 let quantity = quantity.min(self.signed_qty.abs());
744 let result = if self.is_inverse {
745 let points = self.calculate_points_inverse(avg_px_open, avg_px_close)?;
746 quantity * self.multiplier.as_f64() * points
747 } else {
748 quantity * self.multiplier.as_f64() * self.calculate_points(avg_px_open, avg_px_close)
749 };
750 Ok(result)
751 }
752
753 #[must_use]
755 pub fn calculate_pnl(&self, avg_px_open: f64, avg_px_close: f64, quantity: Quantity) -> Money {
756 let pnl_raw = self
757 .calculate_pnl_raw(avg_px_open, avg_px_close, quantity.as_f64())
758 .unwrap_or_else(|e| {
759 log::error!("Error calculating PnL: {e}");
760 0.0
761 });
762 Money::new(pnl_raw, self.settlement_currency)
763 }
764
765 #[must_use]
767 pub fn total_pnl(&self, last: Price) -> Money {
768 let unrealized = self.unrealized_pnl(last);
769 match self.realized_pnl {
770 Some(realized) => realized + unrealized,
771 None => unrealized,
772 }
773 }
774
775 #[must_use]
777 pub fn unrealized_pnl(&self, last: Price) -> Money {
778 if self.side == PositionSide::Flat {
779 Money::new(0.0, self.settlement_currency)
780 } else {
781 let avg_px_open = self.avg_px_open;
782 let avg_px_close = last.as_f64();
783 let quantity = self.quantity.as_f64();
784 let pnl = self
785 .calculate_pnl_raw(avg_px_open, avg_px_close, quantity)
786 .unwrap_or_else(|e| {
787 log::error!("Error calculating unrealized PnL: {e}");
788 0.0
789 });
790 Money::new(pnl, self.settlement_currency)
791 }
792 }
793
794 #[must_use]
796 pub fn closing_order_side(&self) -> OrderSide {
797 match self.side {
798 PositionSide::Long => OrderSide::Sell,
799 PositionSide::Short => OrderSide::Buy,
800 _ => OrderSide::NoOrderSide,
801 }
802 }
803
804 #[must_use]
806 pub fn is_opposite_side(&self, side: OrderSide) -> bool {
807 self.entry != side
808 }
809
810 #[must_use]
812 pub fn symbol(&self) -> Symbol {
813 self.instrument_id.symbol
814 }
815
816 #[must_use]
818 pub fn venue(&self) -> Venue {
819 self.instrument_id.venue
820 }
821
822 #[must_use]
824 pub fn event_count(&self) -> usize {
825 self.events.len()
826 }
827
828 #[must_use]
830 pub fn client_order_ids(&self) -> Vec<ClientOrderId> {
831 let mut result = self
833 .events
834 .iter()
835 .map(|event| event.client_order_id)
836 .collect::<AHashSet<ClientOrderId>>()
837 .into_iter()
838 .collect::<Vec<ClientOrderId>>();
839 result.sort_unstable();
840 result
841 }
842
843 #[must_use]
845 pub fn venue_order_ids(&self) -> Vec<VenueOrderId> {
846 let mut result = self
848 .events
849 .iter()
850 .map(|event| event.venue_order_id)
851 .collect::<AHashSet<VenueOrderId>>()
852 .into_iter()
853 .collect::<Vec<VenueOrderId>>();
854 result.sort_unstable();
855 result
856 }
857
858 #[must_use]
860 pub fn trade_ids(&self) -> Vec<TradeId> {
861 let mut result = self
862 .events
863 .iter()
864 .map(|event| event.trade_id)
865 .collect::<AHashSet<TradeId>>()
866 .into_iter()
867 .collect::<Vec<TradeId>>();
868 result.sort_unstable();
869 result
870 }
871
872 #[must_use]
879 pub fn notional_value(&self, last: Price) -> Money {
880 if self.is_inverse {
881 check_predicate_true(
882 last.is_positive(),
883 "last price must be positive for inverse instrument",
884 )
885 .expect(FAILED);
886 Money::new(
887 self.quantity.as_f64() * self.multiplier.as_f64() * (1.0 / last.as_f64()),
888 self.base_currency.unwrap(),
889 )
890 } else {
891 Money::new(
892 self.quantity.as_f64() * last.as_f64() * self.multiplier.as_f64(),
893 self.quote_currency,
894 )
895 }
896 }
897
898 #[must_use]
900 pub fn last_event(&self) -> Option<OrderFilled> {
901 self.events.last().copied()
902 }
903
904 #[must_use]
906 pub fn last_trade_id(&self) -> Option<TradeId> {
907 self.events.last().map(|e| e.trade_id)
908 }
909
910 #[must_use]
912 pub fn is_long(&self) -> bool {
913 self.side == PositionSide::Long
914 }
915
916 #[must_use]
918 pub fn is_short(&self) -> bool {
919 self.side == PositionSide::Short
920 }
921
922 #[must_use]
924 pub fn is_open(&self) -> bool {
925 self.side != PositionSide::Flat && self.ts_closed.is_none()
926 }
927
928 #[must_use]
930 pub fn is_closed(&self) -> bool {
931 self.side == PositionSide::Flat && self.ts_closed.is_some()
932 }
933
934 #[must_use]
939 pub fn signed_decimal_qty(&self) -> Decimal {
940 Decimal::try_from(self.signed_qty).unwrap_or(Decimal::ZERO)
941 }
942
943 #[must_use]
945 pub fn commissions(&self) -> Vec<Money> {
946 self.commissions.values().copied().collect()
947 }
948}
949
950impl PartialEq<Self> for Position {
951 fn eq(&self, other: &Self) -> bool {
952 self.id == other.id
953 }
954}
955
956impl Eq for Position {}
957
958impl Hash for Position {
959 fn hash<H: Hasher>(&self, state: &mut H) {
960 self.id.hash(state);
961 }
962}
963
964impl Display for Position {
965 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
966 let quantity_str = if self.quantity == Quantity::zero(self.size_precision) {
967 String::new()
968 } else {
969 self.quantity.to_formatted_string() + " "
970 };
971 write!(
972 f,
973 "Position({} {}{}, id={})",
974 self.side, quantity_str, self.instrument_id, self.id
975 )
976 }
977}
978
979#[must_use]
991pub fn fold_net_position(legs: &[(Decimal, Decimal, u64)]) -> (Decimal, Decimal) {
992 let mut sorted: Vec<&(Decimal, Decimal, u64)> =
993 legs.iter().filter(|(qty, _, _)| !qty.is_zero()).collect();
994 sorted.sort_by_key(|(_, _, ts_opened)| *ts_opened);
995
996 let mut net_signed_qty = Decimal::ZERO;
997 let mut net_avg_px = Decimal::ZERO;
998
999 for (p_qty, p_px, _) in sorted {
1000 let p_qty = *p_qty;
1001 let p_px = *p_px;
1002
1003 if net_signed_qty.is_zero() {
1004 net_signed_qty = p_qty;
1005 net_avg_px = p_px;
1006 continue;
1007 }
1008
1009 let same_side = net_signed_qty.is_sign_negative() == p_qty.is_sign_negative();
1010 let new_net = net_signed_qty + p_qty;
1011
1012 if same_side {
1013 let total_abs = net_signed_qty.abs() + p_qty.abs();
1014 net_avg_px = (net_signed_qty.abs() * net_avg_px + p_qty.abs() * p_px) / total_abs;
1015 net_signed_qty = new_net;
1016 } else if new_net.is_zero()
1017 || new_net.is_sign_negative() == net_signed_qty.is_sign_negative()
1018 {
1019 net_signed_qty = new_net;
1020 if new_net.is_zero() {
1021 net_avg_px = Decimal::ZERO;
1022 }
1023 } else {
1024 net_signed_qty = new_net;
1025 net_avg_px = p_px;
1026 }
1027 }
1028
1029 (net_signed_qty, net_avg_px)
1030}
1031
1032#[cfg(test)]
1033mod tests {
1034 use std::str::FromStr;
1035
1036 use ahash::AHashSet;
1037 use nautilus_core::UnixNanos;
1038 use proptest::prelude::*;
1039 use rstest::rstest;
1040 use rust_decimal::{Decimal, prelude::ToPrimitive};
1041 use rust_decimal_macros::dec;
1042
1043 use crate::{
1044 enums::{OrderSide, OrderType, PositionAdjustmentType, PositionSide},
1045 events::{OrderEventAny, OrderFilled, PositionAdjusted, order::spec::OrderFilledSpec},
1046 identifiers::{
1047 AccountId, ClientOrderId, PositionId, StrategyId, TradeId, VenueOrderId, stubs::uuid4,
1048 },
1049 instruments::{CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny, stubs::*},
1050 orders::{Order, builder::OrderTestBuilder, stubs::TestOrderEventStubs},
1051 position::{Position, fold_net_position},
1052 stubs::*,
1053 types::{Currency, Money, Price, Quantity},
1054 };
1055
1056 #[rstest]
1057 fn test_position_long_display(stub_position_long: Position) {
1058 let display = format!("{stub_position_long}");
1059 assert_eq!(display, "Position(LONG 1 AUD/USD.SIM, id=1)");
1060 }
1061
1062 #[rstest]
1063 fn test_position_short_display(stub_position_short: Position) {
1064 let display = format!("{stub_position_short}");
1065 assert_eq!(display, "Position(SHORT 1 AUD/USD.SIM, id=1)");
1066 }
1067
1068 #[rstest]
1069 #[should_panic(expected = "`fill.trade_id` already contained in `trade_ids")]
1070 fn test_two_trades_with_same_trade_id_error(audusd_sim: CurrencyPair) {
1071 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
1072 let order1 = OrderTestBuilder::new(OrderType::Market)
1073 .instrument_id(audusd_sim.id())
1074 .side(OrderSide::Buy)
1075 .quantity(Quantity::from(100_000))
1076 .build();
1077 let order2 = OrderTestBuilder::new(OrderType::Market)
1078 .instrument_id(audusd_sim.id())
1079 .side(OrderSide::Buy)
1080 .quantity(Quantity::from(100_000))
1081 .build();
1082 let fill1 = TestOrderEventStubs::filled(
1083 &order1,
1084 &audusd_sim,
1085 Some(TradeId::new("1")),
1086 None,
1087 Some(Price::from("1.00001")),
1088 None,
1089 None,
1090 None,
1091 None,
1092 None,
1093 );
1094 let fill2 = TestOrderEventStubs::filled(
1095 &order2,
1096 &audusd_sim,
1097 Some(TradeId::new("1")),
1098 None,
1099 Some(Price::from("1.00002")),
1100 None,
1101 None,
1102 None,
1103 None,
1104 None,
1105 );
1106 let mut position = Position::new(&audusd_sim, fill1.into());
1107 position.apply(&fill2.into());
1108 }
1109
1110 #[rstest]
1111 fn test_position_applies_fills_with_negative_prices(audusd_sim: CurrencyPair) {
1112 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
1116 let order = OrderTestBuilder::new(OrderType::Market)
1117 .instrument_id(audusd_sim.id())
1118 .side(OrderSide::Buy)
1119 .quantity(Quantity::from(100_000))
1120 .build();
1121 let fill1 = TestOrderEventStubs::filled(
1122 &order,
1123 &audusd_sim,
1124 Some(TradeId::new("1")),
1125 None,
1126 Some(Price::from("-5.00000")),
1127 Some(Quantity::from(50_000)),
1128 None,
1129 None,
1130 None,
1131 None,
1132 );
1133 let fill2 = TestOrderEventStubs::filled(
1134 &order,
1135 &audusd_sim,
1136 Some(TradeId::new("2")),
1137 None,
1138 Some(Price::from("-7.00000")),
1139 Some(Quantity::from(50_000)),
1140 None,
1141 None,
1142 None,
1143 None,
1144 );
1145 let mut position = Position::new(&audusd_sim, fill1.into());
1146 position.apply(&fill2.into());
1147
1148 assert_eq!(position.quantity, Quantity::from(100_000));
1149 assert_eq!(position.signed_qty, 100_000.0);
1150 assert_eq!(position.side, PositionSide::Long);
1151 assert_eq!(position.avg_px_open, -6.0);
1153 }
1154
1155 #[rstest]
1156 fn test_position_filled_with_buy_order(audusd_sim: CurrencyPair) {
1157 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
1158 let order = OrderTestBuilder::new(OrderType::Market)
1159 .instrument_id(audusd_sim.id())
1160 .side(OrderSide::Buy)
1161 .quantity(Quantity::from(100_000))
1162 .build();
1163 let fill = TestOrderEventStubs::filled(
1164 &order,
1165 &audusd_sim,
1166 None,
1167 None,
1168 Some(Price::from("1.00001")),
1169 None,
1170 None,
1171 None,
1172 None,
1173 None,
1174 );
1175 let last_price = Price::from_str("1.0005").unwrap();
1176 let position = Position::new(&audusd_sim, fill.into());
1177 assert_eq!(position.symbol(), audusd_sim.id().symbol);
1178 assert_eq!(position.venue(), audusd_sim.id().venue);
1179 assert_eq!(position.closing_order_side(), OrderSide::Sell);
1180 assert!(!position.is_opposite_side(OrderSide::Buy));
1181 assert_eq!(position, position); assert!(position.closing_order_id.is_none());
1183 assert_eq!(position.quantity, Quantity::from(100_000));
1184 assert_eq!(position.peak_qty, Quantity::from(100_000));
1185 assert_eq!(position.size_precision, 0);
1186 assert_eq!(position.signed_qty, 100_000.0);
1187 assert_eq!(position.entry, OrderSide::Buy);
1188 assert_eq!(position.side, PositionSide::Long);
1189 assert_eq!(position.ts_opened.as_u64(), 0);
1190 assert_eq!(position.duration_ns, 0);
1191 assert_eq!(position.avg_px_open, 1.00001);
1192 assert_eq!(position.event_count(), 1);
1193 assert_eq!(position.id, PositionId::new("1"));
1194 assert_eq!(position.events.len(), 1);
1195 assert!(position.is_long());
1196 assert!(!position.is_short());
1197 assert!(position.is_open());
1198 assert!(!position.is_closed());
1199 assert_eq!(position.realized_return, 0.0);
1200 assert_eq!(position.realized_pnl, Some(Money::from("-2.0 USD")));
1201 assert_eq!(position.unrealized_pnl(last_price), Money::from("49.0 USD"));
1202 assert_eq!(position.total_pnl(last_price), Money::from("47.0 USD"));
1203 assert_eq!(position.commissions(), vec![Money::from("2.0 USD")]);
1204 assert_eq!(
1205 format!("{position}"),
1206 "Position(LONG 100_000 AUD/USD.SIM, id=1)"
1207 );
1208 }
1209
1210 #[rstest]
1211 fn test_position_filled_with_sell_order(audusd_sim: CurrencyPair) {
1212 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
1213 let order = OrderTestBuilder::new(OrderType::Market)
1214 .instrument_id(audusd_sim.id())
1215 .side(OrderSide::Sell)
1216 .quantity(Quantity::from(100_000))
1217 .build();
1218 let fill = TestOrderEventStubs::filled(
1219 &order,
1220 &audusd_sim,
1221 None,
1222 None,
1223 Some(Price::from("1.00001")),
1224 None,
1225 None,
1226 None,
1227 None,
1228 None,
1229 );
1230 let last_price = Price::from_str("1.00050").unwrap();
1231 let position = Position::new(&audusd_sim, fill.into());
1232 assert_eq!(position.symbol(), audusd_sim.id().symbol);
1233 assert_eq!(position.venue(), audusd_sim.id().venue);
1234 assert_eq!(position.closing_order_side(), OrderSide::Buy);
1235 assert!(!position.is_opposite_side(OrderSide::Sell));
1236 assert_eq!(position, position); assert!(position.closing_order_id.is_none());
1238 assert_eq!(position.quantity, Quantity::from(100_000));
1239 assert_eq!(position.peak_qty, Quantity::from(100_000));
1240 assert_eq!(position.signed_qty, -100_000.0);
1241 assert_eq!(position.entry, OrderSide::Sell);
1242 assert_eq!(position.side, PositionSide::Short);
1243 assert_eq!(position.ts_opened.as_u64(), 0);
1244 assert_eq!(position.avg_px_open, 1.00001);
1245 assert_eq!(position.event_count(), 1);
1246 assert_eq!(position.id, PositionId::new("1"));
1247 assert_eq!(position.events.len(), 1);
1248 assert!(!position.is_long());
1249 assert!(position.is_short());
1250 assert!(position.is_open());
1251 assert!(!position.is_closed());
1252 assert_eq!(position.realized_return, 0.0);
1253 assert_eq!(position.realized_pnl, Some(Money::from("-2.0 USD")));
1254 assert_eq!(
1255 position.unrealized_pnl(last_price),
1256 Money::from("-49.0 USD")
1257 );
1258 assert_eq!(position.total_pnl(last_price), Money::from("-51.0 USD"));
1259 assert_eq!(position.commissions(), vec![Money::from("2.0 USD")]);
1260 assert_eq!(
1261 format!("{position}"),
1262 "Position(SHORT 100_000 AUD/USD.SIM, id=1)"
1263 );
1264 }
1265
1266 #[rstest]
1267 fn test_position_partial_fills_with_buy_order(audusd_sim: CurrencyPair) {
1268 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
1269 let order = OrderTestBuilder::new(OrderType::Market)
1270 .instrument_id(audusd_sim.id())
1271 .side(OrderSide::Buy)
1272 .quantity(Quantity::from(100_000))
1273 .build();
1274 let fill = TestOrderEventStubs::filled(
1275 &order,
1276 &audusd_sim,
1277 None,
1278 None,
1279 Some(Price::from("1.00001")),
1280 Some(Quantity::from(50_000)),
1281 None,
1282 None,
1283 None,
1284 None,
1285 );
1286 let last_price = Price::from_str("1.00048").unwrap();
1287 let position = Position::new(&audusd_sim, fill.into());
1288 assert_eq!(position.quantity, Quantity::from(50_000));
1289 assert_eq!(position.peak_qty, Quantity::from(50_000));
1290 assert_eq!(position.side, PositionSide::Long);
1291 assert_eq!(position.signed_qty, 50000.0);
1292 assert_eq!(position.avg_px_open, 1.00001);
1293 assert_eq!(position.event_count(), 1);
1294 assert_eq!(position.ts_opened.as_u64(), 0);
1295 assert!(position.is_long());
1296 assert!(!position.is_short());
1297 assert!(position.is_open());
1298 assert!(!position.is_closed());
1299 assert_eq!(position.realized_return, 0.0);
1300 assert_eq!(position.realized_pnl, Some(Money::from("-2.0 USD")));
1301 assert_eq!(position.unrealized_pnl(last_price), Money::from("23.5 USD"));
1302 assert_eq!(position.total_pnl(last_price), Money::from("21.5 USD"));
1303 assert_eq!(position.commissions(), vec![Money::from("2.0 USD")]);
1304 assert_eq!(
1305 format!("{position}"),
1306 "Position(LONG 50_000 AUD/USD.SIM, id=1)"
1307 );
1308 }
1309
1310 #[rstest]
1311 fn test_position_partial_fills_with_two_sell_orders(audusd_sim: CurrencyPair) {
1312 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
1313 let order = OrderTestBuilder::new(OrderType::Market)
1314 .instrument_id(audusd_sim.id())
1315 .side(OrderSide::Sell)
1316 .quantity(Quantity::from(100_000))
1317 .build();
1318 let fill1 = TestOrderEventStubs::filled(
1319 &order,
1320 &audusd_sim,
1321 Some(TradeId::new("1")),
1322 None,
1323 Some(Price::from("1.00001")),
1324 Some(Quantity::from(50_000)),
1325 None,
1326 None,
1327 None,
1328 None,
1329 );
1330 let fill2 = TestOrderEventStubs::filled(
1331 &order,
1332 &audusd_sim,
1333 Some(TradeId::new("2")),
1334 None,
1335 Some(Price::from("1.00002")),
1336 Some(Quantity::from(50_000)),
1337 None,
1338 None,
1339 None,
1340 None,
1341 );
1342 let last_price = Price::from_str("1.0005").unwrap();
1343 let mut position = Position::new(&audusd_sim, fill1.into());
1344 position.apply(&fill2.into());
1345
1346 assert_eq!(position.quantity, Quantity::from(100_000));
1347 assert_eq!(position.peak_qty, Quantity::from(100_000));
1348 assert_eq!(position.side, PositionSide::Short);
1349 assert_eq!(position.signed_qty, -100_000.0);
1350 assert_eq!(position.avg_px_open, 1.000_015);
1351 assert_eq!(position.event_count(), 2);
1352 assert_eq!(position.ts_opened, 0);
1353 assert!(position.is_short());
1354 assert!(!position.is_long());
1355 assert!(position.is_open());
1356 assert!(!position.is_closed());
1357 assert_eq!(position.realized_return, 0.0);
1358 assert_eq!(position.realized_pnl, Some(Money::from("-4.0 USD")));
1359 assert_eq!(
1360 position.unrealized_pnl(last_price),
1361 Money::from("-48.5 USD")
1362 );
1363 assert_eq!(position.total_pnl(last_price), Money::from("-52.5 USD"));
1364 assert_eq!(position.commissions(), vec![Money::from("4.0 USD")]);
1365 }
1366
1367 #[rstest]
1368 pub fn test_position_filled_with_buy_order_then_sell_order(audusd_sim: CurrencyPair) {
1369 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
1370 let order = OrderTestBuilder::new(OrderType::Market)
1371 .instrument_id(audusd_sim.id())
1372 .side(OrderSide::Buy)
1373 .quantity(Quantity::from(150_000))
1374 .build();
1375 let fill = TestOrderEventStubs::filled(
1376 &order,
1377 &audusd_sim,
1378 Some(TradeId::new("1")),
1379 Some(PositionId::new("P-1")),
1380 Some(Price::from("1.00001")),
1381 None,
1382 None,
1383 None,
1384 Some(UnixNanos::from(1_000_000_000)),
1385 None,
1386 );
1387 let mut position = Position::new(&audusd_sim, fill.into());
1388
1389 let fill2 = OrderFilledSpec::builder()
1390 .trader_id(order.trader_id())
1391 .strategy_id(StrategyId::new("S-001"))
1392 .instrument_id(order.instrument_id())
1393 .client_order_id(order.client_order_id())
1394 .venue_order_id(VenueOrderId::from("2"))
1395 .account_id(order.account_id().unwrap_or(AccountId::new("SIM-001")))
1396 .trade_id(TradeId::new("2"))
1397 .order_side(OrderSide::Sell)
1398 .last_qty(order.quantity())
1399 .last_px(Price::from("1.00011"))
1400 .currency(audusd_sim.quote_currency())
1401 .ts_event(2_000_000_000.into())
1402 .position_id(PositionId::new("T1"))
1403 .commission(Money::from("0.0 USD"))
1404 .build();
1405 position.apply(&fill2);
1406 let last = Price::from_str("1.0005").unwrap();
1407
1408 assert!(position.is_opposite_side(fill2.order_side));
1409 assert_eq!(
1410 position.quantity,
1411 Quantity::zero(audusd_sim.price_precision())
1412 );
1413 assert_eq!(position.size_precision, 0);
1414 assert_eq!(position.signed_qty, 0.0);
1415 assert_eq!(position.side, PositionSide::Flat);
1416 assert_eq!(position.ts_opened, 1_000_000_000);
1417 assert_eq!(position.ts_closed, Some(UnixNanos::from(2_000_000_000)));
1418 assert_eq!(position.duration_ns, 1_000_000_000);
1419 assert_eq!(position.avg_px_open, 1.00001);
1420 assert_eq!(position.avg_px_close, Some(1.00011));
1421 assert!(!position.is_long());
1422 assert!(!position.is_short());
1423 assert!(!position.is_open());
1424 assert!(position.is_closed());
1425 assert_eq!(position.realized_return, 9.999_900_000_998_888e-5);
1426 assert_eq!(position.realized_pnl, Some(Money::from("13.0 USD")));
1427 assert_eq!(position.unrealized_pnl(last), Money::from("0 USD"));
1428 assert_eq!(position.commissions(), vec![Money::from("2 USD")]);
1429 assert_eq!(position.total_pnl(last), Money::from("13 USD"));
1430 assert_eq!(format!("{position}"), "Position(FLAT AUD/USD.SIM, id=P-1)");
1431 }
1432
1433 #[rstest]
1434 pub fn test_position_filled_with_sell_order_then_buy_order(audusd_sim: CurrencyPair) {
1435 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
1436 let order1 = OrderTestBuilder::new(OrderType::Market)
1437 .instrument_id(audusd_sim.id())
1438 .side(OrderSide::Sell)
1439 .quantity(Quantity::from(100_000))
1440 .build();
1441 let order2 = OrderTestBuilder::new(OrderType::Market)
1442 .instrument_id(audusd_sim.id())
1443 .side(OrderSide::Buy)
1444 .quantity(Quantity::from(100_000))
1445 .build();
1446 let fill1 = TestOrderEventStubs::filled(
1447 &order1,
1448 &audusd_sim,
1449 None,
1450 Some(PositionId::new("P-19700101-000000-001-001-1")),
1451 Some(Price::from("1.0")),
1452 None,
1453 None,
1454 None,
1455 None,
1456 None,
1457 );
1458 let mut position = Position::new(&audusd_sim, fill1.into());
1459 let fill2 = TestOrderEventStubs::filled(
1461 &order2,
1462 &audusd_sim,
1463 Some(TradeId::new("1")),
1464 Some(PositionId::new("P-19700101-000000-001-001-1")),
1465 Some(Price::from("1.00001")),
1466 Some(Quantity::from(50_000)),
1467 None,
1468 None,
1469 None,
1470 None,
1471 );
1472 let fill3 = TestOrderEventStubs::filled(
1473 &order2,
1474 &audusd_sim,
1475 Some(TradeId::new("2")),
1476 Some(PositionId::new("P-19700101-000000-001-001-1")),
1477 Some(Price::from("1.00003")),
1478 Some(Quantity::from(50_000)),
1479 None,
1480 None,
1481 None,
1482 None,
1483 );
1484 let last = Price::from("1.0005");
1485 position.apply(&fill2.into());
1486 position.apply(&fill3.into());
1487
1488 assert_eq!(
1489 position.quantity,
1490 Quantity::zero(audusd_sim.price_precision())
1491 );
1492 assert_eq!(position.side, PositionSide::Flat);
1493 assert_eq!(position.ts_opened, 0);
1494 assert_eq!(position.avg_px_open, 1.0);
1495 assert_eq!(position.events.len(), 3);
1496 assert_eq!(position.ts_closed, Some(UnixNanos::default()));
1497 assert_eq!(position.avg_px_close, Some(1.00002));
1498 assert!(!position.is_long());
1499 assert!(!position.is_short());
1500 assert!(!position.is_open());
1501 assert!(position.is_closed());
1502 assert_eq!(position.commissions(), vec![Money::from("6.0 USD")]);
1503 assert_eq!(position.unrealized_pnl(last), Money::from("0 USD"));
1504 assert_eq!(position.realized_pnl, Some(Money::from("-8.0 USD")));
1505 assert_eq!(position.total_pnl(last), Money::from("-8.0 USD"));
1506 assert_eq!(
1507 format!("{position}"),
1508 "Position(FLAT AUD/USD.SIM, id=P-19700101-000000-001-001-1)"
1509 );
1510 }
1511
1512 #[rstest]
1513 fn test_position_filled_with_no_change(audusd_sim: CurrencyPair) {
1514 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
1515 let order1 = OrderTestBuilder::new(OrderType::Market)
1516 .instrument_id(audusd_sim.id())
1517 .side(OrderSide::Buy)
1518 .quantity(Quantity::from(100_000))
1519 .build();
1520 let order2 = OrderTestBuilder::new(OrderType::Market)
1521 .instrument_id(audusd_sim.id())
1522 .side(OrderSide::Sell)
1523 .quantity(Quantity::from(100_000))
1524 .build();
1525 let fill1 = TestOrderEventStubs::filled(
1526 &order1,
1527 &audusd_sim,
1528 Some(TradeId::new("1")),
1529 Some(PositionId::new("P-19700101-000000-001-001-1")),
1530 Some(Price::from("1.0")),
1531 None,
1532 None,
1533 None,
1534 None,
1535 None,
1536 );
1537 let mut position = Position::new(&audusd_sim, fill1.into());
1538 let fill2 = TestOrderEventStubs::filled(
1539 &order2,
1540 &audusd_sim,
1541 Some(TradeId::new("2")),
1542 Some(PositionId::new("P-19700101-000000-001-001-1")),
1543 Some(Price::from("1.0")),
1544 None,
1545 None,
1546 None,
1547 None,
1548 None,
1549 );
1550 let last = Price::from("1.0005");
1551 position.apply(&fill2.into());
1552
1553 assert_eq!(
1554 position.quantity,
1555 Quantity::zero(audusd_sim.price_precision())
1556 );
1557 assert_eq!(position.closing_order_side(), OrderSide::NoOrderSide);
1558 assert_eq!(position.side, PositionSide::Flat);
1559 assert_eq!(position.ts_opened, 0);
1560 assert_eq!(position.avg_px_open, 1.0);
1561 assert_eq!(position.events.len(), 2);
1562 assert_eq!(position.ts_closed, Some(UnixNanos::default()));
1564 assert_eq!(position.avg_px_close, Some(1.0));
1565 assert!(!position.is_long());
1566 assert!(!position.is_short());
1567 assert!(!position.is_open());
1568 assert!(position.is_closed());
1569 assert_eq!(position.commissions(), vec![Money::from("4.0 USD")]);
1570 assert_eq!(position.unrealized_pnl(last), Money::from("0 USD"));
1571 assert_eq!(position.realized_pnl, Some(Money::from("-4.0 USD")));
1572 assert_eq!(position.total_pnl(last), Money::from("-4.0 USD"));
1573 assert_eq!(
1574 format!("{position}"),
1575 "Position(FLAT AUD/USD.SIM, id=P-19700101-000000-001-001-1)"
1576 );
1577 }
1578
1579 #[rstest]
1580 fn test_position_long_with_multiple_filled_orders(audusd_sim: CurrencyPair) {
1581 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
1582 let order1 = OrderTestBuilder::new(OrderType::Market)
1583 .instrument_id(audusd_sim.id())
1584 .side(OrderSide::Buy)
1585 .quantity(Quantity::from(100_000))
1586 .build();
1587 let order2 = OrderTestBuilder::new(OrderType::Market)
1588 .instrument_id(audusd_sim.id())
1589 .side(OrderSide::Buy)
1590 .quantity(Quantity::from(100_000))
1591 .build();
1592 let order3 = OrderTestBuilder::new(OrderType::Market)
1593 .instrument_id(audusd_sim.id())
1594 .side(OrderSide::Sell)
1595 .quantity(Quantity::from(200_000))
1596 .build();
1597 let fill1 = TestOrderEventStubs::filled(
1598 &order1,
1599 &audusd_sim,
1600 Some(TradeId::new("1")),
1601 Some(PositionId::new("P-123456")),
1602 Some(Price::from("1.0")),
1603 None,
1604 None,
1605 None,
1606 None,
1607 None,
1608 );
1609 let fill2 = TestOrderEventStubs::filled(
1610 &order2,
1611 &audusd_sim,
1612 Some(TradeId::new("2")),
1613 Some(PositionId::new("P-123456")),
1614 Some(Price::from("1.00001")),
1615 None,
1616 None,
1617 None,
1618 None,
1619 None,
1620 );
1621 let fill3 = TestOrderEventStubs::filled(
1622 &order3,
1623 &audusd_sim,
1624 Some(TradeId::new("3")),
1625 Some(PositionId::new("P-123456")),
1626 Some(Price::from("1.0001")),
1627 None,
1628 None,
1629 None,
1630 None,
1631 None,
1632 );
1633 let mut position = Position::new(&audusd_sim, fill1.into());
1634 let last = Price::from("1.0005");
1635 position.apply(&fill2.into());
1636 position.apply(&fill3.into());
1637
1638 assert_eq!(
1639 position.quantity,
1640 Quantity::zero(audusd_sim.price_precision())
1641 );
1642 assert_eq!(position.side, PositionSide::Flat);
1643 assert_eq!(position.ts_opened, 0);
1644 assert_eq!(position.avg_px_open, 1.000_005);
1645 assert_eq!(position.events.len(), 3);
1646 assert_eq!(position.ts_closed, Some(UnixNanos::default()));
1651 assert_eq!(position.avg_px_close, Some(1.0001));
1652 assert!(position.is_closed());
1653 assert!(!position.is_open());
1654 assert!(!position.is_long());
1655 assert!(!position.is_short());
1656 assert_eq!(position.commissions(), vec![Money::from("6.0 USD")]);
1657 assert_eq!(position.realized_pnl, Some(Money::from("13.0 USD")));
1658 assert_eq!(position.unrealized_pnl(last), Money::from("0 USD"));
1659 assert_eq!(position.total_pnl(last), Money::from("13 USD"));
1660 assert_eq!(
1661 format!("{position}"),
1662 "Position(FLAT AUD/USD.SIM, id=P-123456)"
1663 );
1664 }
1665
1666 #[rstest]
1667 fn test_pnl_calculation_from_trading_technologies_example(currency_pair_ethusdt: CurrencyPair) {
1668 let ethusdt = InstrumentAny::CurrencyPair(currency_pair_ethusdt);
1669 let quantity1 = Quantity::from(12);
1670 let price1 = Price::from("100.0");
1671 let order1 = OrderTestBuilder::new(OrderType::Market)
1672 .instrument_id(ethusdt.id())
1673 .side(OrderSide::Buy)
1674 .quantity(quantity1)
1675 .build();
1676 let commission1 = calculate_commission(ðusdt, order1.quantity(), price1, None);
1677 let fill1 = TestOrderEventStubs::filled(
1678 &order1,
1679 ðusdt,
1680 Some(TradeId::new("1")),
1681 Some(PositionId::new("P-123456")),
1682 Some(price1),
1683 None,
1684 None,
1685 Some(commission1),
1686 None,
1687 None,
1688 );
1689 let mut position = Position::new(ðusdt, fill1.into());
1690 let quantity2 = Quantity::from(17);
1691 let order2 = OrderTestBuilder::new(OrderType::Market)
1692 .instrument_id(ethusdt.id())
1693 .side(OrderSide::Buy)
1694 .quantity(quantity2)
1695 .build();
1696 let price2 = Price::from("99.0");
1697 let commission2 = calculate_commission(ðusdt, order2.quantity(), price2, None);
1698 let fill2 = TestOrderEventStubs::filled(
1699 &order2,
1700 ðusdt,
1701 Some(TradeId::new("2")),
1702 Some(PositionId::new("P-123456")),
1703 Some(price2),
1704 None,
1705 None,
1706 Some(commission2),
1707 None,
1708 None,
1709 );
1710 position.apply(&fill2.into());
1711 assert_eq!(position.quantity, Quantity::from(29));
1712 assert_eq!(position.realized_pnl, Some(Money::from("-0.28830000 USDT")));
1713 assert_eq!(position.avg_px_open, 99.413_793_103_448_27);
1714 let quantity3 = Quantity::from(9);
1715 let order3 = OrderTestBuilder::new(OrderType::Market)
1716 .instrument_id(ethusdt.id())
1717 .side(OrderSide::Sell)
1718 .quantity(quantity3)
1719 .build();
1720 let price3 = Price::from("101.0");
1721 let commission3 = calculate_commission(ðusdt, order3.quantity(), price3, None);
1722 let fill3 = TestOrderEventStubs::filled(
1723 &order3,
1724 ðusdt,
1725 Some(TradeId::new("3")),
1726 Some(PositionId::new("P-123456")),
1727 Some(price3),
1728 None,
1729 None,
1730 Some(commission3),
1731 None,
1732 None,
1733 );
1734 position.apply(&fill3.into());
1735 assert_eq!(position.quantity, Quantity::from(20));
1736 assert_eq!(position.realized_pnl, Some(Money::from("13.89666207 USDT")));
1737 assert_eq!(position.avg_px_open, 99.413_793_103_448_27);
1738 let quantity4 = Quantity::from("4");
1739 let price4 = Price::from("105.0");
1740 let order4 = OrderTestBuilder::new(OrderType::Market)
1741 .instrument_id(ethusdt.id())
1742 .side(OrderSide::Sell)
1743 .quantity(quantity4)
1744 .build();
1745 let commission4 = calculate_commission(ðusdt, order4.quantity(), price4, None);
1746 let fill4 = TestOrderEventStubs::filled(
1747 &order4,
1748 ðusdt,
1749 Some(TradeId::new("4")),
1750 Some(PositionId::new("P-123456")),
1751 Some(price4),
1752 None,
1753 None,
1754 Some(commission4),
1755 None,
1756 None,
1757 );
1758 position.apply(&fill4.into());
1759 assert_eq!(position.quantity, Quantity::from("16"));
1760 assert_eq!(position.realized_pnl, Some(Money::from("36.19948966 USDT")));
1761 assert_eq!(position.avg_px_open, 99.413_793_103_448_27);
1762 let quantity5 = Quantity::from("3");
1763 let price5 = Price::from("103.0");
1764 let order5 = OrderTestBuilder::new(OrderType::Market)
1765 .instrument_id(ethusdt.id())
1766 .side(OrderSide::Buy)
1767 .quantity(quantity5)
1768 .build();
1769 let commission5 = calculate_commission(ðusdt, order5.quantity(), price5, None);
1770 let fill5 = TestOrderEventStubs::filled(
1771 &order5,
1772 ðusdt,
1773 Some(TradeId::new("5")),
1774 Some(PositionId::new("P-123456")),
1775 Some(price5),
1776 None,
1777 None,
1778 Some(commission5),
1779 None,
1780 None,
1781 );
1782 position.apply(&fill5.into());
1783 assert_eq!(position.quantity, Quantity::from("19"));
1784 assert_eq!(position.realized_pnl, Some(Money::from("36.16858966 USDT")));
1785 assert_eq!(position.avg_px_open, 99.980_036_297_640_65);
1786 assert_eq!(
1787 format!("{position}"),
1788 "Position(LONG 19.00000 ETHUSDT.BINANCE, id=P-123456)"
1789 );
1790 }
1791
1792 #[rstest]
1793 fn test_position_closed_and_reopened(audusd_sim: CurrencyPair) {
1794 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
1795 let quantity1 = Quantity::from(150_000);
1796 let price1 = Price::from("1.00001");
1797 let order = OrderTestBuilder::new(OrderType::Market)
1798 .instrument_id(audusd_sim.id())
1799 .side(OrderSide::Buy)
1800 .quantity(quantity1)
1801 .build();
1802 let commission1 = calculate_commission(&audusd_sim, quantity1, price1, None);
1803 let fill1 = TestOrderEventStubs::filled(
1804 &order,
1805 &audusd_sim,
1806 Some(TradeId::new("5")),
1807 Some(PositionId::new("P-123456")),
1808 Some(Price::from("1.00001")),
1809 None,
1810 None,
1811 Some(commission1),
1812 Some(UnixNanos::from(1_000_000_000)),
1813 None,
1814 );
1815 let mut position = Position::new(&audusd_sim, fill1.into());
1816
1817 let fill2 = OrderFilledSpec::builder()
1818 .trader_id(order.trader_id())
1819 .strategy_id(order.strategy_id())
1820 .instrument_id(order.instrument_id())
1821 .client_order_id(order.client_order_id())
1822 .venue_order_id(VenueOrderId::from("2"))
1823 .account_id(order.account_id().unwrap_or(AccountId::new("SIM-001")))
1824 .trade_id(TradeId::from("2"))
1825 .order_side(OrderSide::Sell)
1826 .last_qty(order.quantity())
1827 .last_px(Price::from("1.00011"))
1828 .currency(audusd_sim.quote_currency())
1829 .ts_event(UnixNanos::from(2_000_000_000))
1830 .position_id(PositionId::from("P-123456"))
1831 .commission(Money::from("0 USD"))
1832 .build();
1833
1834 position.apply(&fill2);
1835
1836 let fill3 = OrderFilledSpec::builder()
1837 .trader_id(order.trader_id())
1838 .strategy_id(order.strategy_id())
1839 .instrument_id(order.instrument_id())
1840 .client_order_id(order.client_order_id())
1841 .venue_order_id(VenueOrderId::from("2"))
1842 .account_id(order.account_id().unwrap_or(AccountId::new("SIM-001")))
1843 .trade_id(TradeId::from("3"))
1844 .last_qty(order.quantity())
1845 .last_px(Price::from("1.00012"))
1846 .currency(audusd_sim.quote_currency())
1847 .ts_event(UnixNanos::from(3_000_000_000))
1848 .position_id(PositionId::from("P-123456"))
1849 .commission(Money::from("0 USD"))
1850 .build();
1851
1852 position.apply(&fill3);
1853
1854 let last = Price::from("1.0003");
1855 assert!(position.is_opposite_side(fill2.order_side));
1856 assert_eq!(position.quantity, Quantity::from(150_000));
1857 assert_eq!(position.peak_qty, Quantity::from(150_000));
1858 assert_eq!(position.side, PositionSide::Long);
1859 assert_eq!(position.opening_order_id, fill3.client_order_id);
1860 assert_eq!(position.closing_order_id, None);
1861 assert_eq!(position.closing_order_id, None);
1862 assert_eq!(position.ts_opened, 3_000_000_000);
1863 assert_eq!(position.duration_ns, 0);
1864 assert_eq!(position.avg_px_open, 1.00012);
1865 assert_eq!(position.event_count(), 1);
1866 assert_eq!(position.ts_closed, None);
1867 assert_eq!(position.avg_px_close, None);
1868 assert!(position.is_long());
1869 assert!(!position.is_short());
1870 assert!(position.is_open());
1871 assert!(!position.is_closed());
1872 assert_eq!(position.realized_return, 0.0);
1873 assert_eq!(position.realized_pnl, Some(Money::from("0 USD")));
1874 assert_eq!(position.unrealized_pnl(last), Money::from("27 USD"));
1875 assert_eq!(position.total_pnl(last), Money::from("27 USD"));
1876 assert_eq!(position.commissions(), vec![Money::from("0 USD")]);
1877 assert_eq!(
1878 format!("{position}"),
1879 "Position(LONG 150_000 AUD/USD.SIM, id=P-123456)"
1880 );
1881 }
1882
1883 #[rstest]
1884 fn test_position_realized_pnl_with_interleaved_order_sides(
1885 currency_pair_btcusdt: CurrencyPair,
1886 ) {
1887 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
1888 let order1 = OrderTestBuilder::new(OrderType::Market)
1889 .instrument_id(btcusdt.id())
1890 .side(OrderSide::Buy)
1891 .quantity(Quantity::from(12))
1892 .build();
1893 let commission1 =
1894 calculate_commission(&btcusdt, order1.quantity(), Price::from("10000.0"), None);
1895 let fill1 = TestOrderEventStubs::filled(
1896 &order1,
1897 &btcusdt,
1898 Some(TradeId::from("1")),
1899 Some(PositionId::from("P-19700101-000000-001-001-1")),
1900 Some(Price::from("10000.0")),
1901 None,
1902 None,
1903 Some(commission1),
1904 None,
1905 None,
1906 );
1907 let mut position = Position::new(&btcusdt, fill1.into());
1908 let order2 = OrderTestBuilder::new(OrderType::Market)
1909 .instrument_id(btcusdt.id())
1910 .side(OrderSide::Buy)
1911 .quantity(Quantity::from(17))
1912 .build();
1913 let commission2 =
1914 calculate_commission(&btcusdt, order2.quantity(), Price::from("9999.0"), None);
1915 let fill2 = TestOrderEventStubs::filled(
1916 &order2,
1917 &btcusdt,
1918 Some(TradeId::from("2")),
1919 Some(PositionId::from("P-19700101-000000-001-001-1")),
1920 Some(Price::from("9999.0")),
1921 None,
1922 None,
1923 Some(commission2),
1924 None,
1925 None,
1926 );
1927 position.apply(&fill2.into());
1928 assert_eq!(position.quantity, Quantity::from(29));
1929 assert_eq!(
1930 position.realized_pnl,
1931 Some(Money::from("-289.98300000 USDT"))
1932 );
1933 assert_eq!(position.avg_px_open, 9_999.413_793_103_447);
1934 let order3 = OrderTestBuilder::new(OrderType::Market)
1935 .instrument_id(btcusdt.id())
1936 .side(OrderSide::Sell)
1937 .quantity(Quantity::from(9))
1938 .build();
1939 let commission3 =
1940 calculate_commission(&btcusdt, order3.quantity(), Price::from("10001.0"), None);
1941 let fill3 = TestOrderEventStubs::filled(
1942 &order3,
1943 &btcusdt,
1944 Some(TradeId::from("3")),
1945 Some(PositionId::from("P-19700101-000000-001-001-1")),
1946 Some(Price::from("10001.0")),
1947 None,
1948 None,
1949 Some(commission3),
1950 None,
1951 None,
1952 );
1953 position.apply(&fill3.into());
1954 assert_eq!(position.quantity, Quantity::from(20));
1955 assert_eq!(
1956 position.realized_pnl,
1957 Some(Money::from("-365.71613793 USDT"))
1958 );
1959 assert_eq!(position.avg_px_open, 9_999.413_793_103_447);
1960 let order4 = OrderTestBuilder::new(OrderType::Market)
1961 .instrument_id(btcusdt.id())
1962 .side(OrderSide::Buy)
1963 .quantity(Quantity::from(3))
1964 .build();
1965 let commission4 =
1966 calculate_commission(&btcusdt, order4.quantity(), Price::from("10003.0"), None);
1967 let fill4 = TestOrderEventStubs::filled(
1968 &order4,
1969 &btcusdt,
1970 Some(TradeId::from("4")),
1971 Some(PositionId::from("P-19700101-000000-001-001-1")),
1972 Some(Price::from("10003.0")),
1973 None,
1974 None,
1975 Some(commission4),
1976 None,
1977 None,
1978 );
1979 position.apply(&fill4.into());
1980 assert_eq!(position.quantity, Quantity::from(23));
1981 assert_eq!(
1982 position.realized_pnl,
1983 Some(Money::from("-395.72513793 USDT"))
1984 );
1985 assert_eq!(position.avg_px_open, 9_999.881_559_220_39);
1986 let order5 = OrderTestBuilder::new(OrderType::Market)
1987 .instrument_id(btcusdt.id())
1988 .side(OrderSide::Sell)
1989 .quantity(Quantity::from(4))
1990 .build();
1991 let commission5 =
1992 calculate_commission(&btcusdt, order5.quantity(), Price::from("10005.0"), None);
1993 let fill5 = TestOrderEventStubs::filled(
1994 &order5,
1995 &btcusdt,
1996 Some(TradeId::from("5")),
1997 Some(PositionId::from("P-19700101-000000-001-001-1")),
1998 Some(Price::from("10005.0")),
1999 None,
2000 None,
2001 Some(commission5),
2002 None,
2003 None,
2004 );
2005 position.apply(&fill5.into());
2006 assert_eq!(position.quantity, Quantity::from(19));
2007 assert_eq!(
2008 position.realized_pnl,
2009 Some(Money::from("-415.27137481 USDT"))
2010 );
2011 assert_eq!(position.avg_px_open, 9_999.881_559_220_39);
2012 assert_eq!(
2013 format!("{position}"),
2014 "Position(LONG 19.000000 BTCUSDT.BINANCE, id=P-19700101-000000-001-001-1)"
2015 );
2016 }
2017
2018 #[rstest]
2019 fn test_calculate_pnl_when_given_position_side_flat_returns_zero(
2020 currency_pair_btcusdt: CurrencyPair,
2021 ) {
2022 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
2023 let order = OrderTestBuilder::new(OrderType::Market)
2024 .instrument_id(btcusdt.id())
2025 .side(OrderSide::Buy)
2026 .quantity(Quantity::from(12))
2027 .build();
2028 let fill = TestOrderEventStubs::filled(
2029 &order,
2030 &btcusdt,
2031 None,
2032 Some(PositionId::from("P-123456")),
2033 Some(Price::from("10500.0")),
2034 None,
2035 None,
2036 None,
2037 None,
2038 None,
2039 );
2040 let position = Position::new(&btcusdt, fill.into());
2041 let result = position.calculate_pnl(10500.0, 10500.0, Quantity::from("100000.0"));
2042 assert_eq!(result, Money::from("0 USDT"));
2043 }
2044
2045 #[rstest]
2046 fn test_calculate_pnl_for_long_position_win(currency_pair_btcusdt: CurrencyPair) {
2047 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
2048 let order = OrderTestBuilder::new(OrderType::Market)
2049 .instrument_id(btcusdt.id())
2050 .side(OrderSide::Buy)
2051 .quantity(Quantity::from(12))
2052 .build();
2053 let commission =
2054 calculate_commission(&btcusdt, order.quantity(), Price::from("10500.0"), None);
2055 let fill = TestOrderEventStubs::filled(
2056 &order,
2057 &btcusdt,
2058 None,
2059 Some(PositionId::from("P-123456")),
2060 Some(Price::from("10500.0")),
2061 None,
2062 None,
2063 Some(commission),
2064 None,
2065 None,
2066 );
2067 let position = Position::new(&btcusdt, fill.into());
2068 let pnl = position.calculate_pnl(10500.0, 10510.0, Quantity::from("12.0"));
2069 assert_eq!(pnl, Money::from("120 USDT"));
2070 assert_eq!(position.realized_pnl, Some(Money::from("-126 USDT")));
2071 assert_eq!(
2072 position.unrealized_pnl(Price::from("10510.0")),
2073 Money::from("120.0 USDT")
2074 );
2075 assert_eq!(
2076 position.total_pnl(Price::from("10510.0")),
2077 Money::from("-6 USDT")
2078 );
2079 assert_eq!(position.commissions(), vec![Money::from("126.0 USDT")]);
2080 }
2081
2082 #[rstest]
2083 fn test_calculate_pnl_for_long_position_loss(currency_pair_btcusdt: CurrencyPair) {
2084 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
2085 let order = OrderTestBuilder::new(OrderType::Market)
2086 .instrument_id(btcusdt.id())
2087 .side(OrderSide::Buy)
2088 .quantity(Quantity::from(12))
2089 .build();
2090 let commission =
2091 calculate_commission(&btcusdt, order.quantity(), Price::from("10500.0"), None);
2092 let fill = TestOrderEventStubs::filled(
2093 &order,
2094 &btcusdt,
2095 None,
2096 Some(PositionId::from("P-123456")),
2097 Some(Price::from("10500.0")),
2098 None,
2099 None,
2100 Some(commission),
2101 None,
2102 None,
2103 );
2104 let position = Position::new(&btcusdt, fill.into());
2105 let pnl = position.calculate_pnl(10500.0, 10480.5, Quantity::from("10.0"));
2106 assert_eq!(pnl, Money::from("-195 USDT"));
2107 assert_eq!(position.realized_pnl, Some(Money::from("-126 USDT")));
2108 assert_eq!(
2109 position.unrealized_pnl(Price::from("10480.50")),
2110 Money::from("-234.0 USDT")
2111 );
2112 assert_eq!(
2113 position.total_pnl(Price::from("10480.50")),
2114 Money::from("-360 USDT")
2115 );
2116 assert_eq!(position.commissions(), vec![Money::from("126.0 USDT")]);
2117 }
2118
2119 #[rstest]
2120 fn test_calculate_pnl_for_short_position_winning(currency_pair_btcusdt: CurrencyPair) {
2121 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
2122 let order = OrderTestBuilder::new(OrderType::Market)
2123 .instrument_id(btcusdt.id())
2124 .side(OrderSide::Sell)
2125 .quantity(Quantity::from("10.15"))
2126 .build();
2127 let commission =
2128 calculate_commission(&btcusdt, order.quantity(), Price::from("10500.0"), None);
2129 let fill = TestOrderEventStubs::filled(
2130 &order,
2131 &btcusdt,
2132 None,
2133 Some(PositionId::from("P-123456")),
2134 Some(Price::from("10500.0")),
2135 None,
2136 None,
2137 Some(commission),
2138 None,
2139 None,
2140 );
2141 let position = Position::new(&btcusdt, fill.into());
2142 let pnl = position.calculate_pnl(10500.0, 10390.0, Quantity::from("10.15"));
2143 assert_eq!(pnl, Money::from("1116.5 USDT"));
2144 assert_eq!(
2145 position.unrealized_pnl(Price::from("10390.0")),
2146 Money::from("1116.5 USDT")
2147 );
2148 assert_eq!(position.realized_pnl, Some(Money::from("-106.575 USDT")));
2149 assert_eq!(position.commissions(), vec![Money::from("106.575 USDT")]);
2150 assert_eq!(
2151 position.notional_value(Price::from("10390.0")),
2152 Money::from("105458.5 USDT")
2153 );
2154 }
2155
2156 #[rstest]
2157 fn test_calculate_pnl_for_short_position_loss(currency_pair_btcusdt: CurrencyPair) {
2158 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
2159 let order = OrderTestBuilder::new(OrderType::Market)
2160 .instrument_id(btcusdt.id())
2161 .side(OrderSide::Sell)
2162 .quantity(Quantity::from("10.0"))
2163 .build();
2164 let commission =
2165 calculate_commission(&btcusdt, order.quantity(), Price::from("10500.0"), None);
2166 let fill = TestOrderEventStubs::filled(
2167 &order,
2168 &btcusdt,
2169 None,
2170 Some(PositionId::from("P-123456")),
2171 Some(Price::from("10500.0")),
2172 None,
2173 None,
2174 Some(commission),
2175 None,
2176 None,
2177 );
2178 let position = Position::new(&btcusdt, fill.into());
2179 let pnl = position.calculate_pnl(10500.0, 10670.5, Quantity::from("10.0"));
2180 assert_eq!(pnl, Money::from("-1705 USDT"));
2181 assert_eq!(
2182 position.unrealized_pnl(Price::from("10670.5")),
2183 Money::from("-1705 USDT")
2184 );
2185 assert_eq!(position.realized_pnl, Some(Money::from("-105 USDT")));
2186 assert_eq!(position.commissions(), vec![Money::from("105 USDT")]);
2187 assert_eq!(
2188 position.notional_value(Price::from("10670.5")),
2189 Money::from("106705 USDT")
2190 );
2191 }
2192
2193 #[rstest]
2194 fn test_calculate_pnl_for_inverse1(xbtusd_bitmex: CryptoPerpetual) {
2195 let xbtusd_bitmex = InstrumentAny::CryptoPerpetual(xbtusd_bitmex);
2196 let order = OrderTestBuilder::new(OrderType::Market)
2197 .instrument_id(xbtusd_bitmex.id())
2198 .side(OrderSide::Sell)
2199 .quantity(Quantity::from("100000"))
2200 .build();
2201 let commission = calculate_commission(
2202 &xbtusd_bitmex,
2203 order.quantity(),
2204 Price::from("10000.0"),
2205 None,
2206 );
2207 let fill = TestOrderEventStubs::filled(
2208 &order,
2209 &xbtusd_bitmex,
2210 None,
2211 Some(PositionId::from("P-123456")),
2212 Some(Price::from("10000.0")),
2213 None,
2214 None,
2215 Some(commission),
2216 None,
2217 None,
2218 );
2219 let position = Position::new(&xbtusd_bitmex, fill.into());
2220 let pnl = position.calculate_pnl(10000.0, 11000.0, Quantity::from("100000.0"));
2221 assert_eq!(pnl, Money::from("-0.90909091 BTC"));
2222 assert_eq!(
2223 position.unrealized_pnl(Price::from("11000.0")),
2224 Money::from("-0.90909091 BTC")
2225 );
2226 assert_eq!(position.realized_pnl, Some(Money::from("-0.00750000 BTC")));
2227 assert_eq!(
2228 position.notional_value(Price::from("11000.0")),
2229 Money::from("9.09090909 BTC")
2230 );
2231 }
2232
2233 #[rstest]
2234 fn test_calculate_pnl_for_inverse2(ethusdt_bitmex: CryptoPerpetual) {
2235 let ethusdt_bitmex = InstrumentAny::CryptoPerpetual(ethusdt_bitmex);
2236 let order = OrderTestBuilder::new(OrderType::Market)
2237 .instrument_id(ethusdt_bitmex.id())
2238 .side(OrderSide::Sell)
2239 .quantity(Quantity::from("100000"))
2240 .build();
2241 let commission = calculate_commission(
2242 ðusdt_bitmex,
2243 order.quantity(),
2244 Price::from("375.95"),
2245 None,
2246 );
2247 let fill = TestOrderEventStubs::filled(
2248 &order,
2249 ðusdt_bitmex,
2250 None,
2251 Some(PositionId::from("P-123456")),
2252 Some(Price::from("375.95")),
2253 None,
2254 None,
2255 Some(commission),
2256 None,
2257 None,
2258 );
2259 let position = Position::new(ðusdt_bitmex, fill.into());
2260
2261 assert_eq!(
2262 position.unrealized_pnl(Price::from("370.00")),
2263 Money::from("4.27745208 ETH")
2264 );
2265 assert_eq!(
2266 position.notional_value(Price::from("370.00")),
2267 Money::from("270.27027027 ETH")
2268 );
2269 }
2270
2271 #[rstest]
2272 fn test_calculate_unrealized_pnl_for_long(currency_pair_btcusdt: CurrencyPair) {
2273 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
2274 let order1 = OrderTestBuilder::new(OrderType::Market)
2275 .instrument_id(btcusdt.id())
2276 .side(OrderSide::Buy)
2277 .quantity(Quantity::from("2.000000"))
2278 .build();
2279 let order2 = OrderTestBuilder::new(OrderType::Market)
2280 .instrument_id(btcusdt.id())
2281 .side(OrderSide::Buy)
2282 .quantity(Quantity::from("2.000000"))
2283 .build();
2284 let commission1 =
2285 calculate_commission(&btcusdt, order1.quantity(), Price::from("10500.0"), None);
2286 let fill1 = TestOrderEventStubs::filled(
2287 &order1,
2288 &btcusdt,
2289 Some(TradeId::new("1")),
2290 Some(PositionId::new("P-123456")),
2291 Some(Price::from("10500.00")),
2292 None,
2293 None,
2294 Some(commission1),
2295 None,
2296 None,
2297 );
2298 let commission2 =
2299 calculate_commission(&btcusdt, order2.quantity(), Price::from("10500.0"), None);
2300 let fill2 = TestOrderEventStubs::filled(
2301 &order2,
2302 &btcusdt,
2303 Some(TradeId::new("2")),
2304 Some(PositionId::new("P-123456")),
2305 Some(Price::from("10500.00")),
2306 None,
2307 None,
2308 Some(commission2),
2309 None,
2310 None,
2311 );
2312 let mut position = Position::new(&btcusdt, fill1.into());
2313 position.apply(&fill2.into());
2314 let pnl = position.unrealized_pnl(Price::from("11505.60"));
2315 assert_eq!(pnl, Money::from("4022.40000000 USDT"));
2316 assert_eq!(
2317 position.realized_pnl,
2318 Some(Money::from("-42.00000000 USDT"))
2319 );
2320 assert_eq!(
2321 position.commissions(),
2322 vec![Money::from("42.00000000 USDT")]
2323 );
2324 }
2325
2326 #[rstest]
2327 fn test_calculate_unrealized_pnl_for_short(currency_pair_btcusdt: CurrencyPair) {
2328 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
2329 let order = OrderTestBuilder::new(OrderType::Market)
2330 .instrument_id(btcusdt.id())
2331 .side(OrderSide::Sell)
2332 .quantity(Quantity::from("5.912000"))
2333 .build();
2334 let commission =
2335 calculate_commission(&btcusdt, order.quantity(), Price::from("10505.60"), None);
2336 let fill = TestOrderEventStubs::filled(
2337 &order,
2338 &btcusdt,
2339 Some(TradeId::new("1")),
2340 Some(PositionId::new("P-123456")),
2341 Some(Price::from("10505.60")),
2342 None,
2343 None,
2344 Some(commission),
2345 None,
2346 None,
2347 );
2348 let position = Position::new(&btcusdt, fill.into());
2349 let pnl = position.unrealized_pnl(Price::from("10407.15"));
2350 assert_eq!(pnl, Money::from("582.03640000 USDT"));
2351 assert_eq!(
2352 position.realized_pnl,
2353 Some(Money::from("-62.10910720 USDT"))
2354 );
2355 assert_eq!(
2356 position.commissions(),
2357 vec![Money::from("62.10910720 USDT")]
2358 );
2359 }
2360
2361 #[rstest]
2362 fn test_calculate_unrealized_pnl_for_long_inverse(xbtusd_bitmex: CryptoPerpetual) {
2363 let xbtusd_bitmex = InstrumentAny::CryptoPerpetual(xbtusd_bitmex);
2364 let order = OrderTestBuilder::new(OrderType::Market)
2365 .instrument_id(xbtusd_bitmex.id())
2366 .side(OrderSide::Buy)
2367 .quantity(Quantity::from("100000"))
2368 .build();
2369 let commission = calculate_commission(
2370 &xbtusd_bitmex,
2371 order.quantity(),
2372 Price::from("10500.0"),
2373 None,
2374 );
2375 let fill = TestOrderEventStubs::filled(
2376 &order,
2377 &xbtusd_bitmex,
2378 Some(TradeId::new("1")),
2379 Some(PositionId::new("P-123456")),
2380 Some(Price::from("10500.00")),
2381 None,
2382 None,
2383 Some(commission),
2384 None,
2385 None,
2386 );
2387
2388 let position = Position::new(&xbtusd_bitmex, fill.into());
2389 let pnl = position.unrealized_pnl(Price::from("11505.60"));
2390 assert_eq!(pnl, Money::from("0.83238969 BTC"));
2391 assert_eq!(position.realized_pnl, Some(Money::from("-0.00714286 BTC")));
2392 assert_eq!(position.commissions(), vec![Money::from("0.00714286 BTC")]);
2393 }
2394
2395 #[rstest]
2396 fn test_calculate_unrealized_pnl_for_short_inverse(xbtusd_bitmex: CryptoPerpetual) {
2397 let xbtusd_bitmex = InstrumentAny::CryptoPerpetual(xbtusd_bitmex);
2398 let order = OrderTestBuilder::new(OrderType::Market)
2399 .instrument_id(xbtusd_bitmex.id())
2400 .side(OrderSide::Sell)
2401 .quantity(Quantity::from("1250000"))
2402 .build();
2403 let commission = calculate_commission(
2404 &xbtusd_bitmex,
2405 order.quantity(),
2406 Price::from("15500.00"),
2407 None,
2408 );
2409 let fill = TestOrderEventStubs::filled(
2410 &order,
2411 &xbtusd_bitmex,
2412 Some(TradeId::new("1")),
2413 Some(PositionId::new("P-123456")),
2414 Some(Price::from("15500.00")),
2415 None,
2416 None,
2417 Some(commission),
2418 None,
2419 None,
2420 );
2421 let position = Position::new(&xbtusd_bitmex, fill.into());
2422 let pnl = position.unrealized_pnl(Price::from("12506.65"));
2423
2424 assert_eq!(pnl, Money::from("19.30166700 BTC"));
2425 assert_eq!(position.realized_pnl, Some(Money::from("-0.06048387 BTC")));
2426 assert_eq!(position.commissions(), vec![Money::from("0.06048387 BTC")]);
2427 }
2428
2429 #[rstest]
2430 #[case(OrderSide::Buy, 25, 25.0)]
2431 #[case(OrderSide::Sell,25,-25.0)]
2432 fn test_signed_qty_decimal_qty_for_equity(
2433 #[case] order_side: OrderSide,
2434 #[case] quantity: i64,
2435 #[case] expected: f64,
2436 audusd_sim: CurrencyPair,
2437 ) {
2438 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2439 let order = OrderTestBuilder::new(OrderType::Market)
2440 .instrument_id(audusd_sim.id())
2441 .side(order_side)
2442 .quantity(Quantity::from(quantity))
2443 .build();
2444
2445 let commission =
2446 calculate_commission(&audusd_sim, order.quantity(), Price::from("1.0"), None);
2447 let fill = TestOrderEventStubs::filled(
2448 &order,
2449 &audusd_sim,
2450 None,
2451 Some(PositionId::from("P-123456")),
2452 None,
2453 None,
2454 None,
2455 Some(commission),
2456 None,
2457 None,
2458 );
2459 let position = Position::new(&audusd_sim, fill.into());
2460 assert_eq!(position.signed_qty, expected);
2461 }
2462
2463 #[rstest]
2464 fn test_position_with_commission_none(audusd_sim: CurrencyPair) {
2465 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2466 let fill = OrderFilledSpec::builder()
2467 .position_id(PositionId::from("1"))
2468 .build();
2469
2470 let position = Position::new(&audusd_sim, fill);
2471 assert_eq!(position.realized_pnl, Some(Money::from("0 USD")));
2472 }
2473
2474 #[rstest]
2475 fn test_position_with_commission_zero(audusd_sim: CurrencyPair) {
2476 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2477 let fill = OrderFilledSpec::builder()
2478 .position_id(PositionId::from("1"))
2479 .commission(Money::from("0 USD"))
2480 .build();
2481
2482 let position = Position::new(&audusd_sim, fill);
2483 assert_eq!(position.realized_pnl, Some(Money::from("0 USD")));
2484 }
2485
2486 #[rstest]
2487 fn test_cache_purge_order_events() {
2488 let audusd_sim = audusd_sim();
2489 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2490
2491 let order1 = OrderTestBuilder::new(OrderType::Market)
2492 .client_order_id(ClientOrderId::new("O-1"))
2493 .instrument_id(audusd_sim.id())
2494 .side(OrderSide::Buy)
2495 .quantity(Quantity::from(50_000))
2496 .build();
2497
2498 let order2 = OrderTestBuilder::new(OrderType::Market)
2499 .client_order_id(ClientOrderId::new("O-2"))
2500 .instrument_id(audusd_sim.id())
2501 .side(OrderSide::Buy)
2502 .quantity(Quantity::from(50_000))
2503 .build();
2504
2505 let position_id = PositionId::new("P-123456");
2506
2507 let fill1 = TestOrderEventStubs::filled(
2508 &order1,
2509 &audusd_sim,
2510 Some(TradeId::new("1")),
2511 Some(position_id),
2512 Some(Price::from("1.00001")),
2513 None,
2514 None,
2515 None,
2516 None,
2517 None,
2518 );
2519
2520 let mut position = Position::new(&audusd_sim, fill1.into());
2521
2522 let fill2 = TestOrderEventStubs::filled(
2523 &order2,
2524 &audusd_sim,
2525 Some(TradeId::new("2")),
2526 Some(position_id),
2527 Some(Price::from("1.00002")),
2528 None,
2529 None,
2530 None,
2531 None,
2532 None,
2533 );
2534
2535 position.apply(&fill2.into());
2536 position.purge_events_for_order(order1.client_order_id());
2537
2538 assert_eq!(position.events.len(), 1);
2539 assert_eq!(position.trade_ids.len(), 1);
2540 assert_eq!(position.events[0].client_order_id, order2.client_order_id());
2541 assert!(position.trade_ids.contains(&TradeId::new("2")));
2542 }
2543
2544 #[rstest]
2545 fn test_purge_all_events_returns_none_for_last_event_and_trade_id() {
2546 let audusd_sim = audusd_sim();
2547 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2548
2549 let order = OrderTestBuilder::new(OrderType::Market)
2550 .client_order_id(ClientOrderId::new("O-1"))
2551 .instrument_id(audusd_sim.id())
2552 .side(OrderSide::Buy)
2553 .quantity(Quantity::from(100_000))
2554 .build();
2555
2556 let position_id = PositionId::new("P-123456");
2557 let fill = TestOrderEventStubs::filled(
2558 &order,
2559 &audusd_sim,
2560 Some(TradeId::new("1")),
2561 Some(position_id),
2562 Some(Price::from("1.00050")),
2563 None,
2564 None,
2565 None,
2566 Some(UnixNanos::from(1_000_000_000)), None,
2568 );
2569
2570 let mut position = Position::new(&audusd_sim, fill.into());
2571
2572 assert_eq!(position.events.len(), 1);
2573 assert!(position.last_event().is_some());
2574 assert!(position.last_trade_id().is_some());
2575
2576 let original_ts_opened = position.ts_opened;
2578 let original_ts_last = position.ts_last;
2579 assert_ne!(original_ts_opened, UnixNanos::default());
2580 assert_ne!(original_ts_last, UnixNanos::default());
2581
2582 position.purge_events_for_order(order.client_order_id());
2583
2584 assert_eq!(position.events.len(), 0);
2585 assert_eq!(position.trade_ids.len(), 0);
2586 assert!(position.last_event().is_none());
2587 assert!(position.last_trade_id().is_none());
2588
2589 assert_eq!(position.ts_opened, UnixNanos::default());
2592 assert_eq!(position.ts_last, UnixNanos::default());
2593 assert_eq!(position.ts_closed, Some(UnixNanos::default()));
2594 assert_eq!(position.duration_ns, 0);
2595
2596 assert!(position.is_closed());
2599 assert!(!position.is_open());
2600 assert_eq!(position.side, PositionSide::Flat);
2601 }
2602
2603 #[rstest]
2604 fn test_revive_from_empty_shell(audusd_sim: CurrencyPair) {
2605 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2607
2608 let order1 = OrderTestBuilder::new(OrderType::Market)
2610 .instrument_id(audusd_sim.id())
2611 .side(OrderSide::Buy)
2612 .quantity(Quantity::from(100_000))
2613 .build();
2614
2615 let fill1 = TestOrderEventStubs::filled(
2616 &order1,
2617 &audusd_sim,
2618 None,
2619 Some(PositionId::new("P-1")),
2620 Some(Price::from("1.00000")),
2621 None,
2622 None,
2623 None,
2624 Some(UnixNanos::from(1_000_000_000)),
2625 None,
2626 );
2627
2628 let mut position = Position::new(&audusd_sim, fill1.into());
2629 position.purge_events_for_order(order1.client_order_id());
2630
2631 assert!(position.is_closed());
2633 assert_eq!(position.ts_closed, Some(UnixNanos::default()));
2634 assert_eq!(position.event_count(), 0);
2635
2636 let order2 = OrderTestBuilder::new(OrderType::Market)
2638 .instrument_id(audusd_sim.id())
2639 .side(OrderSide::Buy)
2640 .quantity(Quantity::from(50_000))
2641 .build();
2642
2643 let fill2 = TestOrderEventStubs::filled(
2644 &order2,
2645 &audusd_sim,
2646 None,
2647 Some(PositionId::new("P-1")),
2648 Some(Price::from("1.00020")),
2649 None,
2650 None,
2651 None,
2652 Some(UnixNanos::from(3_000_000_000)),
2653 None,
2654 );
2655
2656 let fill2_typed: OrderFilled = fill2.clone().into();
2657 position.apply(&fill2_typed);
2658
2659 assert!(position.is_long());
2661 assert!(!position.is_closed());
2662 assert!(position.ts_closed.is_none());
2663 assert_eq!(position.ts_opened, fill2.ts_event());
2664 assert_eq!(position.ts_last, fill2.ts_event());
2665 assert_eq!(position.event_count(), 1);
2666 assert_eq!(position.quantity, Quantity::from(50_000));
2667 }
2668
2669 #[rstest]
2670 fn test_empty_shell_position_invariants(audusd_sim: CurrencyPair) {
2671 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2673
2674 let order = OrderTestBuilder::new(OrderType::Market)
2675 .instrument_id(audusd_sim.id())
2676 .side(OrderSide::Buy)
2677 .quantity(Quantity::from(100_000))
2678 .build();
2679
2680 let fill = TestOrderEventStubs::filled(
2681 &order,
2682 &audusd_sim,
2683 None,
2684 Some(PositionId::new("P-1")),
2685 Some(Price::from("1.00000")),
2686 None,
2687 None,
2688 None,
2689 Some(UnixNanos::from(1_000_000_000)),
2690 None,
2691 );
2692
2693 let mut position = Position::new(&audusd_sim, fill.into());
2694 position.purge_events_for_order(order.client_order_id());
2695
2696 assert_eq!(
2698 position.event_count(),
2699 0,
2700 "Precondition: event_count must be 0"
2701 );
2702
2703 assert!(
2705 position.is_closed(),
2706 "INV1: Empty shell must report is_closed() == true"
2707 );
2708 assert!(
2709 !position.is_open(),
2710 "INV1: Empty shell must report is_open() == false"
2711 );
2712
2713 assert_eq!(
2715 position.side,
2716 PositionSide::Flat,
2717 "INV2: Empty shell must be FLAT"
2718 );
2719
2720 assert!(
2722 position.ts_closed.is_some(),
2723 "INV3: Empty shell must have ts_closed.is_some()"
2724 );
2725 assert_eq!(
2726 position.ts_closed,
2727 Some(UnixNanos::default()),
2728 "INV3: Empty shell ts_closed must be 0"
2729 );
2730
2731 assert_eq!(
2733 position.ts_opened,
2734 UnixNanos::default(),
2735 "INV4: Empty shell ts_opened must be 0"
2736 );
2737 assert_eq!(
2738 position.ts_last,
2739 UnixNanos::default(),
2740 "INV4: Empty shell ts_last must be 0"
2741 );
2742 assert_eq!(
2743 position.duration_ns, 0,
2744 "INV4: Empty shell duration_ns must be 0"
2745 );
2746
2747 assert_eq!(
2749 position.quantity,
2750 Quantity::zero(audusd_sim.size_precision()),
2751 "INV5: Empty shell quantity must be 0"
2752 );
2753
2754 assert!(
2756 position.events.is_empty(),
2757 "INV6: Empty shell must have no events"
2758 );
2759 assert!(
2760 position.trade_ids.is_empty(),
2761 "INV6: Empty shell must have no trade IDs"
2762 );
2763 assert!(
2764 position.last_event().is_none(),
2765 "INV6: Empty shell must have no last event"
2766 );
2767 assert!(
2768 position.last_trade_id().is_none(),
2769 "INV6: Empty shell must have no last trade ID"
2770 );
2771 }
2772
2773 #[rstest]
2774 fn test_position_pnl_precision_with_very_small_amounts(audusd_sim: CurrencyPair) {
2775 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2778 let order = OrderTestBuilder::new(OrderType::Market)
2779 .instrument_id(audusd_sim.id())
2780 .side(OrderSide::Buy)
2781 .quantity(Quantity::from(100))
2782 .build();
2783
2784 let small_commission = Money::new(0.01, Currency::USD());
2786 let fill = TestOrderEventStubs::filled(
2787 &order,
2788 &audusd_sim,
2789 None,
2790 None,
2791 Some(Price::from("1.00001")),
2792 Some(Quantity::from(100)),
2793 None,
2794 Some(small_commission),
2795 None,
2796 None,
2797 );
2798
2799 let position = Position::new(&audusd_sim, fill.into());
2800
2801 assert_eq!(position.commissions().len(), 1);
2803 let recorded_commission = position.commissions()[0];
2804 assert!(
2805 recorded_commission.as_f64() > 0.0,
2806 "Commission of 0.01 should be preserved"
2807 );
2808
2809 let realized = position.realized_pnl.unwrap().as_f64();
2811 assert!(
2812 realized < 0.0,
2813 "Realized PnL should be negative due to commission"
2814 );
2815 }
2816
2817 #[rstest]
2818 fn test_position_pnl_precision_with_high_precision_instrument() {
2819 use crate::instruments::stubs::crypto_perpetual_ethusdt;
2821 let ethusdt = crypto_perpetual_ethusdt();
2822 let ethusdt = InstrumentAny::CryptoPerpetual(ethusdt);
2823
2824 let size_precision = ethusdt.size_precision();
2826
2827 let order = OrderTestBuilder::new(OrderType::Market)
2828 .instrument_id(ethusdt.id())
2829 .side(OrderSide::Buy)
2830 .quantity(Quantity::from("1.123456789"))
2831 .build();
2832
2833 let fill = TestOrderEventStubs::filled(
2834 &order,
2835 ðusdt,
2836 None,
2837 None,
2838 Some(Price::from("2345.123456789")),
2839 Some(Quantity::from("1.123456789")),
2840 None,
2841 Some(Money::from("0.1 USDT")),
2842 None,
2843 None,
2844 );
2845
2846 let position = Position::new(ðusdt, fill.into());
2847
2848 let avg_px = position.avg_px_open;
2850 assert!(
2851 (avg_px - 2_345.123_456_789).abs() < 1e-6,
2852 "High precision price should be preserved within f64 tolerance"
2853 );
2854
2855 assert_eq!(
2858 position.quantity.precision, size_precision,
2859 "Quantity precision should match instrument"
2860 );
2861
2862 let qty_f64 = position.quantity.as_f64();
2864 assert!(
2865 qty_f64 > 1.0 && qty_f64 < 2.0,
2866 "Quantity should be in expected range"
2867 );
2868 }
2869
2870 #[rstest]
2871 fn test_position_pnl_accumulation_across_many_fills(audusd_sim: CurrencyPair) {
2872 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2874 let order = OrderTestBuilder::new(OrderType::Market)
2875 .instrument_id(audusd_sim.id())
2876 .side(OrderSide::Buy)
2877 .quantity(Quantity::from(1000))
2878 .build();
2879
2880 let initial_fill = TestOrderEventStubs::filled(
2881 &order,
2882 &audusd_sim,
2883 Some(TradeId::new("1")),
2884 None,
2885 Some(Price::from("1.00000")),
2886 Some(Quantity::from(10)),
2887 None,
2888 Some(Money::from("0.01 USD")),
2889 None,
2890 None,
2891 );
2892
2893 let mut position = Position::new(&audusd_sim, initial_fill.into());
2894
2895 for i in 2..=100 {
2897 let price_offset = f64::from(i) * 0.00001;
2898 let fill = TestOrderEventStubs::filled(
2899 &order,
2900 &audusd_sim,
2901 Some(TradeId::new(i.to_string())),
2902 None,
2903 Some(Price::from(&format!("{:.5}", 1.0 + price_offset))),
2904 Some(Quantity::from(10)),
2905 None,
2906 Some(Money::from("0.01 USD")),
2907 None,
2908 None,
2909 );
2910 position.apply(&fill.into());
2911 }
2912
2913 assert_eq!(position.events.len(), 100);
2915 assert_eq!(position.quantity, Quantity::from(1000));
2916
2917 let total_commission: f64 = position.commissions().iter().map(|c| c.as_f64()).sum();
2919 assert!(
2920 (total_commission - 1.0).abs() < 1e-10,
2921 "Commission accumulation should be accurate: expected 1.0, was {total_commission}"
2922 );
2923
2924 let avg_px = position.avg_px_open;
2926 assert!(
2927 avg_px > 1.0 && avg_px < 1.001,
2928 "Average price should be reasonable: got {avg_px}"
2929 );
2930 }
2931
2932 #[rstest]
2933 fn test_position_pnl_with_extreme_price_values(audusd_sim: CurrencyPair) {
2934 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2936
2937 let order_small = OrderTestBuilder::new(OrderType::Market)
2939 .instrument_id(audusd_sim.id())
2940 .side(OrderSide::Buy)
2941 .quantity(Quantity::from(100_000))
2942 .build();
2943
2944 let fill_small = TestOrderEventStubs::filled(
2945 &order_small,
2946 &audusd_sim,
2947 None,
2948 None,
2949 Some(Price::from("0.00001")),
2950 Some(Quantity::from(100_000)),
2951 None,
2952 None,
2953 None,
2954 None,
2955 );
2956
2957 let position_small = Position::new(&audusd_sim, fill_small.into());
2958 assert_eq!(position_small.avg_px_open, 0.00001);
2959
2960 let last_price_small = Price::from("0.00002");
2962 let unrealized = position_small.unrealized_pnl(last_price_small);
2963 assert!(
2964 unrealized.as_f64() > 0.0,
2965 "Unrealized PnL should be positive when price doubles"
2966 );
2967
2968 let order_large = OrderTestBuilder::new(OrderType::Market)
2970 .instrument_id(audusd_sim.id())
2971 .side(OrderSide::Buy)
2972 .quantity(Quantity::from(100))
2973 .build();
2974
2975 let fill_large = TestOrderEventStubs::filled(
2976 &order_large,
2977 &audusd_sim,
2978 None,
2979 None,
2980 Some(Price::from("99999.99999")),
2981 Some(Quantity::from(100)),
2982 None,
2983 None,
2984 None,
2985 None,
2986 );
2987
2988 let position_large = Position::new(&audusd_sim, fill_large.into());
2989 assert!(
2990 (position_large.avg_px_open - 99999.99999).abs() < 1e-6,
2991 "Large price should be preserved within f64 tolerance"
2992 );
2993 }
2994
2995 #[rstest]
2996 fn test_position_pnl_roundtrip_precision(audusd_sim: CurrencyPair) {
2997 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2999 let buy_order = OrderTestBuilder::new(OrderType::Market)
3000 .instrument_id(audusd_sim.id())
3001 .side(OrderSide::Buy)
3002 .quantity(Quantity::from(100_000))
3003 .build();
3004
3005 let sell_order = OrderTestBuilder::new(OrderType::Market)
3006 .instrument_id(audusd_sim.id())
3007 .side(OrderSide::Sell)
3008 .quantity(Quantity::from(100_000))
3009 .build();
3010
3011 let open_fill = TestOrderEventStubs::filled(
3013 &buy_order,
3014 &audusd_sim,
3015 Some(TradeId::new("1")),
3016 None,
3017 Some(Price::from("1.123456")),
3018 None,
3019 None,
3020 Some(Money::from("0.50 USD")),
3021 None,
3022 None,
3023 );
3024
3025 let mut position = Position::new(&audusd_sim, open_fill.into());
3026
3027 let close_fill = TestOrderEventStubs::filled(
3029 &sell_order,
3030 &audusd_sim,
3031 Some(TradeId::new("2")),
3032 None,
3033 Some(Price::from("1.123456")),
3034 None,
3035 None,
3036 Some(Money::from("0.50 USD")),
3037 None,
3038 None,
3039 );
3040
3041 position.apply(&close_fill.into());
3042
3043 assert!(position.is_closed());
3045
3046 let realized = position.realized_pnl.unwrap().as_f64();
3048 assert!(
3049 (realized - (-1.0)).abs() < 1e-10,
3050 "Realized PnL should be exactly -1.0 USD (commissions), was {realized}"
3051 );
3052 }
3053
3054 #[rstest]
3055 fn test_position_commission_in_base_currency_buy() {
3056 let btc_usdt = currency_pair_btcusdt();
3058 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
3059
3060 let order = OrderTestBuilder::new(OrderType::Market)
3061 .instrument_id(btc_usdt.id())
3062 .side(OrderSide::Buy)
3063 .quantity(Quantity::from("1.0"))
3064 .build();
3065
3066 let fill = match TestOrderEventStubs::filled(
3068 &order,
3069 &btc_usdt,
3070 Some(TradeId::new("1")),
3071 None,
3072 Some(Price::from("50000.0")),
3073 Some(Quantity::from("1.0")),
3074 None,
3075 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
3076 None,
3077 None,
3078 ) {
3079 OrderEventAny::Filled(fill) => fill,
3080 _ => unreachable!(),
3081 };
3082
3083 let position = Position::new(&btc_usdt, fill);
3084 let replayed_position = Position::new(&btc_usdt, fill);
3085
3086 assert!(
3088 (position.quantity.as_f64() - 0.999).abs() < 1e-9,
3089 "Position quantity should be 0.999 BTC (1.0 - 0.001 commission), was {}",
3090 position.quantity.as_f64()
3091 );
3092
3093 assert!(
3095 (position.signed_qty - 0.999).abs() < 1e-9,
3096 "Signed qty should be 0.999, was {}",
3097 position.signed_qty
3098 );
3099
3100 assert_eq!(
3102 position.adjustments.len(),
3103 1,
3104 "Should have 1 adjustment event"
3105 );
3106 let adjustment = &position.adjustments[0];
3107 assert_eq!(
3108 adjustment.adjustment_type,
3109 PositionAdjustmentType::Commission
3110 );
3111 assert_eq!(
3112 adjustment.quantity_change,
3113 Some(rust_decimal_macros::dec!(-0.001))
3114 );
3115 assert_eq!(adjustment.pnl_change, None);
3116 assert_eq!(
3117 adjustment.event_id,
3118 replayed_position.adjustments[0].event_id
3119 );
3120 }
3121
3122 #[rstest]
3123 fn test_position_commission_in_base_currency_sell() {
3124 let btc_usdt = currency_pair_btcusdt();
3126 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
3127
3128 let order = OrderTestBuilder::new(OrderType::Market)
3129 .instrument_id(btc_usdt.id())
3130 .side(OrderSide::Sell)
3131 .quantity(Quantity::from("1.0"))
3132 .build();
3133
3134 let fill = TestOrderEventStubs::filled(
3136 &order,
3137 &btc_usdt,
3138 Some(TradeId::new("1")),
3139 None,
3140 Some(Price::from("50000.0")),
3141 Some(Quantity::from("1.0")),
3142 None,
3143 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
3144 None,
3145 None,
3146 );
3147
3148 let position = Position::new(&btc_usdt, fill.into());
3149
3150 assert!(
3153 (position.quantity.as_f64() - 1.001).abs() < 1e-9,
3154 "Position quantity should be 1.001 BTC (1.0 + 0.001 commission), was {}",
3155 position.quantity.as_f64()
3156 );
3157
3158 assert!(
3160 (position.signed_qty - (-1.001)).abs() < 1e-9,
3161 "Signed qty should be -1.001, was {}",
3162 position.signed_qty
3163 );
3164
3165 assert_eq!(
3167 position.adjustments.len(),
3168 1,
3169 "Should have 1 adjustment event"
3170 );
3171 let adjustment = &position.adjustments[0];
3172 assert_eq!(
3173 adjustment.adjustment_type,
3174 PositionAdjustmentType::Commission
3175 );
3176 assert_eq!(
3178 adjustment.quantity_change,
3179 Some(rust_decimal_macros::dec!(-0.001))
3180 );
3181 assert_eq!(adjustment.pnl_change, None);
3182 }
3183
3184 #[rstest]
3185 fn test_position_commission_in_quote_currency_no_adjustment() {
3186 let btc_usdt = currency_pair_btcusdt();
3188 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
3189
3190 let order = OrderTestBuilder::new(OrderType::Market)
3191 .instrument_id(btc_usdt.id())
3192 .side(OrderSide::Buy)
3193 .quantity(Quantity::from("1.0"))
3194 .build();
3195
3196 let fill = TestOrderEventStubs::filled(
3198 &order,
3199 &btc_usdt,
3200 Some(TradeId::new("1")),
3201 None,
3202 Some(Price::from("50000.0")),
3203 Some(Quantity::from("1.0")),
3204 None,
3205 Some(Money::new(50.0, Currency::USD())),
3206 None,
3207 None,
3208 );
3209
3210 let position = Position::new(&btc_usdt, fill.into());
3211
3212 assert!(
3214 (position.quantity.as_f64() - 1.0).abs() < 1e-9,
3215 "Position quantity should be 1.0 BTC (no adjustment for quote currency commission), was {}",
3216 position.quantity.as_f64()
3217 );
3218
3219 assert_eq!(
3221 position.adjustments.len(),
3222 0,
3223 "Should have no adjustment events for quote currency commission"
3224 );
3225 }
3226
3227 #[rstest]
3228 fn test_position_reset_clears_adjustments() {
3229 let btc_usdt = currency_pair_btcusdt();
3231 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
3232
3233 let buy_order = OrderTestBuilder::new(OrderType::Market)
3235 .instrument_id(btc_usdt.id())
3236 .side(OrderSide::Buy)
3237 .quantity(Quantity::from("1.0"))
3238 .build();
3239
3240 let buy_fill = TestOrderEventStubs::filled(
3241 &buy_order,
3242 &btc_usdt,
3243 Some(TradeId::new("1")),
3244 None,
3245 Some(Price::from("50000.0")),
3246 Some(Quantity::from("1.0")),
3247 None,
3248 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
3249 None,
3250 None,
3251 );
3252
3253 let mut position = Position::new(&btc_usdt, buy_fill.into());
3254 assert_eq!(position.adjustments.len(), 1, "Should have 1 adjustment");
3255
3256 let sell_order = OrderTestBuilder::new(OrderType::Market)
3258 .instrument_id(btc_usdt.id())
3259 .side(OrderSide::Sell)
3260 .quantity(Quantity::from("0.999"))
3261 .build();
3262
3263 let sell_fill = TestOrderEventStubs::filled(
3264 &sell_order,
3265 &btc_usdt,
3266 Some(TradeId::new("2")),
3267 None,
3268 Some(Price::from("51000.0")),
3269 Some(Quantity::from("0.999")),
3270 None,
3271 Some(Money::new(50.0, Currency::USD())), None,
3273 None,
3274 );
3275
3276 position.apply(&sell_fill.into());
3277 assert_eq!(position.side, PositionSide::Flat);
3278 assert_eq!(
3279 position.adjustments.len(),
3280 1,
3281 "Should still have 1 adjustment (no new one from quote commission)"
3282 );
3283
3284 let buy_order2 = OrderTestBuilder::new(OrderType::Market)
3286 .instrument_id(btc_usdt.id())
3287 .side(OrderSide::Buy)
3288 .quantity(Quantity::from("2.0"))
3289 .build();
3290
3291 let buy_fill2 = TestOrderEventStubs::filled(
3292 &buy_order2,
3293 &btc_usdt,
3294 Some(TradeId::new("3")),
3295 None,
3296 Some(Price::from("52000.0")),
3297 Some(Quantity::from("2.0")),
3298 None,
3299 Some(Money::new(0.002, btc_usdt.base_currency().unwrap())),
3300 None,
3301 None,
3302 );
3303
3304 position.apply(&buy_fill2.into());
3305
3306 assert_eq!(
3308 position.adjustments.len(),
3309 1,
3310 "Adjustments should be cleared on position reset, only new adjustment"
3311 );
3312 assert_eq!(
3313 position.adjustments[0].quantity_change,
3314 Some(rust_decimal_macros::dec!(-0.002)),
3315 "New adjustment should be for the new fill"
3316 );
3317 assert_eq!(position.events.len(), 1, "Events should also be reset");
3318 }
3319
3320 #[rstest]
3321 fn test_purge_events_for_order_clears_adjustments_when_flat() {
3322 let btc_usdt = currency_pair_btcusdt();
3324 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
3325
3326 let order = OrderTestBuilder::new(OrderType::Market)
3327 .instrument_id(btc_usdt.id())
3328 .side(OrderSide::Buy)
3329 .quantity(Quantity::from("1.0"))
3330 .build();
3331
3332 let fill = TestOrderEventStubs::filled(
3333 &order,
3334 &btc_usdt,
3335 Some(TradeId::new("1")),
3336 None,
3337 Some(Price::from("50000.0")),
3338 Some(Quantity::from("1.0")),
3339 None,
3340 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
3341 None,
3342 None,
3343 );
3344
3345 let mut position = Position::new(&btc_usdt, fill.into());
3346 assert_eq!(position.adjustments.len(), 1, "Should have 1 adjustment");
3347 assert_eq!(position.events.len(), 1);
3348
3349 position.purge_events_for_order(order.client_order_id());
3351
3352 assert_eq!(position.side, PositionSide::Flat);
3353 assert_eq!(position.events.len(), 0, "Events should be cleared");
3354 assert_eq!(
3355 position.adjustments.len(),
3356 0,
3357 "Adjustments should be cleared when position goes flat"
3358 );
3359 assert_eq!(position.quantity, Quantity::zero(btc_usdt.size_precision()));
3360 }
3361
3362 #[rstest]
3363 fn test_purge_events_for_order_clears_adjustments_on_rebuild() {
3364 let btc_usdt = currency_pair_btcusdt();
3366 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
3367
3368 let order1 = OrderTestBuilder::new(OrderType::Market)
3370 .instrument_id(btc_usdt.id())
3371 .side(OrderSide::Buy)
3372 .quantity(Quantity::from("1.0"))
3373 .client_order_id(ClientOrderId::new("O-001"))
3374 .build();
3375
3376 let fill1 = TestOrderEventStubs::filled(
3377 &order1,
3378 &btc_usdt,
3379 Some(TradeId::new("1")),
3380 None,
3381 Some(Price::from("50000.0")),
3382 Some(Quantity::from("1.0")),
3383 None,
3384 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
3385 None,
3386 None,
3387 );
3388
3389 let mut position = Position::new(&btc_usdt, fill1.into());
3390 assert_eq!(position.adjustments.len(), 1);
3391
3392 let order2 = OrderTestBuilder::new(OrderType::Market)
3394 .instrument_id(btc_usdt.id())
3395 .side(OrderSide::Buy)
3396 .quantity(Quantity::from("2.0"))
3397 .client_order_id(ClientOrderId::new("O-002"))
3398 .build();
3399
3400 let fill2 = TestOrderEventStubs::filled(
3401 &order2,
3402 &btc_usdt,
3403 Some(TradeId::new("2")),
3404 None,
3405 Some(Price::from("51000.0")),
3406 Some(Quantity::from("2.0")),
3407 None,
3408 Some(Money::new(0.002, btc_usdt.base_currency().unwrap())),
3409 None,
3410 None,
3411 );
3412
3413 position.apply(&fill2.into());
3414 assert_eq!(position.adjustments.len(), 2, "Should have 2 adjustments");
3415 assert_eq!(position.events.len(), 2);
3416
3417 position.purge_events_for_order(order1.client_order_id());
3419
3420 assert_eq!(position.events.len(), 1, "Should have 1 remaining event");
3421 assert_eq!(
3422 position.adjustments.len(),
3423 1,
3424 "Should have only the adjustment from remaining fill"
3425 );
3426 assert_eq!(
3427 position.adjustments[0].quantity_change,
3428 Some(rust_decimal_macros::dec!(-0.002)),
3429 "Should be the adjustment from order2"
3430 );
3431 assert!(
3432 (position.quantity.as_f64() - 1.998).abs() < 1e-9,
3433 "Quantity should be 2.0 - 0.002 commission"
3434 );
3435 }
3436
3437 #[rstest]
3438 fn test_purge_events_preserves_manual_adjustments() {
3439 let btc_usdt = currency_pair_btcusdt();
3441 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
3442
3443 let order1 = OrderTestBuilder::new(OrderType::Market)
3445 .instrument_id(btc_usdt.id())
3446 .side(OrderSide::Buy)
3447 .quantity(Quantity::from("1.0"))
3448 .client_order_id(ClientOrderId::new("O-001"))
3449 .build();
3450
3451 let fill1 = TestOrderEventStubs::filled(
3452 &order1,
3453 &btc_usdt,
3454 Some(TradeId::new("1")),
3455 None,
3456 Some(Price::from("50000.0")),
3457 Some(Quantity::from("1.0")),
3458 None,
3459 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
3460 None,
3461 None,
3462 );
3463
3464 let mut position = Position::new(&btc_usdt, fill1.into());
3465 assert_eq!(position.adjustments.len(), 1);
3466
3467 let funding_adjustment = PositionAdjusted::new(
3469 position.trader_id,
3470 position.strategy_id,
3471 position.instrument_id,
3472 position.id,
3473 position.account_id,
3474 PositionAdjustmentType::Funding,
3475 None,
3476 Some(Money::new(10.0, btc_usdt.quote_currency())),
3477 None, uuid4(),
3479 UnixNanos::default(),
3480 UnixNanos::default(),
3481 );
3482 position.apply_adjustment(funding_adjustment);
3483 assert_eq!(position.adjustments.len(), 2);
3484
3485 let order2 = OrderTestBuilder::new(OrderType::Market)
3487 .instrument_id(btc_usdt.id())
3488 .side(OrderSide::Buy)
3489 .quantity(Quantity::from("2.0"))
3490 .client_order_id(ClientOrderId::new("O-002"))
3491 .build();
3492
3493 let fill2 = TestOrderEventStubs::filled(
3494 &order2,
3495 &btc_usdt,
3496 Some(TradeId::new("2")),
3497 None,
3498 Some(Price::from("51000.0")),
3499 Some(Quantity::from("2.0")),
3500 None,
3501 Some(Money::new(0.002, btc_usdt.base_currency().unwrap())),
3502 None,
3503 None,
3504 );
3505
3506 position.apply(&fill2.into());
3507 assert_eq!(
3508 position.adjustments.len(),
3509 3,
3510 "Should have 3 adjustments: 2 commissions + 1 funding"
3511 );
3512
3513 position.purge_events_for_order(order1.client_order_id());
3515
3516 assert_eq!(position.events.len(), 1, "Should have 1 remaining event");
3517 assert_eq!(
3518 position.adjustments.len(),
3519 2,
3520 "Should have funding adjustment + commission from remaining fill"
3521 );
3522
3523 let has_funding = position.adjustments.iter().any(|adj| {
3525 adj.adjustment_type == PositionAdjustmentType::Funding
3526 && adj.pnl_change == Some(Money::new(10.0, btc_usdt.quote_currency()))
3527 });
3528 assert!(has_funding, "Funding adjustment should be preserved");
3529
3530 assert_eq!(
3533 position.realized_pnl,
3534 Some(Money::new(10.0, btc_usdt.quote_currency())),
3535 "Realized PnL should be the funding payment only (commission is in BTC, not USDT)"
3536 );
3537 }
3538
3539 #[rstest]
3540 fn test_position_commission_affects_buy_and_sell_qty() {
3541 let btc_usdt = currency_pair_btcusdt();
3543 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
3544
3545 let buy_order = OrderTestBuilder::new(OrderType::Market)
3546 .instrument_id(btc_usdt.id())
3547 .side(OrderSide::Buy)
3548 .quantity(Quantity::from("1.0"))
3549 .build();
3550
3551 let fill = TestOrderEventStubs::filled(
3553 &buy_order,
3554 &btc_usdt,
3555 Some(TradeId::new("1")),
3556 None,
3557 Some(Price::from("50000.0")),
3558 Some(Quantity::from("1.0")),
3559 None,
3560 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
3561 None,
3562 None,
3563 );
3564
3565 let position = Position::new(&btc_usdt, fill.into());
3566
3567 assert!(
3569 (position.buy_qty.as_f64() - 1.0).abs() < 1e-9,
3570 "buy_qty should be 1.0 (order fill amount), was {}",
3571 position.buy_qty.as_f64()
3572 );
3573
3574 assert!(
3576 (position.quantity.as_f64() - 0.999).abs() < 1e-9,
3577 "position.quantity should be 0.999 (1.0 - 0.001 commission), was {}",
3578 position.quantity.as_f64()
3579 );
3580
3581 assert_eq!(position.adjustments.len(), 1);
3583 assert_eq!(
3584 position.adjustments[0].quantity_change,
3585 Some(rust_decimal_macros::dec!(-0.001))
3586 );
3587 }
3588
3589 #[rstest]
3590 fn test_position_perpetual_commission_no_adjustment() {
3591 let eth_perp = crypto_perpetual_ethusdt();
3593 let eth_perp = InstrumentAny::CryptoPerpetual(eth_perp);
3594
3595 let order = OrderTestBuilder::new(OrderType::Market)
3596 .instrument_id(eth_perp.id())
3597 .side(OrderSide::Buy)
3598 .quantity(Quantity::from("1.0"))
3599 .build();
3600
3601 let fill = TestOrderEventStubs::filled(
3603 &order,
3604 ð_perp,
3605 Some(TradeId::new("1")),
3606 None,
3607 Some(Price::from("3000.0")),
3608 Some(Quantity::from("1.0")),
3609 None,
3610 Some(Money::new(0.001, eth_perp.base_currency().unwrap())),
3611 None,
3612 None,
3613 );
3614
3615 let position = Position::new(ð_perp, fill.into());
3616
3617 assert!(
3619 (position.quantity.as_f64() - 1.0).abs() < 1e-9,
3620 "Perpetual position should be 1.0 contracts (no adjustment), was {}",
3621 position.quantity.as_f64()
3622 );
3623
3624 assert!(
3626 (position.signed_qty - 1.0).abs() < 1e-9,
3627 "Signed qty should be 1.0, was {}",
3628 position.signed_qty
3629 );
3630 }
3631
3632 #[rstest]
3633 fn test_signed_decimal_qty_long(stub_position_long: Position) {
3634 let signed_qty = stub_position_long.signed_decimal_qty();
3635 assert!(signed_qty > Decimal::ZERO);
3636 assert_eq!(
3637 signed_qty,
3638 Decimal::try_from(stub_position_long.signed_qty).unwrap()
3639 );
3640 }
3641
3642 #[rstest]
3643 fn test_signed_decimal_qty_short(stub_position_short: Position) {
3644 let signed_qty = stub_position_short.signed_decimal_qty();
3645 assert!(signed_qty < Decimal::ZERO);
3646 assert_eq!(
3647 signed_qty,
3648 Decimal::try_from(stub_position_short.signed_qty).unwrap()
3649 );
3650 }
3651
3652 #[rstest]
3653 fn test_signed_decimal_qty_flat(audusd_sim: CurrencyPair) {
3654 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3655 let order = OrderTestBuilder::new(OrderType::Market)
3656 .instrument_id(audusd_sim.id())
3657 .side(OrderSide::Buy)
3658 .quantity(Quantity::from(100_000))
3659 .build();
3660 let fill = TestOrderEventStubs::filled(
3661 &order,
3662 &audusd_sim,
3663 Some(TradeId::new("1")),
3664 None,
3665 Some(Price::from("1.00001")),
3666 None,
3667 None,
3668 None,
3669 None,
3670 None,
3671 );
3672 let mut position = Position::new(&audusd_sim, fill.into());
3673
3674 let close_order = OrderTestBuilder::new(OrderType::Market)
3675 .instrument_id(audusd_sim.id())
3676 .side(OrderSide::Sell)
3677 .quantity(Quantity::from(100_000))
3678 .build();
3679 let close_fill = TestOrderEventStubs::filled(
3680 &close_order,
3681 &audusd_sim,
3682 Some(TradeId::new("2")),
3683 None,
3684 Some(Price::from("1.00002")),
3685 None,
3686 None,
3687 None,
3688 None,
3689 None,
3690 );
3691 position.apply(&close_fill.into());
3692
3693 assert_eq!(position.side, PositionSide::Flat);
3694 assert_eq!(position.signed_decimal_qty(), Decimal::ZERO);
3695 }
3696
3697 #[rstest]
3698 fn test_position_flat_with_floating_point_precision_edge_case() {
3699 let btc_usdt = currency_pair_btcusdt();
3703 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
3704
3705 let order1 = OrderTestBuilder::new(OrderType::Market)
3706 .instrument_id(btc_usdt.id())
3707 .side(OrderSide::Buy)
3708 .quantity(Quantity::from("0.123456789"))
3709 .build();
3710 let fill1 = TestOrderEventStubs::filled(
3711 &order1,
3712 &btc_usdt,
3713 Some(TradeId::new("1")),
3714 None,
3715 Some(Price::from("50000.00")),
3716 None,
3717 None,
3718 None,
3719 None,
3720 None,
3721 );
3722 let mut position = Position::new(&btc_usdt, fill1.into());
3723
3724 assert_eq!(position.side, PositionSide::Long);
3725 assert!(position.quantity.is_positive());
3726
3727 let order2 = OrderTestBuilder::new(OrderType::Market)
3728 .instrument_id(btc_usdt.id())
3729 .side(OrderSide::Sell)
3730 .quantity(Quantity::from("0.123456789"))
3731 .build();
3732 let fill2 = TestOrderEventStubs::filled(
3733 &order2,
3734 &btc_usdt,
3735 Some(TradeId::new("2")),
3736 None,
3737 Some(Price::from("50000.00")),
3738 None,
3739 None,
3740 None,
3741 None,
3742 None,
3743 );
3744 position.apply(&fill2.into());
3745
3746 assert_eq!(
3747 position.side,
3748 PositionSide::Flat,
3749 "Position should be FLAT, not {:?}",
3750 position.side
3751 );
3752 assert!(
3753 position.quantity.is_zero(),
3754 "Quantity should be zero, was {}",
3755 position.quantity
3756 );
3757 assert_eq!(
3758 position.signed_qty, 0.0,
3759 "signed_qty should be normalized to 0.0, was {}",
3760 position.signed_qty
3761 );
3762 assert!(position.is_closed());
3763 }
3764
3765 #[rstest]
3766 fn test_position_adjustment_floating_point_precision_edge_case() {
3767 let btc_usdt = currency_pair_btcusdt();
3769 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
3770
3771 let order = OrderTestBuilder::new(OrderType::Market)
3772 .instrument_id(btc_usdt.id())
3773 .side(OrderSide::Buy)
3774 .quantity(Quantity::from("1.0"))
3775 .build();
3776 let fill = TestOrderEventStubs::filled(
3777 &order,
3778 &btc_usdt,
3779 Some(TradeId::new("1")),
3780 None,
3781 Some(Price::from("50000.00")),
3782 None,
3783 None,
3784 None,
3785 None,
3786 None,
3787 );
3788 let mut position = Position::new(&btc_usdt, fill.into());
3789
3790 let adjustment = PositionAdjusted::new(
3791 position.trader_id,
3792 position.strategy_id,
3793 position.instrument_id,
3794 position.id,
3795 position.account_id,
3796 PositionAdjustmentType::Commission,
3797 Some(Decimal::from_str("-1.0").unwrap()),
3798 None,
3799 None,
3800 uuid4(),
3801 UnixNanos::default(),
3802 UnixNanos::default(),
3803 );
3804 position.apply_adjustment(adjustment);
3805
3806 assert_eq!(
3807 position.side,
3808 PositionSide::Flat,
3809 "Position should be FLAT after zeroing adjustment"
3810 );
3811 assert!(
3812 position.quantity.is_zero(),
3813 "Quantity should be zero after adjustment"
3814 );
3815 assert_eq!(
3816 position.signed_qty, 0.0,
3817 "signed_qty should be normalized to 0.0"
3818 );
3819 }
3820
3821 #[rstest]
3822 fn test_position_spot_buy_partial_fills_with_base_commission() {
3823 let eth_usdt = currency_pair_ethusdt();
3826 let eth_usdt = InstrumentAny::CurrencyPair(eth_usdt);
3827
3828 let order1 = OrderTestBuilder::new(OrderType::Market)
3829 .instrument_id(eth_usdt.id())
3830 .side(OrderSide::Buy)
3831 .quantity(Quantity::from("0.00350"))
3832 .build();
3833
3834 let fill1 = TestOrderEventStubs::filled(
3835 &order1,
3836 ð_usdt,
3837 Some(TradeId::new("1")),
3838 None,
3839 Some(Price::from("2042.69")),
3840 Some(Quantity::from("0.00350")),
3841 None,
3842 Some(Money::new(0.00001, eth_usdt.base_currency().unwrap())),
3843 None,
3844 None,
3845 );
3846
3847 let mut position = Position::new(ð_usdt, fill1.into());
3848
3849 assert_eq!(position.quantity, Quantity::from("0.00349"));
3850 assert!((position.signed_qty - 0.00349).abs() < 1e-9);
3851 assert_eq!(position.side, PositionSide::Long);
3852 assert_eq!(position.adjustments.len(), 1);
3853 assert_eq!(
3854 position.adjustments[0].quantity_change,
3855 Some(rust_decimal_macros::dec!(-0.00001))
3856 );
3857
3858 let order2 = OrderTestBuilder::new(OrderType::Market)
3859 .instrument_id(eth_usdt.id())
3860 .side(OrderSide::Buy)
3861 .quantity(Quantity::from("0.00350"))
3862 .build();
3863
3864 let fill2 = TestOrderEventStubs::filled(
3865 &order2,
3866 ð_usdt,
3867 Some(TradeId::new("2")),
3868 None,
3869 Some(Price::from("2042.69")),
3870 Some(Quantity::from("0.00350")),
3871 None,
3872 Some(Money::new(0.00001, eth_usdt.base_currency().unwrap())),
3873 None,
3874 None,
3875 );
3876
3877 position.apply(&fill2.into());
3878
3879 assert_eq!(position.quantity, Quantity::from("0.00698"));
3880 assert!((position.signed_qty - 0.00698).abs() < 1e-9);
3881 assert_eq!(position.adjustments.len(), 2);
3882
3883 let order3 = OrderTestBuilder::new(OrderType::Market)
3884 .instrument_id(eth_usdt.id())
3885 .side(OrderSide::Buy)
3886 .quantity(Quantity::from("0.00300"))
3887 .build();
3888
3889 let fill3 = TestOrderEventStubs::filled(
3890 &order3,
3891 ð_usdt,
3892 Some(TradeId::new("3")),
3893 None,
3894 Some(Price::from("2042.69")),
3895 Some(Quantity::from("0.00300")),
3896 None,
3897 Some(Money::new(0.00001, eth_usdt.base_currency().unwrap())),
3898 None,
3899 None,
3900 );
3901
3902 position.apply(&fill3.into());
3903
3904 assert_eq!(position.quantity, Quantity::from("0.00997"));
3907 assert!((position.signed_qty - 0.00997).abs() < 1e-9);
3908 assert_eq!(position.side, PositionSide::Long);
3909 assert_eq!(position.adjustments.len(), 3);
3910
3911 assert_eq!(position.buy_qty, Quantity::from("0.01000"));
3913 }
3914
3915 #[rstest]
3916 fn test_position_spot_sell_partial_fills_with_base_commission() {
3917 let btc_usdt = currency_pair_btcusdt();
3918 let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
3919
3920 let order1 = OrderTestBuilder::new(OrderType::Market)
3921 .instrument_id(btc_usdt.id())
3922 .side(OrderSide::Sell)
3923 .quantity(Quantity::from("0.5"))
3924 .build();
3925
3926 let fill1 = TestOrderEventStubs::filled(
3927 &order1,
3928 &btc_usdt,
3929 Some(TradeId::new("1")),
3930 None,
3931 Some(Price::from("50000.0")),
3932 Some(Quantity::from("0.5")),
3933 None,
3934 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
3935 None,
3936 None,
3937 );
3938
3939 let mut position = Position::new(&btc_usdt, fill1.into());
3940
3941 assert!((position.signed_qty - (-0.501)).abs() < 1e-9);
3943 assert_eq!(position.side, PositionSide::Short);
3944 assert_eq!(position.adjustments.len(), 1);
3945
3946 let order2 = OrderTestBuilder::new(OrderType::Market)
3947 .instrument_id(btc_usdt.id())
3948 .side(OrderSide::Sell)
3949 .quantity(Quantity::from("0.5"))
3950 .build();
3951
3952 let fill2 = TestOrderEventStubs::filled(
3953 &order2,
3954 &btc_usdt,
3955 Some(TradeId::new("2")),
3956 None,
3957 Some(Price::from("50000.0")),
3958 Some(Quantity::from("0.5")),
3959 None,
3960 Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
3961 None,
3962 None,
3963 );
3964
3965 position.apply(&fill2.into());
3966
3967 assert!((position.signed_qty - (-1.002)).abs() < 1e-9);
3969 assert!((position.quantity.as_f64() - 1.002).abs() < 1e-9);
3970 assert_eq!(position.adjustments.len(), 2);
3971 assert_eq!(position.sell_qty, Quantity::from("1.0"));
3972 }
3973
3974 #[rstest]
3975 fn test_position_spot_round_trip_close_flat_with_quote_commission() {
3976 let eth_usdt = currency_pair_ethusdt();
3977 let eth_usdt = InstrumentAny::CurrencyPair(eth_usdt);
3978
3979 let buy_order = OrderTestBuilder::new(OrderType::Market)
3980 .instrument_id(eth_usdt.id())
3981 .side(OrderSide::Buy)
3982 .quantity(Quantity::from("1.00000"))
3983 .build();
3984
3985 let buy_fill = TestOrderEventStubs::filled(
3986 &buy_order,
3987 ð_usdt,
3988 Some(TradeId::new("1")),
3989 None,
3990 Some(Price::from("2000.00")),
3991 Some(Quantity::from("1.00000")),
3992 None,
3993 Some(Money::new(0.001, eth_usdt.base_currency().unwrap())),
3994 None,
3995 None,
3996 );
3997
3998 let mut position = Position::new(ð_usdt, buy_fill.into());
3999
4000 assert_eq!(position.quantity, Quantity::from("0.99900"));
4002 assert_eq!(position.side, PositionSide::Long);
4003
4004 let sell_order = OrderTestBuilder::new(OrderType::Market)
4005 .instrument_id(eth_usdt.id())
4006 .side(OrderSide::Sell)
4007 .quantity(Quantity::from("0.99900"))
4008 .build();
4009
4010 let sell_fill = TestOrderEventStubs::filled(
4011 &sell_order,
4012 ð_usdt,
4013 Some(TradeId::new("2")),
4014 None,
4015 Some(Price::from("2100.00")),
4016 Some(Quantity::from("0.99900")),
4017 None,
4018 Some(Money::new(2.0, Currency::USDT())),
4019 None,
4020 None,
4021 );
4022
4023 position.apply(&sell_fill.into());
4024
4025 assert_eq!(position.side, PositionSide::Flat);
4026 assert_eq!(position.signed_qty, 0.0);
4027 assert!(position.is_closed());
4028 assert_eq!(position.adjustments.len(), 1);
4030
4031 let realized = position.realized_pnl.unwrap().as_f64();
4033 assert!(
4034 (realized - 97.9).abs() < 0.01,
4035 "Realized PnL should be ~97.90 USDT, was {realized}"
4036 );
4037 }
4038
4039 #[rstest]
4040 fn test_position_spot_commission_accumulation_multiple_partial_fills() {
4041 let eth_usdt = currency_pair_ethusdt();
4042 let eth_usdt = InstrumentAny::CurrencyPair(eth_usdt);
4043
4044 let order1 = OrderTestBuilder::new(OrderType::Market)
4045 .instrument_id(eth_usdt.id())
4046 .side(OrderSide::Buy)
4047 .quantity(Quantity::from("0.50000"))
4048 .build();
4049
4050 let fill1 = TestOrderEventStubs::filled(
4051 &order1,
4052 ð_usdt,
4053 Some(TradeId::new("1")),
4054 None,
4055 Some(Price::from("2000.00")),
4056 Some(Quantity::from("0.50000")),
4057 None,
4058 Some(Money::new(0.0005, eth_usdt.base_currency().unwrap())),
4059 None,
4060 None,
4061 );
4062
4063 let mut position = Position::new(ð_usdt, fill1.into());
4064
4065 let order2 = OrderTestBuilder::new(OrderType::Market)
4066 .instrument_id(eth_usdt.id())
4067 .side(OrderSide::Buy)
4068 .quantity(Quantity::from("0.50000"))
4069 .build();
4070
4071 let fill2 = TestOrderEventStubs::filled(
4072 &order2,
4073 ð_usdt,
4074 Some(TradeId::new("2")),
4075 None,
4076 Some(Price::from("2010.00")),
4077 Some(Quantity::from("0.50000")),
4078 None,
4079 Some(Money::new(0.0005, eth_usdt.base_currency().unwrap())),
4080 None,
4081 None,
4082 );
4083
4084 position.apply(&fill2.into());
4085
4086 assert_eq!(position.quantity, Quantity::from("0.99900"));
4088 assert_eq!(position.buy_qty, Quantity::from("1.00000"));
4089
4090 assert_eq!(position.adjustments.len(), 2);
4091 for adj in &position.adjustments {
4092 assert_eq!(adj.adjustment_type, PositionAdjustmentType::Commission);
4093 assert_eq!(
4094 adj.quantity_change,
4095 Some(rust_decimal_macros::dec!(-0.0005))
4096 );
4097 }
4098
4099 let commissions = position.commissions();
4100 assert_eq!(commissions.len(), 1);
4101 let eth_commission = commissions[0];
4102 assert!(
4103 (eth_commission.as_f64() - 0.001).abs() < 1e-9,
4104 "Total ETH commission should be 0.001, was {}",
4105 eth_commission.as_f64()
4106 );
4107 }
4108
4109 #[rstest]
4110 fn test_position_apply_fill_with_earlier_timestamp_adjusts_ts_opened(audusd_sim: CurrencyPair) {
4111 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
4112 let order1 = OrderTestBuilder::new(OrderType::Market)
4113 .instrument_id(audusd_sim.id())
4114 .side(OrderSide::Buy)
4115 .quantity(Quantity::from(100_000))
4116 .build();
4117 let order2 = OrderTestBuilder::new(OrderType::Market)
4118 .instrument_id(audusd_sim.id())
4119 .side(OrderSide::Buy)
4120 .quantity(Quantity::from(100_000))
4121 .build();
4122
4123 let fill1 = TestOrderEventStubs::filled(
4125 &order1,
4126 &audusd_sim,
4127 Some(TradeId::new("t1")),
4128 None,
4129 Some(Price::from("1.00001")),
4130 None,
4131 None,
4132 None,
4133 Some(UnixNanos::from(2_000u64)),
4134 None,
4135 );
4136 let mut position = Position::new(&audusd_sim, fill1.into());
4137 assert_eq!(position.ts_opened, UnixNanos::from(2_000u64));
4138
4139 let fill2 = TestOrderEventStubs::filled(
4141 &order2,
4142 &audusd_sim,
4143 Some(TradeId::new("t2")),
4144 None,
4145 Some(Price::from("1.00002")),
4146 None,
4147 None,
4148 None,
4149 Some(UnixNanos::from(1_000u64)),
4150 None,
4151 );
4152
4153 position.apply(&fill2.into());
4155 assert_eq!(position.ts_opened, UnixNanos::from(2_000u64));
4156 assert_eq!(position.opening_order_id, order1.client_order_id());
4157 assert_eq!(position.events.len(), 2);
4158 }
4159
4160 #[rstest]
4161 fn test_position_commissions_multi_currency_insertion_order(audusd_sim: CurrencyPair) {
4162 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
4167 let order_template = OrderTestBuilder::new(OrderType::Market)
4168 .instrument_id(audusd_sim.id())
4169 .side(OrderSide::Buy)
4170 .quantity(Quantity::from(100_000))
4171 .build();
4172
4173 let fill_usd = TestOrderEventStubs::filled(
4174 &order_template,
4175 &audusd_sim,
4176 Some(TradeId::new("t1")),
4177 None,
4178 Some(Price::from("1.00001")),
4179 None,
4180 None,
4181 Some(Money::from("1.0 USD")),
4182 None,
4183 None,
4184 );
4185 let mut position = Position::new(&audusd_sim, fill_usd.into());
4186
4187 let fill_usdt = TestOrderEventStubs::filled(
4188 &order_template,
4189 &audusd_sim,
4190 Some(TradeId::new("t2")),
4191 None,
4192 Some(Price::from("1.00001")),
4193 None,
4194 None,
4195 Some(Money::from("2.0 USDT")),
4196 None,
4197 None,
4198 );
4199 position.apply(&fill_usdt.into());
4200
4201 let fill_usd_again = TestOrderEventStubs::filled(
4202 &order_template,
4203 &audusd_sim,
4204 Some(TradeId::new("t3")),
4205 None,
4206 Some(Price::from("1.00001")),
4207 None,
4208 None,
4209 Some(Money::from("0.5 USD")),
4210 None,
4211 None,
4212 );
4213 position.apply(&fill_usd_again.into());
4214
4215 let fill_btc = TestOrderEventStubs::filled(
4216 &order_template,
4217 &audusd_sim,
4218 Some(TradeId::new("t4")),
4219 None,
4220 Some(Price::from("1.00001")),
4221 None,
4222 None,
4223 Some(Money::from("0.0001 BTC")),
4224 None,
4225 None,
4226 );
4227 position.apply(&fill_btc.into());
4228
4229 assert_eq!(
4232 position.commissions(),
4233 vec![
4234 Money::from("1.5 USD"),
4235 Money::from("2.0 USDT"),
4236 Money::from("0.0001 BTC"),
4237 ]
4238 );
4239 }
4240
4241 #[rstest]
4242 fn test_fold_net_position_empty() {
4243 let (net_qty, net_px) = fold_net_position(&[]);
4244 assert_eq!(net_qty, Decimal::ZERO);
4245 assert_eq!(net_px, Decimal::ZERO);
4246 }
4247
4248 #[rstest]
4249 fn test_fold_net_position_single_long() {
4250 let legs = [(dec!(100), dec!(1.5), 1u64)];
4251 let (net_qty, net_px) = fold_net_position(&legs);
4252 assert_eq!(net_qty, dec!(100));
4253 assert_eq!(net_px, dec!(1.5));
4254 }
4255
4256 #[rstest]
4257 fn test_fold_net_position_single_short() {
4258 let legs = [(dec!(-100), dec!(1.5), 1u64)];
4259 let (net_qty, net_px) = fold_net_position(&legs);
4260 assert_eq!(net_qty, dec!(-100));
4261 assert_eq!(net_px, dec!(1.5));
4262 }
4263
4264 #[rstest]
4265 fn test_fold_net_position_same_side_weighted_average() {
4266 let legs = [(dec!(100), dec!(1.0), 1u64), (dec!(200), dec!(0.5), 2u64)];
4268 let (net_qty, net_px) = fold_net_position(&legs);
4269 assert_eq!(net_qty, dec!(300));
4270 assert_eq!(net_px, dec!(200) / dec!(300));
4272 }
4273
4274 #[rstest]
4275 fn test_fold_net_position_partial_close_preserves_avg() {
4276 let legs = [
4278 (dec!(300), dec!(0.80), 1u64),
4279 (dec!(-100), dec!(1.00), 2u64),
4280 ];
4281 let (net_qty, net_px) = fold_net_position(&legs);
4282 assert_eq!(net_qty, dec!(200));
4283 assert_eq!(net_px, dec!(0.80));
4284 }
4285
4286 #[rstest]
4287 fn test_fold_net_position_full_close() {
4288 let legs = [(dec!(100), dec!(1.0), 1u64), (dec!(-100), dec!(2.0), 2u64)];
4289 let (net_qty, net_px) = fold_net_position(&legs);
4290 assert_eq!(net_qty, Decimal::ZERO);
4291 assert_eq!(net_px, Decimal::ZERO);
4292 }
4293
4294 #[rstest]
4295 fn test_fold_net_position_single_flip_uses_flipping_price() {
4296 let legs = [
4298 (dec!(100), dec!(1.00), 1u64),
4299 (dec!(-50), dec!(2.00), 2u64),
4300 (dec!(-100), dec!(3.00), 3u64),
4301 ];
4302 let (net_qty, net_px) = fold_net_position(&legs);
4303 assert_eq!(net_qty, dec!(-50));
4304 assert_eq!(net_px, dec!(3.00));
4305 }
4306
4307 #[rstest]
4308 fn test_fold_net_position_double_flip() {
4309 let legs = [
4311 (dec!(50), dec!(1.00), 1u64),
4312 (dec!(-100), dec!(2.00), 2u64),
4313 (dec!(100), dec!(3.00), 3u64),
4314 ];
4315 let (net_qty, net_px) = fold_net_position(&legs);
4316 assert_eq!(net_qty, dec!(50));
4317 assert_eq!(net_px, dec!(3.00));
4318 }
4319
4320 #[rstest]
4321 fn test_fold_net_position_zero_quantity_legs_skipped() {
4322 let legs = [
4324 (dec!(100), dec!(1.0), 1u64),
4325 (Decimal::ZERO, dec!(99.0), 2u64),
4326 (dec!(50), dec!(2.0), 3u64),
4327 ];
4328 let (net_qty, net_px) = fold_net_position(&legs);
4329 assert_eq!(net_qty, dec!(150));
4330 assert_eq!(net_px, dec!(200) / dec!(150));
4332 }
4333
4334 #[rstest]
4335 fn test_fold_net_position_stable_sort_preserves_input_order_for_equal_ts() {
4336 let leg_a = (dec!(100), dec!(1.00), 1u64);
4338 let leg_b = (dec!(-100), dec!(2.00), 1u64);
4339
4340 let ab = [leg_a, leg_b];
4341 let ba = [leg_b, leg_a];
4342
4343 assert_eq!(fold_net_position(&ab), (Decimal::ZERO, Decimal::ZERO));
4345 assert_eq!(fold_net_position(&ba), (Decimal::ZERO, Decimal::ZERO));
4347
4348 let leg_c = (dec!(150), dec!(1.00), 1u64);
4350 let leg_d = (dec!(-100), dec!(2.00), 1u64);
4351 let cd = [leg_c, leg_d];
4352 let dc = [leg_d, leg_c];
4353 assert_eq!(fold_net_position(&cd), (dec!(50), dec!(1.00)));
4355 assert_eq!(fold_net_position(&dc), (dec!(50), dec!(1.00)));
4357 }
4358
4359 #[rstest]
4360 fn test_fold_net_position_close_then_reopen() {
4361 let legs = [
4363 (dec!(100), dec!(1.00), 1u64),
4364 (dec!(-100), dec!(1.50), 2u64),
4365 (dec!(50), dec!(3.00), 3u64),
4366 ];
4367 let (net_qty, net_px) = fold_net_position(&legs);
4368 assert_eq!(net_qty, dec!(50));
4369 assert_eq!(net_px, dec!(3.00));
4370 }
4371
4372 #[rstest]
4373 fn test_fold_net_position_orders_by_ts_opened() {
4374 let in_order = [
4376 (dec!(100), dec!(1.00), 1u64),
4377 (dec!(-50), dec!(2.00), 2u64),
4378 (dec!(-100), dec!(3.00), 3u64),
4379 ];
4380 let shuffled = [
4381 (dec!(-100), dec!(3.00), 3u64),
4382 (dec!(100), dec!(1.00), 1u64),
4383 (dec!(-50), dec!(2.00), 2u64),
4384 ];
4385 assert_eq!(fold_net_position(&in_order), fold_net_position(&shuffled));
4386 }
4387
4388 fn netting_reference(
4391 instrument: &InstrumentAny,
4392 fills: &[(OrderSide, u32, u32, u64)],
4393 ) -> (Decimal, Decimal) {
4394 let mut sorted_fills = fills.to_vec();
4395 sorted_fills.sort_by_key(|(_, _, _, ts)| *ts);
4396
4397 let mut position: Option<Position> = None;
4398
4399 for (idx, &(side, qty, px, ts)) in sorted_fills.iter().enumerate() {
4400 let order = OrderTestBuilder::new(OrderType::Market)
4401 .instrument_id(instrument.id())
4402 .side(side)
4403 .quantity(Quantity::from(qty))
4404 .build();
4405 let fill = TestOrderEventStubs::filled(
4406 &order,
4407 instrument,
4408 Some(TradeId::new(format!("T{idx}").as_str())),
4409 Some(PositionId::new("P-NET")),
4410 Some(Price::from(px.to_string().as_str())),
4411 None,
4412 None,
4413 Some(Money::new(0.0, instrument.quote_currency())),
4414 Some(UnixNanos::from(ts)),
4415 None,
4416 );
4417 let event: OrderFilled = fill.into();
4418 if let Some(p) = position.as_mut() {
4419 p.apply(&event);
4420 } else {
4421 position = Some(Position::new(instrument, event));
4422 }
4423 }
4424 let p = position.expect("at least one fill");
4425 let signed = Decimal::try_from(p.signed_qty).unwrap_or(Decimal::ZERO);
4426 let px = Decimal::try_from(p.avg_px_open).unwrap_or(Decimal::ZERO);
4427 (signed, px)
4428 }
4429
4430 fn hedging_legs(fills: &[(OrderSide, u32, u32, u64)]) -> Vec<(Decimal, Decimal, u64)> {
4432 fills
4433 .iter()
4434 .map(|&(side, qty, px, ts)| {
4435 let signed = if side == OrderSide::Buy {
4436 Decimal::from(qty)
4437 } else {
4438 -Decimal::from(qty)
4439 };
4440 (signed, Decimal::from(px), ts)
4441 })
4442 .collect()
4443 }
4444
4445 proptest! {
4446 #[rstest]
4452 fn prop_fold_matches_netting_replay(
4453 fills in proptest::collection::vec(
4454 (
4455 prop_oneof![Just(OrderSide::Buy), Just(OrderSide::Sell)],
4456 1u32..1_000u32,
4457 1u32..100u32,
4458 0u64..1_000_000u64,
4459 ),
4460 1..6,
4461 )
4462 ) {
4463 let mut seen_ts: AHashSet<u64> = AHashSet::new();
4466 for &(_, _, _, ts) in &fills {
4467 if !seen_ts.insert(ts) {
4468 prop_assume!(false);
4469 }
4470 }
4471
4472 let mut sorted_fills = fills.clone();
4476 sorted_fills.sort_by_key(|(_, _, _, ts)| *ts);
4477 let mut running: i64 = 0;
4478 let mut zero_mid = false;
4479
4480 for (idx, &(side, qty, _, _)) in sorted_fills.iter().enumerate() {
4481 let qty_i64 = i64::from(qty);
4482 let signed: i64 = if side == OrderSide::Buy {
4483 qty_i64
4484 } else {
4485 -qty_i64
4486 };
4487 running += signed;
4488 if idx + 1 < sorted_fills.len() && running == 0 {
4489 zero_mid = true;
4490 break;
4491 }
4492 }
4493 prop_assume!(!zero_mid);
4494
4495 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4496 let (ref_qty, ref_px) = netting_reference(&instrument, &fills);
4497 let legs = hedging_legs(&fills);
4498 let (fold_qty, fold_px) = fold_net_position(&legs);
4499
4500 prop_assert_eq!(fold_qty, ref_qty);
4501
4502 if !ref_qty.is_zero() {
4506 let fold_px_f64 = fold_px.to_f64().unwrap_or(0.0);
4507 let ref_px_f64 = ref_px.to_f64().unwrap_or(0.0);
4508 let max_mag = fold_px_f64.abs().max(ref_px_f64.abs()).max(1.0);
4509 prop_assert!(
4510 (fold_px_f64 - ref_px_f64).abs() < 1e-9 * max_mag,
4511 "fold_px {fold_px_f64} vs ref_px {ref_px_f64}",
4512 );
4513 }
4514 }
4515 }
4516}