use std::{
fmt::Display,
hash::{Hash, Hasher},
};
use ahash::{AHashMap, AHashSet};
use indexmap::IndexMap;
use nautilus_core::{
UUID4, UnixNanos,
correctness::{FAILED, check_equal, check_predicate_true},
};
use rust_decimal::{Decimal, prelude::ToPrimitive};
use serde::{Deserialize, Serialize};
use crate::{
enums::{InstrumentClass, OrderSide, OrderSideSpecified, PositionAdjustmentType, PositionSide},
events::{OrderFillVoided, OrderFilled, PositionAdjusted},
identifiers::{
AccountId, ClientOrderId, InstrumentId, PositionId, StrategyId, Symbol, TradeId, TraderId,
Venue, VenueOrderId,
},
instruments::{Instrument, InstrumentAny},
types::{Currency, Money, Price, Quantity},
};
#[repr(C)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(
feature = "python",
pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
)]
#[cfg_attr(
feature = "python",
pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
)]
pub struct Position {
pub events: Vec<OrderFilled>,
pub adjustments: Vec<PositionAdjusted>,
#[serde(default)]
pub replay_events: Vec<PositionReplayEvent>,
#[serde(default)]
pub fill_voids: Vec<PositionFillVoid>,
pub trader_id: TraderId,
pub strategy_id: StrategyId,
pub instrument_id: InstrumentId,
pub id: PositionId,
pub account_id: AccountId,
pub opening_order_id: ClientOrderId,
pub closing_order_id: Option<ClientOrderId>,
pub entry: OrderSide,
pub side: PositionSide,
pub signed_qty: f64,
pub quantity: Quantity,
pub peak_qty: Quantity,
pub price_precision: u8,
pub size_precision: u8,
pub multiplier: Quantity,
pub is_inverse: bool,
pub is_currency_pair: bool,
pub instrument_class: InstrumentClass,
pub base_currency: Option<Currency>,
pub quote_currency: Currency,
pub settlement_currency: Currency,
pub ts_init: UnixNanos,
pub ts_opened: UnixNanos,
pub ts_last: UnixNanos,
pub ts_closed: Option<UnixNanos>,
pub duration_ns: u64,
pub avg_px_open: f64,
pub avg_px_close: Option<f64>,
pub realized_return: f64,
pub realized_pnl: Option<Money>,
#[serde(with = "nautilus_core::serialization::sorted_hashset")]
pub trade_ids: AHashSet<TradeId>,
pub buy_qty: Quantity,
pub sell_qty: Quantity,
pub commissions: IndexMap<Currency, Money>,
}
#[expect(clippy::large_enum_variant)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PositionReplayEvent {
Filled(OrderFilled),
Adjusted(PositionAdjusted),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PositionFillVoid {
pub event: OrderFillVoided,
pub voided_qty: Quantity,
pub commission_voided: Option<Money>,
}
impl Position {
#[must_use]
#[allow(
clippy::needless_pass_by_value,
reason = "constructor takes the opening fill by value as the position's seed event"
)]
pub fn new(instrument: &InstrumentAny, fill: OrderFilled) -> Self {
check_equal(
&instrument.id(),
&fill.instrument_id,
"instrument.id()",
"fill.instrument_id",
)
.expect(FAILED);
assert_ne!(fill.order_side, OrderSide::NoOrderSide);
let position_id = fill.position_id.expect("No position ID to open `Position`");
let mut item = Self {
events: Vec::<OrderFilled>::new(),
adjustments: Vec::<PositionAdjusted>::new(),
replay_events: Vec::new(),
fill_voids: Vec::new(),
trade_ids: AHashSet::<TradeId>::new(),
buy_qty: Quantity::zero(instrument.size_precision()),
sell_qty: Quantity::zero(instrument.size_precision()),
commissions: IndexMap::<Currency, Money>::new(),
trader_id: fill.trader_id,
strategy_id: fill.strategy_id,
instrument_id: fill.instrument_id,
id: position_id,
account_id: fill.account_id,
opening_order_id: fill.client_order_id,
closing_order_id: None,
entry: fill.order_side,
side: PositionSide::Flat,
signed_qty: 0.0,
quantity: fill.last_qty,
peak_qty: fill.last_qty,
price_precision: instrument.price_precision(),
size_precision: instrument.size_precision(),
multiplier: instrument.multiplier(),
is_inverse: instrument.is_inverse(),
is_currency_pair: matches!(instrument, InstrumentAny::CurrencyPair(_)),
instrument_class: instrument.instrument_class(),
base_currency: instrument.base_currency(),
quote_currency: instrument.quote_currency(),
settlement_currency: instrument.cost_currency(),
ts_init: fill.ts_init,
ts_opened: fill.ts_event,
ts_last: fill.ts_event,
ts_closed: None,
duration_ns: 0,
avg_px_open: fill.last_px.as_f64(),
avg_px_close: None,
realized_return: 0.0,
realized_pnl: None,
};
item.apply(&fill);
item
}
pub fn purge_events_for_order(&mut self, client_order_id: ClientOrderId) {
self.replay_events.retain(|event| {
!matches!(event, PositionReplayEvent::Filled(fill) if fill.client_order_id == client_order_id)
});
self.fill_voids
.retain(|record| record.event.client_order_id != client_order_id);
let filtered_events: Vec<OrderFilled> = self
.events
.iter()
.filter(|e| e.client_order_id != client_order_id)
.cloned()
.collect();
let preserved_adjustments: Vec<PositionAdjusted> = self
.adjustments
.iter()
.filter(|adj| {
adj.adjustment_type != PositionAdjustmentType::Commission
})
.copied()
.collect();
if filtered_events.is_empty() {
log::warn!(
"Position {} has no fills remaining after purging order {}; consider closing the position instead",
self.id,
client_order_id
);
self.events.clear();
self.trade_ids.clear();
self.adjustments.clear();
self.buy_qty = Quantity::zero(self.size_precision);
self.sell_qty = Quantity::zero(self.size_precision);
self.commissions.clear();
self.signed_qty = 0.0;
self.quantity = Quantity::zero(self.size_precision);
self.side = PositionSide::Flat;
self.avg_px_close = None;
self.realized_pnl = None;
self.realized_return = 0.0;
self.ts_opened = UnixNanos::default();
self.ts_last = UnixNanos::default();
self.ts_closed = Some(UnixNanos::default());
self.duration_ns = 0;
return;
}
let position_id = self.id;
let size_precision = self.size_precision;
self.events = Vec::new();
self.trade_ids = AHashSet::new();
self.adjustments = Vec::new();
self.buy_qty = Quantity::zero(size_precision);
self.sell_qty = Quantity::zero(size_precision);
self.commissions.clear();
self.signed_qty = 0.0;
self.quantity = Quantity::zero(size_precision);
self.peak_qty = Quantity::zero(size_precision);
self.side = PositionSide::Flat;
self.avg_px_open = 0.0;
self.avg_px_close = None;
self.realized_pnl = None;
self.realized_return = 0.0;
let first_event = &filtered_events[0];
self.entry = first_event.order_side;
self.opening_order_id = first_event.client_order_id;
self.ts_opened = first_event.ts_event;
self.ts_init = first_event.ts_init;
self.closing_order_id = None;
self.ts_closed = None;
self.duration_ns = 0;
for event in filtered_events {
self.apply_fill(&event, false);
}
for adjustment in preserved_adjustments {
self.apply_adjustment_state(adjustment, false);
}
log::info!(
"Purged fills for order {} from position {}; recalculated state: qty={}, signed_qty={}, side={:?}",
client_order_id,
position_id,
self.quantity,
self.signed_qty,
self.side
);
}
pub fn apply(&mut self, fill: &OrderFilled) {
self.apply_fill(fill, true);
}
fn apply_fill(&mut self, fill: &OrderFilled, record_replay: bool) {
if record_replay
&& (self.side == PositionSide::Flat || !self.trade_ids.contains(&fill.trade_id))
&& self.is_duplicate_replay_fill(fill)
{
log::warn!(
"Ignoring historical duplicate fill {} for position {}; durable replay already contains this trade",
fill.trade_id,
self.id,
);
return;
}
if fill.ts_event < self.ts_opened {
log::warn!(
"Fill ts_event {} for {} is before position ts_opened {}",
fill.ts_event,
self.id,
self.ts_opened,
);
}
if self.side == PositionSide::Flat {
self.events.clear();
self.trade_ids.clear();
self.adjustments.clear();
self.buy_qty = Quantity::zero(self.size_precision);
self.sell_qty = Quantity::zero(self.size_precision);
self.commissions.clear();
self.opening_order_id = fill.client_order_id;
self.closing_order_id = None;
self.peak_qty = Quantity::zero(self.size_precision);
self.ts_init = fill.ts_init;
self.ts_opened = fill.ts_event;
self.ts_closed = None;
self.duration_ns = 0;
self.avg_px_open = fill.last_px.as_f64();
self.avg_px_close = None;
self.realized_return = 0.0;
self.realized_pnl = None;
}
if record_replay {
check_predicate_true(
!self.trade_ids.contains(&fill.trade_id),
"`fill.trade_id` already contained in `trade_ids",
)
.expect(FAILED);
self.replay_events
.push(PositionReplayEvent::Filled(fill.clone()));
}
self.events.push(fill.clone());
self.trade_ids.insert(fill.trade_id);
if let Some(commission) = fill.commission {
let commission_currency = commission.currency;
if let Some(existing_commission) = self.commissions.get_mut(&commission_currency) {
*existing_commission = *existing_commission + commission;
} else {
self.commissions.insert(commission_currency, commission);
}
}
match fill.specified_side() {
OrderSideSpecified::Buy => {
self.handle_buy_order_fill(fill);
}
OrderSideSpecified::Sell => {
self.handle_sell_order_fill(fill);
}
}
if self.is_currency_pair
&& let Some(commission) = fill.commission
&& let Some(base_currency) = self.base_currency
&& commission.currency == base_currency
{
let mut adjustment_id = fill.event_id.as_bytes();
adjustment_id[15] ^= 0x01;
let adjustment = PositionAdjusted::new(
self.trader_id,
self.strategy_id,
self.instrument_id,
self.id,
self.account_id,
PositionAdjustmentType::Commission,
Some(-commission.as_decimal()),
None,
Some(fill.client_order_id.inner()),
UUID4::from_bytes(adjustment_id),
fill.ts_event,
fill.ts_init,
);
self.apply_adjustment_state(adjustment, false);
}
self.quantity = Quantity::new(self.signed_qty.abs(), self.size_precision);
if self.quantity > self.peak_qty {
self.peak_qty = self.quantity;
}
if self.quantity.is_zero() {
self.side = PositionSide::Flat;
self.signed_qty = 0.0; self.closing_order_id = Some(fill.client_order_id);
self.ts_closed = Some(fill.ts_event);
self.duration_ns = if let Some(ts_closed) = self.ts_closed {
ts_closed.as_u64().saturating_sub(self.ts_opened.as_u64())
} else {
0
};
} else if self.signed_qty > 0.0 {
self.entry = OrderSide::Buy;
self.side = PositionSide::Long;
} else {
self.entry = OrderSide::Sell;
self.side = PositionSide::Short;
}
self.ts_last = fill.ts_event;
debug_assert!(
match self.side {
PositionSide::Long => self.signed_qty > 0.0,
PositionSide::Short => self.signed_qty < 0.0,
PositionSide::Flat => self.signed_qty == 0.0,
PositionSide::NoPositionSide => false,
},
"Invariant: position side must match signed_qty sign (side={:?}, signed_qty={})",
self.side,
self.signed_qty,
);
debug_assert!(
self.peak_qty >= self.quantity,
"Invariant: peak_qty must not be less than current quantity (peak={}, quantity={})",
self.peak_qty,
self.quantity,
);
}
fn is_duplicate_replay_fill(&self, fill: &OrderFilled) -> bool {
let continues_latest_fill = fill.causation_id.is_some_and(|source_id| {
self.events.last().is_some_and(|latest| {
latest.trade_id == fill.trade_id && latest.event_id == source_id
})
});
if self.trade_ids.contains(&fill.trade_id) {
return !continues_latest_fill
|| self.replay_events.iter().any(|event| {
matches!(
event,
PositionReplayEvent::Filled(replayed)
if replayed.trade_id == fill.trade_id
&& replayed.causation_id == fill.causation_id
)
});
}
let replay_starts_current_cycle = self.replay_events.is_empty()
|| matches!(
(self.replay_events.first(), self.events.first()),
(
Some(PositionReplayEvent::Filled(replayed)),
Some(current),
) if replayed.event_id == current.event_id
);
let corrected_trade = self
.fill_voids
.iter()
.any(|record| record.event.trade_id == fill.trade_id);
let current_cycle_only = replay_starts_current_cycle && !corrected_trade;
if current_cycle_only {
return false;
}
self.replay_events.iter().any(|event| {
matches!(
event,
PositionReplayEvent::Filled(replayed) if replayed.trade_id == fill.trade_id
)
})
}
fn handle_buy_order_fill(&mut self, fill: &OrderFilled) {
let mut realized_pnl = if let Some(commission) = fill.commission {
if commission.currency == self.settlement_currency {
-commission.as_f64()
} else {
0.0
}
} else {
0.0
};
let last_px = fill.last_px.as_f64();
let last_qty = fill.last_qty.as_f64();
let last_qty_object = fill.last_qty;
if self.signed_qty > 0.0 {
self.avg_px_open = self.calculate_avg_px_open_px(last_px, last_qty);
} else if self.signed_qty < 0.0 {
let avg_px_close = self.calculate_avg_px_close_px(last_px, last_qty);
self.avg_px_close = Some(avg_px_close);
self.realized_return = self
.calculate_return(self.avg_px_open, avg_px_close)
.unwrap_or_else(|e| {
log::error!("Error calculating return: {e}");
0.0
});
realized_pnl += self
.calculate_pnl_raw(self.avg_px_open, last_px, last_qty)
.unwrap_or_else(|e| {
log::error!("Error calculating PnL: {e}");
0.0
});
}
let current_pnl = self.realized_pnl.map_or(0.0, |p| p.as_f64());
self.realized_pnl = Some(Money::new(
current_pnl + realized_pnl,
self.settlement_currency,
));
let was_short = self.signed_qty < 0.0;
self.signed_qty += last_qty;
self.buy_qty = self.buy_qty + last_qty_object;
if was_short && self.signed_qty > 0.0 {
self.avg_px_open = last_px;
}
}
fn handle_sell_order_fill(&mut self, fill: &OrderFilled) {
let mut realized_pnl = if let Some(commission) = fill.commission {
if commission.currency == self.settlement_currency {
-commission.as_f64()
} else {
0.0
}
} else {
0.0
};
let last_px = fill.last_px.as_f64();
let last_qty = fill.last_qty.as_f64();
let last_qty_object = fill.last_qty;
if self.signed_qty < 0.0 {
self.avg_px_open = self.calculate_avg_px_open_px(last_px, last_qty);
} else if self.signed_qty > 0.0 {
let avg_px_close = self.calculate_avg_px_close_px(last_px, last_qty);
self.avg_px_close = Some(avg_px_close);
self.realized_return = self
.calculate_return(self.avg_px_open, avg_px_close)
.unwrap_or_else(|e| {
log::error!("Error calculating return: {e}");
0.0
});
realized_pnl += self
.calculate_pnl_raw(self.avg_px_open, last_px, last_qty)
.unwrap_or_else(|e| {
log::error!("Error calculating PnL: {e}");
0.0
});
}
let current_pnl = self.realized_pnl.map_or(0.0, |p| p.as_f64());
self.realized_pnl = Some(Money::new(
current_pnl + realized_pnl,
self.settlement_currency,
));
let was_long = self.signed_qty > 0.0;
self.signed_qty -= last_qty;
self.sell_qty = self.sell_qty + last_qty_object;
if was_long && self.signed_qty < 0.0 {
self.avg_px_open = last_px;
}
}
pub fn apply_adjustment(&mut self, adjustment: PositionAdjusted) {
self.apply_adjustment_state(adjustment, true);
}
fn apply_adjustment_state(&mut self, adjustment: PositionAdjusted, record_replay: bool) {
if record_replay {
self.replay_events
.push(PositionReplayEvent::Adjusted(adjustment));
}
if let Some(quantity_change) = adjustment.quantity_change {
self.signed_qty += quantity_change
.to_f64()
.expect("Failed to convert Decimal to f64");
self.quantity = Quantity::new(self.signed_qty.abs(), self.size_precision);
if self.quantity > self.peak_qty {
self.peak_qty = self.quantity;
}
}
if let Some(pnl_change) = adjustment.pnl_change {
self.realized_pnl = Some(match self.realized_pnl {
Some(current) => current + pnl_change,
None => pnl_change,
});
}
if self.quantity.is_zero() {
self.side = PositionSide::Flat;
self.signed_qty = 0.0; } else if self.signed_qty > 0.0 {
self.side = PositionSide::Long;
if self.entry == OrderSide::NoOrderSide {
self.entry = OrderSide::Buy;
}
} else {
self.side = PositionSide::Short;
if self.entry == OrderSide::NoOrderSide {
self.entry = OrderSide::Sell;
}
}
self.adjustments.push(adjustment);
self.ts_last = adjustment.ts_event;
debug_assert!(
match self.side {
PositionSide::Long => self.signed_qty > 0.0,
PositionSide::Short => self.signed_qty < 0.0,
PositionSide::Flat => self.signed_qty == 0.0,
PositionSide::NoPositionSide => false,
},
"Invariant: position side must match signed_qty sign (side={:?}, signed_qty={})",
self.side,
self.signed_qty,
);
debug_assert!(
self.peak_qty >= self.quantity,
"Invariant: peak_qty must not be less than current quantity (peak={}, quantity={})",
self.peak_qty,
self.quantity,
);
}
pub fn apply_fill_void(
&mut self,
event: OrderFillVoided,
voided_qty: Quantity,
commission_voided: Option<Money>,
) -> anyhow::Result<Option<Money>> {
let fragment_qty = self
.fill_fragments(event.client_order_id, event.trade_id)
.iter()
.fold(Quantity::zero(self.size_precision), |total, fill| {
total + fill.last_qty
});
anyhow::ensure!(
!voided_qty.is_zero() && voided_qty <= fragment_qty,
"position fill void exceeds known fragments for {}",
event.trade_id,
);
if let Some(previous) = self.fill_voids.iter().rev().find(|record| {
record.event.client_order_id == event.client_order_id
&& record.event.trade_id == event.trade_id
}) {
anyhow::ensure!(
voided_qty >= previous.voided_qty,
"stale position fill void for {}",
event.trade_id,
);
anyhow::ensure!(
voided_qty != previous.voided_qty
|| commission_voided != previous.commission_voided,
"duplicate position fill void for {}",
event.trade_id,
);
}
self.fill_voids.push(PositionFillVoid {
event,
voided_qty,
commission_voided,
});
Ok(self.rebuild_from_replay())
}
#[must_use]
pub fn fill_fragments(
&self,
client_order_id: ClientOrderId,
trade_id: TradeId,
) -> Vec<&OrderFilled> {
self.replay_events
.iter()
.filter_map(|event| match event {
PositionReplayEvent::Filled(fill)
if fill.client_order_id == client_order_id && fill.trade_id == trade_id =>
{
Some(fill)
}
_ => None,
})
.collect()
}
fn rebuild_from_replay(&mut self) -> Option<Money> {
let replay_events = self.replay_events.clone();
let mut quantity_removed = AHashMap::<usize, Quantity>::new();
let mut commission_removed = AHashMap::<usize, Money>::new();
for correction in self.latest_fill_voids() {
let mut remaining_qty = correction.voided_qty;
let mut remaining_commission = correction.commission_voided;
for (index, replay_event) in replay_events.iter().enumerate().rev() {
let PositionReplayEvent::Filled(fill) = replay_event else {
continue;
};
if fill.client_order_id != correction.event.client_order_id
|| fill.trade_id != correction.event.trade_id
{
continue;
}
if !remaining_qty.is_zero() {
let removed = remaining_qty.min(fill.last_qty);
quantity_removed.insert(index, removed);
remaining_qty = remaining_qty - removed;
}
if let (Some(remaining), Some(commission)) = (remaining_commission, fill.commission)
{
let removed_raw = remaining.raw.abs().min(commission.raw.abs());
let removed =
Money::from_raw(removed_raw * remaining.raw.signum(), remaining.currency);
commission_removed.insert(index, removed);
let next = remaining - removed;
remaining_commission = (!next.is_zero()).then_some(next);
}
}
}
self.reset_derived_state();
let mut closed_cycles_pnl: Option<Money> = None;
for (index, replay_event) in replay_events.iter().enumerate() {
match replay_event {
PositionReplayEvent::Filled(fill) => {
let removed = quantity_removed
.get(&index)
.copied()
.unwrap_or_else(|| Quantity::zero(fill.last_qty.precision));
let effective_qty = fill.last_qty - removed;
let effective_commission =
match (fill.commission, commission_removed.get(&index).copied()) {
(Some(commission), Some(removed)) => Some(commission - removed),
(commission, None) => commission,
(None, Some(_)) => None,
};
if effective_qty.is_zero() {
if let Some(commission) =
effective_commission.filter(|commission| !commission.is_zero())
{
self.apply_surviving_fill_commission(fill, commission);
}
continue;
}
if self.side == PositionSide::Flat
&& let Some(realized_pnl) = self.realized_pnl
{
closed_cycles_pnl = Some(
closed_cycles_pnl.map_or(realized_pnl, |total| total + realized_pnl),
);
}
let mut effective = fill.clone();
effective.last_qty = effective_qty;
effective.commission = effective_commission;
self.apply_fill(&effective, false);
}
PositionReplayEvent::Adjusted(adjustment) => {
self.apply_adjustment_state(*adjustment, false);
}
}
}
closed_cycles_pnl
}
fn apply_surviving_fill_commission(&mut self, fill: &OrderFilled, commission: Money) {
self.commissions
.entry(commission.currency)
.and_modify(|total| *total = *total + commission)
.or_insert(commission);
if commission.currency == self.settlement_currency {
let pnl_change = Money::zero(self.settlement_currency) - commission;
self.realized_pnl = Some(match self.realized_pnl {
Some(current) => current + pnl_change,
None => pnl_change,
});
}
if self.is_currency_pair && self.base_currency == Some(commission.currency) {
let mut adjustment_id = fill.event_id.as_bytes();
adjustment_id[15] ^= 0x01;
self.apply_adjustment_state(
PositionAdjusted::new(
self.trader_id,
self.strategy_id,
self.instrument_id,
self.id,
self.account_id,
PositionAdjustmentType::Commission,
Some(-commission.as_decimal()),
None,
Some(fill.client_order_id.inner()),
UUID4::from_bytes(adjustment_id),
fill.ts_event,
fill.ts_init,
),
false,
);
} else {
self.ts_last = fill.ts_event;
}
}
fn latest_fill_voids(&self) -> Vec<&PositionFillVoid> {
let mut latest = IndexMap::<(ClientOrderId, TradeId), &PositionFillVoid>::new();
for correction in &self.fill_voids {
latest.insert(
(correction.event.client_order_id, correction.event.trade_id),
correction,
);
}
latest.into_values().collect()
}
fn reset_derived_state(&mut self) {
self.events.clear();
self.adjustments.clear();
self.trade_ids.clear();
self.buy_qty = Quantity::zero(self.size_precision);
self.sell_qty = Quantity::zero(self.size_precision);
self.commissions.clear();
self.signed_qty = 0.0;
self.quantity = Quantity::zero(self.size_precision);
self.peak_qty = Quantity::zero(self.size_precision);
self.side = PositionSide::Flat;
self.closing_order_id = None;
self.ts_opened = UnixNanos::default();
self.ts_last = UnixNanos::default();
self.ts_closed = Some(UnixNanos::default());
self.duration_ns = 0;
self.avg_px_open = 0.0;
self.avg_px_close = None;
self.realized_pnl = None;
self.realized_return = 0.0;
}
fn calculate_avg_px(
&self,
qty: f64,
avg_pg: f64,
last_px: f64,
last_qty: f64,
) -> anyhow::Result<f64> {
debug_assert!(
qty >= 0.0 && last_qty >= 0.0,
"Invariant: average price calc requires non-negative quantities \
(qty={qty}, last_qty={last_qty})"
);
if qty == 0.0 && last_qty == 0.0 {
anyhow::bail!("Cannot calculate average price: both quantities are zero");
}
if last_qty == 0.0 {
anyhow::bail!("Cannot calculate average price: fill quantity is zero");
}
if qty == 0.0 {
return Ok(last_px);
}
let start_cost = avg_pg * qty;
let event_cost = last_px * last_qty;
let total_qty = qty + last_qty;
if total_qty <= 0.0 {
anyhow::bail!(
"Total quantity unexpectedly zero or negative in average price calculation: qty={qty}, last_qty={last_qty}, total_qty={total_qty}"
);
}
Ok((start_cost + event_cost) / total_qty)
}
fn calculate_avg_px_open_px(&self, last_px: f64, last_qty: f64) -> f64 {
self.calculate_avg_px(self.quantity.as_f64(), self.avg_px_open, last_px, last_qty)
.unwrap_or_else(|e| {
log::error!("Error calculating average open price: {e}");
last_px
})
}
fn calculate_avg_px_close_px(&self, last_px: f64, last_qty: f64) -> f64 {
let Some(avg_px_close) = self.avg_px_close else {
return last_px;
};
let closing_qty = if self.side == PositionSide::Long {
self.sell_qty
} else {
self.buy_qty
};
self.calculate_avg_px(closing_qty.as_f64(), avg_px_close, last_px, last_qty)
.unwrap_or_else(|e| {
log::error!("Error calculating average close price: {e}");
last_px
})
}
fn calculate_points(&self, avg_px_open: f64, avg_px_close: f64) -> f64 {
match self.side {
PositionSide::Long => avg_px_close - avg_px_open,
PositionSide::Short => avg_px_open - avg_px_close,
_ => 0.0, }
}
fn calculate_points_inverse(&self, avg_px_open: f64, avg_px_close: f64) -> anyhow::Result<f64> {
const EPSILON: f64 = 1e-15;
if avg_px_open <= 0.0 || avg_px_open.abs() < EPSILON {
anyhow::bail!(
"Cannot calculate inverse points: open price is not positive or is too small ({avg_px_open})"
);
}
if avg_px_close <= 0.0 || avg_px_close.abs() < EPSILON {
anyhow::bail!(
"Cannot calculate inverse points: close price is not positive or is too small ({avg_px_close})"
);
}
let inverse_open = 1.0 / avg_px_open;
let inverse_close = 1.0 / avg_px_close;
let result = match self.side {
PositionSide::Long => inverse_open - inverse_close,
PositionSide::Short => inverse_close - inverse_open,
_ => 0.0, };
Ok(result)
}
fn calculate_return(&self, avg_px_open: f64, avg_px_close: f64) -> anyhow::Result<f64> {
if avg_px_open == 0.0 {
anyhow::bail!(
"Cannot calculate return: open price is zero (close price: {avg_px_close})"
);
}
Ok(self.calculate_points(avg_px_open, avg_px_close) / avg_px_open)
}
fn calculate_pnl_raw(
&self,
avg_px_open: f64,
avg_px_close: f64,
quantity: f64,
) -> anyhow::Result<f64> {
let quantity = quantity.min(self.signed_qty.abs());
let result = if self.is_inverse {
anyhow::ensure!(
self.base_currency.is_some(),
"inverse position {} has no base currency",
self.instrument_id
);
let points = self.calculate_points_inverse(avg_px_open, avg_px_close)?;
quantity * self.multiplier.as_f64() * points
} else {
quantity * self.multiplier.as_f64() * self.calculate_points(avg_px_open, avg_px_close)
};
Ok(result)
}
pub fn try_calculate_pnl(
&self,
avg_px_open: f64,
avg_px_close: f64,
quantity: Quantity,
) -> anyhow::Result<Money> {
let pnl_raw = self.calculate_pnl_raw(avg_px_open, avg_px_close, quantity.as_f64())?;
Money::new_checked(pnl_raw, self.settlement_currency).map_err(Into::into)
}
#[must_use]
pub fn calculate_pnl(&self, avg_px_open: f64, avg_px_close: f64, quantity: Quantity) -> Money {
self.try_calculate_pnl(avg_px_open, avg_px_close, quantity)
.unwrap_or_else(|e| {
log::error!("Error calculating PnL: {e}");
Money::zero(self.settlement_currency)
})
}
pub fn try_total_pnl(&self, last: Price) -> anyhow::Result<Money> {
let unrealized = self.try_unrealized_pnl(last)?;
match self.realized_pnl {
Some(realized) => {
anyhow::ensure!(
realized.currency == unrealized.currency,
"realized and unrealized PnL currencies differ"
);
realized
.checked_add(unrealized)
.ok_or_else(|| anyhow::anyhow!("total PnL overflow"))
}
None => Ok(unrealized),
}
}
#[must_use]
pub fn total_pnl(&self, last: Price) -> Money {
self.try_total_pnl(last).unwrap_or_else(|e| {
log::error!("Error calculating total PnL: {e}");
Money::zero(self.settlement_currency)
})
}
pub fn try_unrealized_pnl(&self, last: Price) -> anyhow::Result<Money> {
if self.side == PositionSide::Flat {
Ok(Money::zero(self.settlement_currency))
} else {
let pnl =
self.calculate_pnl_raw(self.avg_px_open, last.as_f64(), self.quantity.as_f64())?;
Money::new_checked(pnl, self.settlement_currency).map_err(Into::into)
}
}
#[must_use]
pub fn unrealized_pnl(&self, last: Price) -> Money {
self.try_unrealized_pnl(last).unwrap_or_else(|e| {
log::error!("Error calculating unrealized PnL: {e}");
Money::zero(self.settlement_currency)
})
}
#[must_use]
pub fn closing_order_side(&self) -> OrderSide {
match self.side {
PositionSide::Long => OrderSide::Sell,
PositionSide::Short => OrderSide::Buy,
_ => OrderSide::NoOrderSide,
}
}
#[must_use]
pub fn is_opposite_side(&self, side: OrderSide) -> bool {
self.entry != side
}
#[must_use]
pub fn symbol(&self) -> Symbol {
self.instrument_id.symbol
}
#[must_use]
pub fn venue(&self) -> Venue {
self.instrument_id.venue
}
#[must_use]
pub fn event_count(&self) -> usize {
self.events.len()
}
#[must_use]
pub fn client_order_ids(&self) -> Vec<ClientOrderId> {
let mut result = self
.events
.iter()
.map(|event| event.client_order_id)
.collect::<AHashSet<ClientOrderId>>()
.into_iter()
.collect::<Vec<ClientOrderId>>();
result.sort_unstable();
result
}
#[must_use]
pub fn venue_order_ids(&self) -> Vec<VenueOrderId> {
let mut result = self
.events
.iter()
.map(|event| event.venue_order_id)
.collect::<AHashSet<VenueOrderId>>()
.into_iter()
.collect::<Vec<VenueOrderId>>();
result.sort_unstable();
result
}
#[must_use]
pub fn trade_ids(&self) -> Vec<TradeId> {
let mut result = self
.events
.iter()
.map(|event| event.trade_id)
.collect::<AHashSet<TradeId>>()
.into_iter()
.collect::<Vec<TradeId>>();
result.sort_unstable();
result
}
pub fn try_notional_value(&self, last: Price) -> anyhow::Result<Money> {
let currency = if self.is_inverse {
self.base_currency.ok_or_else(|| {
anyhow::anyhow!(
"inverse position {} has no base currency",
self.instrument_id
)
})?
} else {
self.settlement_currency
};
crate::instruments::try_notional_value(
self.quantity,
last,
self.multiplier,
self.is_inverse,
false,
currency,
)
}
#[must_use]
pub fn notional_value(&self, last: Price) -> Money {
self.try_notional_value(last)
.expect("invalid notional value")
}
#[must_use]
pub fn last_event(&self) -> Option<OrderFilled> {
self.events.last().cloned()
}
#[must_use]
pub fn last_trade_id(&self) -> Option<TradeId> {
self.events.last().map(|e| e.trade_id)
}
#[must_use]
pub fn is_long(&self) -> bool {
self.side == PositionSide::Long
}
#[must_use]
pub fn is_short(&self) -> bool {
self.side == PositionSide::Short
}
#[must_use]
pub fn is_open(&self) -> bool {
self.side != PositionSide::Flat && self.ts_closed.is_none()
}
#[must_use]
pub fn is_closed(&self) -> bool {
self.side == PositionSide::Flat && self.ts_closed.is_some()
}
#[must_use]
pub fn signed_decimal_qty(&self) -> Decimal {
Decimal::try_from(self.signed_qty).unwrap_or(Decimal::ZERO)
}
#[must_use]
pub fn commissions(&self) -> Vec<Money> {
self.commissions.values().copied().collect()
}
}
impl PartialEq<Self> for Position {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Eq for Position {}
impl Hash for Position {
fn hash<H: Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
}
impl Display for Position {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let quantity_str = if self.quantity == Quantity::zero(self.size_precision) {
String::new()
} else {
self.quantity.to_formatted_string() + " "
};
write!(
f,
"Position({} {}{}, id={})",
self.side, quantity_str, self.instrument_id, self.id
)
}
}
#[must_use]
pub fn fold_net_position(legs: &[(Decimal, Decimal, u64)]) -> (Decimal, Decimal) {
let mut sorted: Vec<&(Decimal, Decimal, u64)> =
legs.iter().filter(|(qty, _, _)| !qty.is_zero()).collect();
sorted.sort_by_key(|(_, _, ts_opened)| *ts_opened);
let mut net_signed_qty = Decimal::ZERO;
let mut net_avg_px = Decimal::ZERO;
for (p_qty, p_px, _) in sorted {
let p_qty = *p_qty;
let p_px = *p_px;
if net_signed_qty.is_zero() {
net_signed_qty = p_qty;
net_avg_px = p_px;
continue;
}
let same_side = net_signed_qty.is_sign_negative() == p_qty.is_sign_negative();
let new_net = net_signed_qty + p_qty;
if same_side {
let total_abs = net_signed_qty.abs() + p_qty.abs();
net_avg_px = (net_signed_qty.abs() * net_avg_px + p_qty.abs() * p_px) / total_abs;
net_signed_qty = new_net;
} else if new_net.is_zero()
|| new_net.is_sign_negative() == net_signed_qty.is_sign_negative()
{
net_signed_qty = new_net;
if new_net.is_zero() {
net_avg_px = Decimal::ZERO;
}
} else {
net_signed_qty = new_net;
net_avg_px = p_px;
}
}
(net_signed_qty, net_avg_px)
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use ahash::AHashSet;
use nautilus_core::UnixNanos;
use proptest::prelude::*;
use rstest::rstest;
use rust_decimal::{Decimal, prelude::ToPrimitive};
use rust_decimal_macros::dec;
use crate::{
enums::{OrderSide, OrderType, PositionAdjustmentType, PositionSide},
events::{
OrderEventAny, OrderFilled, PositionAdjusted,
order::spec::{OrderFillVoidedSpec, OrderFilledSpec},
},
identifiers::{
AccountId, ClientOrderId, PositionId, StrategyId, TradeId, VenueOrderId, stubs::uuid4,
},
instruments::{
CryptoFuture, CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny, stubs::*,
},
orders::{Order, builder::OrderTestBuilder, stubs::TestOrderEventStubs},
position::{Position, fold_net_position},
stubs::*,
types::{Currency, Money, Price, Quantity},
};
#[rstest]
fn test_position_long_display(stub_position_long: Position) {
let display = format!("{stub_position_long}");
assert_eq!(display, "Position(LONG 1 AUD/USD.SIM, id=1)");
}
#[rstest]
fn test_position_short_display(stub_position_short: Position) {
let display = format!("{stub_position_short}");
assert_eq!(display, "Position(SHORT 1 AUD/USD.SIM, id=1)");
}
#[rstest]
#[should_panic(expected = "`fill.trade_id` already contained in `trade_ids")]
fn test_two_trades_with_same_trade_id_error(audusd_sim: CurrencyPair) {
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let order1 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(100_000))
.build();
let order2 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(100_000))
.build();
let fill1 = TestOrderEventStubs::filled(
&order1,
&audusd_sim,
Some(TradeId::new("1")),
None,
Some(Price::from("1.00001")),
None,
None,
None,
None,
None,
);
let fill2 = TestOrderEventStubs::filled(
&order2,
&audusd_sim,
Some(TradeId::new("1")),
None,
Some(Price::from("1.00002")),
None,
None,
None,
None,
None,
);
let mut position = Position::new(&audusd_sim, fill1.into());
position.apply(&fill2.into());
}
#[rstest]
#[case(false)]
#[case(true)]
fn test_historical_duplicate_trade_id_does_not_poison_fill_void_replay(
#[case] causal_duplicate: bool,
audusd_sim: CurrencyPair,
) {
let instrument = InstrumentAny::CurrencyPair(audusd_sim);
let position_id = PositionId::from("P-DUP");
let fill_open = OrderFilledSpec::builder()
.instrument_id(instrument.id())
.client_order_id(ClientOrderId::from("O-1"))
.trade_id(TradeId::from("T-1"))
.order_side(OrderSide::Buy)
.last_qty(Quantity::from(10))
.last_px(Price::from("1.00000"))
.currency(Currency::USD())
.position_id(position_id)
.ts_event(UnixNanos::from(1))
.build();
let fill_close = OrderFilledSpec::builder()
.instrument_id(instrument.id())
.client_order_id(ClientOrderId::from("O-2"))
.trade_id(TradeId::from("T-2"))
.order_side(OrderSide::Sell)
.last_qty(Quantity::from(10))
.last_px(Price::from("1.00010"))
.currency(Currency::USD())
.position_id(position_id)
.ts_event(UnixNanos::from(2))
.build();
let mut fill_duplicate = OrderFilledSpec::builder()
.instrument_id(instrument.id())
.client_order_id(ClientOrderId::from("O-1"))
.trade_id(TradeId::from("T-1"))
.order_side(OrderSide::Buy)
.last_qty(Quantity::from(10))
.last_px(Price::from("1.00020"))
.currency(Currency::USD())
.position_id(position_id)
.ts_event(UnixNanos::from(3))
.build();
if causal_duplicate {
fill_duplicate.causation_id = Some(fill_open.event_id);
}
let fill_reopen = OrderFilledSpec::builder()
.instrument_id(instrument.id())
.client_order_id(ClientOrderId::from("O-3"))
.trade_id(TradeId::from("T-3"))
.order_side(OrderSide::Buy)
.last_qty(Quantity::from(5))
.last_px(Price::from("1.00000"))
.currency(Currency::USD())
.position_id(position_id)
.ts_event(UnixNanos::from(4))
.build();
let mut fill_duplicate_open = fill_duplicate.clone();
fill_duplicate_open.event_id = uuid4();
fill_duplicate_open.client_order_id = ClientOrderId::from("O-4");
fill_duplicate_open.ts_event = UnixNanos::from(5);
let fill_voided = OrderFillVoidedSpec::builder()
.instrument_id(fill_close.instrument_id)
.client_order_id(fill_close.client_order_id)
.venue_order_id(fill_close.venue_order_id)
.account_id(fill_close.account_id)
.trade_id(fill_close.trade_id)
.voided_qty(Quantity::from(10))
.order_side(fill_close.order_side)
.order_type(fill_close.order_type)
.last_px(fill_close.last_px)
.currency(fill_close.currency)
.liquidity_side(fill_close.liquidity_side)
.position_id(position_id)
.build();
let mut position = Position::new(&instrument, fill_open.clone());
position.apply(&fill_close);
position.apply(&fill_duplicate);
assert_eq!(position.side, PositionSide::Flat);
assert_eq!(position.quantity, Quantity::from(0));
assert_eq!(position.events, vec![fill_open.clone(), fill_close.clone()]);
assert_eq!(position.replay_events.len(), 2);
assert_eq!(position.trade_ids.len(), 2);
assert!(position.trade_ids.contains(&TradeId::from("T-1")));
assert!(position.trade_ids.contains(&TradeId::from("T-2")));
position.apply(&fill_reopen);
position.apply(&fill_duplicate_open);
assert_eq!(position.side, PositionSide::Long);
assert_eq!(position.quantity, Quantity::from(5));
assert_eq!(position.opening_order_id, ClientOrderId::from("O-3"));
assert_eq!(position.events, vec![fill_reopen.clone()]);
assert_eq!(position.replay_events.len(), 3);
assert_eq!(position.trade_ids.len(), 1);
assert!(position.trade_ids.contains(&TradeId::from("T-3")));
position
.apply_fill_void(fill_voided, Quantity::from(10), None)
.unwrap();
assert_eq!(position.side, PositionSide::Long);
assert_eq!(position.quantity, Quantity::from(15));
assert_eq!(position.opening_order_id, ClientOrderId::from("O-1"));
assert_eq!(position.closing_order_id, None);
assert_eq!(position.avg_px_open, 1.0);
assert_eq!(position.buy_qty, Quantity::from(15));
assert_eq!(position.sell_qty, Quantity::from(0));
assert_eq!(
position.events,
vec![fill_open.clone(), fill_reopen.clone()]
);
assert_eq!(position.replay_events.len(), 3);
assert_eq!(position.fill_voids.len(), 1);
assert_eq!(position.trade_ids.len(), 2);
assert!(position.trade_ids.contains(&TradeId::from("T-1")));
assert!(position.trade_ids.contains(&TradeId::from("T-3")));
let mut fill_close_duplicate = fill_close;
fill_close_duplicate.event_id = uuid4();
fill_close_duplicate.ts_event = UnixNanos::from(6);
position.apply(&fill_close_duplicate);
assert_eq!(position.side, PositionSide::Long);
assert_eq!(position.quantity, Quantity::from(15));
assert_eq!(position.events, vec![fill_open, fill_reopen]);
assert_eq!(position.replay_events.len(), 3);
}
#[rstest]
fn test_position_applies_fills_with_negative_prices(audusd_sim: CurrencyPair) {
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(100_000))
.build();
let fill1 = TestOrderEventStubs::filled(
&order,
&audusd_sim,
Some(TradeId::new("1")),
None,
Some(Price::from("-5.00000")),
Some(Quantity::from(50_000)),
None,
None,
None,
None,
);
let fill2 = TestOrderEventStubs::filled(
&order,
&audusd_sim,
Some(TradeId::new("2")),
None,
Some(Price::from("-7.00000")),
Some(Quantity::from(50_000)),
None,
None,
None,
None,
);
let mut position = Position::new(&audusd_sim, fill1.into());
position.apply(&fill2.into());
assert_eq!(position.quantity, Quantity::from(100_000));
assert_eq!(position.signed_qty, 100_000.0);
assert_eq!(position.side, PositionSide::Long);
assert_eq!(position.avg_px_open, -6.0);
}
#[rstest]
fn test_position_filled_with_buy_order(audusd_sim: CurrencyPair) {
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(100_000))
.build();
let fill = TestOrderEventStubs::filled(
&order,
&audusd_sim,
None,
None,
Some(Price::from("1.00001")),
None,
None,
None,
None,
None,
);
let last_price = Price::from_str("1.0005").unwrap();
let position = Position::new(&audusd_sim, fill.into());
assert_eq!(position.symbol(), audusd_sim.id().symbol);
assert_eq!(position.venue(), audusd_sim.id().venue);
assert_eq!(position.closing_order_side(), OrderSide::Sell);
assert!(!position.is_opposite_side(OrderSide::Buy));
assert_eq!(position, position); assert!(position.closing_order_id.is_none());
assert_eq!(position.quantity, Quantity::from(100_000));
assert_eq!(position.peak_qty, Quantity::from(100_000));
assert_eq!(position.size_precision, 0);
assert_eq!(position.signed_qty, 100_000.0);
assert_eq!(position.entry, OrderSide::Buy);
assert_eq!(position.side, PositionSide::Long);
assert_eq!(position.ts_opened.as_u64(), 0);
assert_eq!(position.duration_ns, 0);
assert_eq!(position.avg_px_open, 1.00001);
assert_eq!(position.event_count(), 1);
assert_eq!(position.id, PositionId::new("1"));
assert_eq!(position.events.len(), 1);
assert!(position.is_long());
assert!(!position.is_short());
assert!(position.is_open());
assert!(!position.is_closed());
assert_eq!(position.realized_return, 0.0);
assert_eq!(position.realized_pnl, Some(Money::from("-2.0 USD")));
assert_eq!(position.unrealized_pnl(last_price), Money::from("49.0 USD"));
assert_eq!(position.total_pnl(last_price), Money::from("47.0 USD"));
assert_eq!(position.commissions(), vec![Money::from("2.0 USD")]);
assert_eq!(
format!("{position}"),
"Position(LONG 100_000 AUD/USD.SIM, id=1)"
);
}
#[rstest]
fn test_position_filled_with_sell_order(audusd_sim: CurrencyPair) {
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Sell)
.quantity(Quantity::from(100_000))
.build();
let fill = TestOrderEventStubs::filled(
&order,
&audusd_sim,
None,
None,
Some(Price::from("1.00001")),
None,
None,
None,
None,
None,
);
let last_price = Price::from_str("1.00050").unwrap();
let position = Position::new(&audusd_sim, fill.into());
assert_eq!(position.symbol(), audusd_sim.id().symbol);
assert_eq!(position.venue(), audusd_sim.id().venue);
assert_eq!(position.closing_order_side(), OrderSide::Buy);
assert!(!position.is_opposite_side(OrderSide::Sell));
assert_eq!(position, position); assert!(position.closing_order_id.is_none());
assert_eq!(position.quantity, Quantity::from(100_000));
assert_eq!(position.peak_qty, Quantity::from(100_000));
assert_eq!(position.signed_qty, -100_000.0);
assert_eq!(position.entry, OrderSide::Sell);
assert_eq!(position.side, PositionSide::Short);
assert_eq!(position.ts_opened.as_u64(), 0);
assert_eq!(position.avg_px_open, 1.00001);
assert_eq!(position.event_count(), 1);
assert_eq!(position.id, PositionId::new("1"));
assert_eq!(position.events.len(), 1);
assert!(!position.is_long());
assert!(position.is_short());
assert!(position.is_open());
assert!(!position.is_closed());
assert_eq!(position.realized_return, 0.0);
assert_eq!(position.realized_pnl, Some(Money::from("-2.0 USD")));
assert_eq!(
position.unrealized_pnl(last_price),
Money::from("-49.0 USD")
);
assert_eq!(position.total_pnl(last_price), Money::from("-51.0 USD"));
assert_eq!(position.commissions(), vec![Money::from("2.0 USD")]);
assert_eq!(
format!("{position}"),
"Position(SHORT 100_000 AUD/USD.SIM, id=1)"
);
}
#[rstest]
fn test_position_partial_fills_with_buy_order(audusd_sim: CurrencyPair) {
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(100_000))
.build();
let fill = TestOrderEventStubs::filled(
&order,
&audusd_sim,
None,
None,
Some(Price::from("1.00001")),
Some(Quantity::from(50_000)),
None,
None,
None,
None,
);
let last_price = Price::from_str("1.00048").unwrap();
let position = Position::new(&audusd_sim, fill.into());
assert_eq!(position.quantity, Quantity::from(50_000));
assert_eq!(position.peak_qty, Quantity::from(50_000));
assert_eq!(position.side, PositionSide::Long);
assert_eq!(position.signed_qty, 50000.0);
assert_eq!(position.avg_px_open, 1.00001);
assert_eq!(position.event_count(), 1);
assert_eq!(position.ts_opened.as_u64(), 0);
assert!(position.is_long());
assert!(!position.is_short());
assert!(position.is_open());
assert!(!position.is_closed());
assert_eq!(position.realized_return, 0.0);
assert_eq!(position.realized_pnl, Some(Money::from("-2.0 USD")));
assert_eq!(position.unrealized_pnl(last_price), Money::from("23.5 USD"));
assert_eq!(position.total_pnl(last_price), Money::from("21.5 USD"));
assert_eq!(position.commissions(), vec![Money::from("2.0 USD")]);
assert_eq!(
format!("{position}"),
"Position(LONG 50_000 AUD/USD.SIM, id=1)"
);
}
#[rstest]
fn test_position_partial_fills_with_two_sell_orders(audusd_sim: CurrencyPair) {
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Sell)
.quantity(Quantity::from(100_000))
.build();
let fill1 = TestOrderEventStubs::filled(
&order,
&audusd_sim,
Some(TradeId::new("1")),
None,
Some(Price::from("1.00001")),
Some(Quantity::from(50_000)),
None,
None,
None,
None,
);
let fill2 = TestOrderEventStubs::filled(
&order,
&audusd_sim,
Some(TradeId::new("2")),
None,
Some(Price::from("1.00002")),
Some(Quantity::from(50_000)),
None,
None,
None,
None,
);
let last_price = Price::from_str("1.0005").unwrap();
let mut position = Position::new(&audusd_sim, fill1.into());
position.apply(&fill2.into());
assert_eq!(position.quantity, Quantity::from(100_000));
assert_eq!(position.peak_qty, Quantity::from(100_000));
assert_eq!(position.side, PositionSide::Short);
assert_eq!(position.signed_qty, -100_000.0);
assert_eq!(position.avg_px_open, 1.000_015);
assert_eq!(position.event_count(), 2);
assert_eq!(position.ts_opened, 0);
assert!(position.is_short());
assert!(!position.is_long());
assert!(position.is_open());
assert!(!position.is_closed());
assert_eq!(position.realized_return, 0.0);
assert_eq!(position.realized_pnl, Some(Money::from("-4.0 USD")));
assert_eq!(
position.unrealized_pnl(last_price),
Money::from("-48.5 USD")
);
assert_eq!(position.total_pnl(last_price), Money::from("-52.5 USD"));
assert_eq!(position.commissions(), vec![Money::from("4.0 USD")]);
}
#[rstest]
pub fn test_position_filled_with_buy_order_then_sell_order(audusd_sim: CurrencyPair) {
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(150_000))
.build();
let fill = TestOrderEventStubs::filled(
&order,
&audusd_sim,
Some(TradeId::new("1")),
Some(PositionId::new("P-1")),
Some(Price::from("1.00001")),
None,
None,
None,
Some(UnixNanos::from(1_000_000_000)),
None,
);
let mut position = Position::new(&audusd_sim, fill.into());
let fill2 = OrderFilledSpec::builder()
.trader_id(order.trader_id())
.strategy_id(StrategyId::new("S-001"))
.instrument_id(order.instrument_id())
.client_order_id(order.client_order_id())
.venue_order_id(VenueOrderId::from("2"))
.account_id(order.account_id().unwrap_or(AccountId::new("SIM-001")))
.trade_id(TradeId::new("2"))
.order_side(OrderSide::Sell)
.last_qty(order.quantity())
.last_px(Price::from("1.00011"))
.currency(audusd_sim.quote_currency())
.ts_event(2_000_000_000.into())
.position_id(PositionId::new("T1"))
.commission(Money::from("0.0 USD"))
.build();
position.apply(&fill2);
let last = Price::from_str("1.0005").unwrap();
assert!(position.is_opposite_side(fill2.order_side));
assert_eq!(
position.quantity,
Quantity::zero(audusd_sim.price_precision())
);
assert_eq!(position.size_precision, 0);
assert_eq!(position.signed_qty, 0.0);
assert_eq!(position.side, PositionSide::Flat);
assert_eq!(position.ts_opened, 1_000_000_000);
assert_eq!(position.ts_closed, Some(UnixNanos::from(2_000_000_000)));
assert_eq!(position.duration_ns, 1_000_000_000);
assert_eq!(position.avg_px_open, 1.00001);
assert_eq!(position.avg_px_close, Some(1.00011));
assert!(!position.is_long());
assert!(!position.is_short());
assert!(!position.is_open());
assert!(position.is_closed());
assert_eq!(position.realized_return, 9.999_900_000_998_888e-5);
assert_eq!(position.realized_pnl, Some(Money::from("13.0 USD")));
assert_eq!(position.unrealized_pnl(last), Money::from("0 USD"));
assert_eq!(position.commissions(), vec![Money::from("2 USD")]);
assert_eq!(position.total_pnl(last), Money::from("13 USD"));
assert_eq!(format!("{position}"), "Position(FLAT AUD/USD.SIM, id=P-1)");
}
#[rstest]
pub fn test_position_filled_with_sell_order_then_buy_order(audusd_sim: CurrencyPair) {
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let order1 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Sell)
.quantity(Quantity::from(100_000))
.build();
let order2 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(100_000))
.build();
let fill1 = TestOrderEventStubs::filled(
&order1,
&audusd_sim,
None,
Some(PositionId::new("P-19700101-000000-001-001-1")),
Some(Price::from("1.0")),
None,
None,
None,
None,
None,
);
let mut position = Position::new(&audusd_sim, fill1.into());
let fill2 = TestOrderEventStubs::filled(
&order2,
&audusd_sim,
Some(TradeId::new("1")),
Some(PositionId::new("P-19700101-000000-001-001-1")),
Some(Price::from("1.00001")),
Some(Quantity::from(50_000)),
None,
None,
None,
None,
);
let fill3 = TestOrderEventStubs::filled(
&order2,
&audusd_sim,
Some(TradeId::new("2")),
Some(PositionId::new("P-19700101-000000-001-001-1")),
Some(Price::from("1.00003")),
Some(Quantity::from(50_000)),
None,
None,
None,
None,
);
let last = Price::from("1.0005");
position.apply(&fill2.into());
position.apply(&fill3.into());
assert_eq!(
position.quantity,
Quantity::zero(audusd_sim.price_precision())
);
assert_eq!(position.side, PositionSide::Flat);
assert_eq!(position.ts_opened, 0);
assert_eq!(position.avg_px_open, 1.0);
assert_eq!(position.events.len(), 3);
assert_eq!(position.ts_closed, Some(UnixNanos::default()));
assert_eq!(position.avg_px_close, Some(1.00002));
assert!(!position.is_long());
assert!(!position.is_short());
assert!(!position.is_open());
assert!(position.is_closed());
assert_eq!(position.commissions(), vec![Money::from("6.0 USD")]);
assert_eq!(position.unrealized_pnl(last), Money::from("0 USD"));
assert_eq!(position.realized_pnl, Some(Money::from("-8.0 USD")));
assert_eq!(position.total_pnl(last), Money::from("-8.0 USD"));
assert_eq!(
format!("{position}"),
"Position(FLAT AUD/USD.SIM, id=P-19700101-000000-001-001-1)"
);
}
#[rstest]
fn test_position_filled_with_no_change(audusd_sim: CurrencyPair) {
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let order1 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(100_000))
.build();
let order2 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Sell)
.quantity(Quantity::from(100_000))
.build();
let fill1 = TestOrderEventStubs::filled(
&order1,
&audusd_sim,
Some(TradeId::new("1")),
Some(PositionId::new("P-19700101-000000-001-001-1")),
Some(Price::from("1.0")),
None,
None,
None,
None,
None,
);
let mut position = Position::new(&audusd_sim, fill1.into());
let fill2 = TestOrderEventStubs::filled(
&order2,
&audusd_sim,
Some(TradeId::new("2")),
Some(PositionId::new("P-19700101-000000-001-001-1")),
Some(Price::from("1.0")),
None,
None,
None,
None,
None,
);
let last = Price::from("1.0005");
position.apply(&fill2.into());
assert_eq!(
position.quantity,
Quantity::zero(audusd_sim.price_precision())
);
assert_eq!(position.closing_order_side(), OrderSide::NoOrderSide);
assert_eq!(position.side, PositionSide::Flat);
assert_eq!(position.ts_opened, 0);
assert_eq!(position.avg_px_open, 1.0);
assert_eq!(position.events.len(), 2);
assert_eq!(position.ts_closed, Some(UnixNanos::default()));
assert_eq!(position.avg_px_close, Some(1.0));
assert!(!position.is_long());
assert!(!position.is_short());
assert!(!position.is_open());
assert!(position.is_closed());
assert_eq!(position.commissions(), vec![Money::from("4.0 USD")]);
assert_eq!(position.unrealized_pnl(last), Money::from("0 USD"));
assert_eq!(position.realized_pnl, Some(Money::from("-4.0 USD")));
assert_eq!(position.total_pnl(last), Money::from("-4.0 USD"));
assert_eq!(
format!("{position}"),
"Position(FLAT AUD/USD.SIM, id=P-19700101-000000-001-001-1)"
);
}
#[rstest]
fn test_position_long_with_multiple_filled_orders(audusd_sim: CurrencyPair) {
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let order1 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(100_000))
.build();
let order2 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(100_000))
.build();
let order3 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Sell)
.quantity(Quantity::from(200_000))
.build();
let fill1 = TestOrderEventStubs::filled(
&order1,
&audusd_sim,
Some(TradeId::new("1")),
Some(PositionId::new("P-123456")),
Some(Price::from("1.0")),
None,
None,
None,
None,
None,
);
let fill2 = TestOrderEventStubs::filled(
&order2,
&audusd_sim,
Some(TradeId::new("2")),
Some(PositionId::new("P-123456")),
Some(Price::from("1.00001")),
None,
None,
None,
None,
None,
);
let fill3 = TestOrderEventStubs::filled(
&order3,
&audusd_sim,
Some(TradeId::new("3")),
Some(PositionId::new("P-123456")),
Some(Price::from("1.0001")),
None,
None,
None,
None,
None,
);
let mut position = Position::new(&audusd_sim, fill1.into());
let last = Price::from("1.0005");
position.apply(&fill2.into());
position.apply(&fill3.into());
assert_eq!(
position.quantity,
Quantity::zero(audusd_sim.price_precision())
);
assert_eq!(position.side, PositionSide::Flat);
assert_eq!(position.ts_opened, 0);
assert_eq!(position.avg_px_open, 1.000_005);
assert_eq!(position.events.len(), 3);
assert_eq!(position.ts_closed, Some(UnixNanos::default()));
assert_eq!(position.avg_px_close, Some(1.0001));
assert!(position.is_closed());
assert!(!position.is_open());
assert!(!position.is_long());
assert!(!position.is_short());
assert_eq!(position.commissions(), vec![Money::from("6.0 USD")]);
assert_eq!(position.realized_pnl, Some(Money::from("13.0 USD")));
assert_eq!(position.unrealized_pnl(last), Money::from("0 USD"));
assert_eq!(position.total_pnl(last), Money::from("13 USD"));
assert_eq!(
format!("{position}"),
"Position(FLAT AUD/USD.SIM, id=P-123456)"
);
}
#[rstest]
fn test_pnl_calculation_from_trading_technologies_example(currency_pair_ethusdt: CurrencyPair) {
let ethusdt = InstrumentAny::CurrencyPair(currency_pair_ethusdt);
let quantity1 = Quantity::from(12);
let price1 = Price::from("100.0");
let order1 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(ethusdt.id())
.side(OrderSide::Buy)
.quantity(quantity1)
.build();
let commission1 = calculate_commission(ðusdt, order1.quantity(), price1, None);
let fill1 = TestOrderEventStubs::filled(
&order1,
ðusdt,
Some(TradeId::new("1")),
Some(PositionId::new("P-123456")),
Some(price1),
None,
None,
Some(commission1),
None,
None,
);
let mut position = Position::new(ðusdt, fill1.into());
let quantity2 = Quantity::from(17);
let order2 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(ethusdt.id())
.side(OrderSide::Buy)
.quantity(quantity2)
.build();
let price2 = Price::from("99.0");
let commission2 = calculate_commission(ðusdt, order2.quantity(), price2, None);
let fill2 = TestOrderEventStubs::filled(
&order2,
ðusdt,
Some(TradeId::new("2")),
Some(PositionId::new("P-123456")),
Some(price2),
None,
None,
Some(commission2),
None,
None,
);
position.apply(&fill2.into());
assert_eq!(position.quantity, Quantity::from(29));
assert_eq!(position.realized_pnl, Some(Money::from("-0.28830000 USDT")));
assert_eq!(position.avg_px_open, 99.413_793_103_448_27);
let quantity3 = Quantity::from(9);
let order3 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(ethusdt.id())
.side(OrderSide::Sell)
.quantity(quantity3)
.build();
let price3 = Price::from("101.0");
let commission3 = calculate_commission(ðusdt, order3.quantity(), price3, None);
let fill3 = TestOrderEventStubs::filled(
&order3,
ðusdt,
Some(TradeId::new("3")),
Some(PositionId::new("P-123456")),
Some(price3),
None,
None,
Some(commission3),
None,
None,
);
position.apply(&fill3.into());
assert_eq!(position.quantity, Quantity::from(20));
assert_eq!(position.realized_pnl, Some(Money::from("13.89666207 USDT")));
assert_eq!(position.avg_px_open, 99.413_793_103_448_27);
let quantity4 = Quantity::from("4");
let price4 = Price::from("105.0");
let order4 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(ethusdt.id())
.side(OrderSide::Sell)
.quantity(quantity4)
.build();
let commission4 = calculate_commission(ðusdt, order4.quantity(), price4, None);
let fill4 = TestOrderEventStubs::filled(
&order4,
ðusdt,
Some(TradeId::new("4")),
Some(PositionId::new("P-123456")),
Some(price4),
None,
None,
Some(commission4),
None,
None,
);
position.apply(&fill4.into());
assert_eq!(position.quantity, Quantity::from("16"));
assert_eq!(position.realized_pnl, Some(Money::from("36.19948966 USDT")));
assert_eq!(position.avg_px_open, 99.413_793_103_448_27);
let quantity5 = Quantity::from("3");
let price5 = Price::from("103.0");
let order5 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(ethusdt.id())
.side(OrderSide::Buy)
.quantity(quantity5)
.build();
let commission5 = calculate_commission(ðusdt, order5.quantity(), price5, None);
let fill5 = TestOrderEventStubs::filled(
&order5,
ðusdt,
Some(TradeId::new("5")),
Some(PositionId::new("P-123456")),
Some(price5),
None,
None,
Some(commission5),
None,
None,
);
position.apply(&fill5.into());
assert_eq!(position.quantity, Quantity::from("19"));
assert_eq!(position.realized_pnl, Some(Money::from("36.16858966 USDT")));
assert_eq!(position.avg_px_open, 99.980_036_297_640_65);
assert_eq!(
format!("{position}"),
"Position(LONG 19.00000 ETHUSDT.BINANCE, id=P-123456)"
);
}
#[rstest]
fn test_position_closed_and_reopened(audusd_sim: CurrencyPair) {
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let quantity1 = Quantity::from(150_000);
let price1 = Price::from("1.00001");
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(quantity1)
.build();
let commission1 = calculate_commission(&audusd_sim, quantity1, price1, None);
let fill1 = TestOrderEventStubs::filled(
&order,
&audusd_sim,
Some(TradeId::new("5")),
Some(PositionId::new("P-123456")),
Some(Price::from("1.00001")),
None,
None,
Some(commission1),
Some(UnixNanos::from(1_000_000_000)),
None,
);
let mut position = Position::new(&audusd_sim, fill1.into());
let fill2 = OrderFilledSpec::builder()
.trader_id(order.trader_id())
.strategy_id(order.strategy_id())
.instrument_id(order.instrument_id())
.client_order_id(order.client_order_id())
.venue_order_id(VenueOrderId::from("2"))
.account_id(order.account_id().unwrap_or(AccountId::new("SIM-001")))
.trade_id(TradeId::from("2"))
.order_side(OrderSide::Sell)
.last_qty(order.quantity())
.last_px(Price::from("1.00011"))
.currency(audusd_sim.quote_currency())
.ts_event(UnixNanos::from(2_000_000_000))
.position_id(PositionId::from("P-123456"))
.commission(Money::from("0 USD"))
.build();
position.apply(&fill2);
let fill3 = OrderFilledSpec::builder()
.trader_id(order.trader_id())
.strategy_id(order.strategy_id())
.instrument_id(order.instrument_id())
.client_order_id(order.client_order_id())
.venue_order_id(VenueOrderId::from("2"))
.account_id(order.account_id().unwrap_or(AccountId::new("SIM-001")))
.trade_id(TradeId::from("3"))
.last_qty(order.quantity())
.last_px(Price::from("1.00012"))
.currency(audusd_sim.quote_currency())
.ts_event(UnixNanos::from(3_000_000_000))
.position_id(PositionId::from("P-123456"))
.commission(Money::from("0 USD"))
.build();
position.apply(&fill3);
let last = Price::from("1.0003");
assert!(position.is_opposite_side(fill2.order_side));
assert_eq!(position.quantity, Quantity::from(150_000));
assert_eq!(position.peak_qty, Quantity::from(150_000));
assert_eq!(position.side, PositionSide::Long);
assert_eq!(position.opening_order_id, fill3.client_order_id);
assert_eq!(position.closing_order_id, None);
assert_eq!(position.closing_order_id, None);
assert_eq!(position.ts_opened, 3_000_000_000);
assert_eq!(position.duration_ns, 0);
assert_eq!(position.avg_px_open, 1.00012);
assert_eq!(position.event_count(), 1);
assert_eq!(position.ts_closed, None);
assert_eq!(position.avg_px_close, None);
assert!(position.is_long());
assert!(!position.is_short());
assert!(position.is_open());
assert!(!position.is_closed());
assert_eq!(position.realized_return, 0.0);
assert_eq!(position.realized_pnl, Some(Money::from("0 USD")));
assert_eq!(position.unrealized_pnl(last), Money::from("27 USD"));
assert_eq!(position.total_pnl(last), Money::from("27 USD"));
assert_eq!(position.commissions(), vec![Money::from("0 USD")]);
assert_eq!(
format!("{position}"),
"Position(LONG 150_000 AUD/USD.SIM, id=P-123456)"
);
}
#[rstest]
fn test_fill_void_replays_across_position_close_and_reopen(audusd_sim: CurrencyPair) {
let instrument = InstrumentAny::CurrencyPair(audusd_sim);
let position_id = PositionId::from("P-VOID-REPLAY");
let fill1 = OrderFilledSpec::builder()
.instrument_id(instrument.id())
.client_order_id(ClientOrderId::from("O-OPEN"))
.trade_id(TradeId::from("T-OPEN"))
.order_side(OrderSide::Buy)
.last_qty(Quantity::from(10))
.last_px(Price::from("1.00000"))
.currency(Currency::USD())
.position_id(position_id)
.commission(Money::from("1.00 USD"))
.ts_event(UnixNanos::from(1))
.build();
let fill2 = OrderFilledSpec::builder()
.instrument_id(instrument.id())
.client_order_id(ClientOrderId::from("O-CLOSE"))
.trade_id(TradeId::from("T-CLOSE"))
.order_side(OrderSide::Sell)
.last_qty(Quantity::from(10))
.last_px(Price::from("1.10000"))
.currency(Currency::USD())
.position_id(position_id)
.commission(Money::from("1.00 USD"))
.ts_event(UnixNanos::from(2))
.build();
let fill3 = OrderFilledSpec::builder()
.instrument_id(instrument.id())
.client_order_id(ClientOrderId::from("O-REOPEN"))
.trade_id(TradeId::from("T-REOPEN"))
.order_side(OrderSide::Buy)
.last_qty(Quantity::from(5))
.last_px(Price::from("1.20000"))
.currency(Currency::USD())
.position_id(position_id)
.commission(Money::from("1.00 USD"))
.ts_event(UnixNanos::from(3))
.build();
let fill_voided = OrderFillVoidedSpec::builder()
.instrument_id(fill2.instrument_id)
.client_order_id(fill2.client_order_id)
.venue_order_id(fill2.venue_order_id)
.account_id(fill2.account_id)
.trade_id(fill2.trade_id)
.voided_qty(Quantity::from(5))
.commission_voided(Money::from("0.50 USD"))
.order_side(fill2.order_side)
.order_type(fill2.order_type)
.last_px(fill2.last_px)
.currency(fill2.currency)
.liquidity_side(fill2.liquidity_side)
.position_id(position_id)
.build();
let mut position = Position::new(&instrument, fill1);
position.apply(&fill2);
position.apply(&fill3);
position
.apply_fill_void(
fill_voided,
Quantity::from(5),
Some(Money::from("0.50 USD")),
)
.unwrap();
let encoded = serde_json::to_string(&position).unwrap();
let restored: Position = serde_json::from_str(&encoded).unwrap();
assert_eq!(position.side, PositionSide::Long);
assert_eq!(position.quantity, Quantity::from(10));
assert_eq!(position.opening_order_id, ClientOrderId::from("O-OPEN"));
assert_eq!(position.buy_qty, Quantity::from(15));
assert_eq!(position.sell_qty, Quantity::from(5));
assert_eq!(position.commissions(), vec![Money::from("2.50 USD")]);
assert_eq!(position.replay_events.len(), 3);
assert_eq!(position.fill_voids.len(), 1);
assert_eq!(restored.quantity, position.quantity);
assert_eq!(restored.opening_order_id, position.opening_order_id);
assert_eq!(restored.commissions(), position.commissions());
assert_eq!(restored.replay_events.len(), position.replay_events.len());
assert_eq!(restored.fill_voids.len(), position.fill_voids.len());
}
#[rstest]
fn test_full_fill_void_preserves_unvoided_commission(audusd_sim: CurrencyPair) {
let instrument = InstrumentAny::CurrencyPair(audusd_sim);
let position_id = PositionId::from("P-FEE-VOID");
let fill = OrderFilledSpec::builder()
.instrument_id(instrument.id())
.client_order_id(ClientOrderId::from("O-FEE"))
.trade_id(TradeId::from("T-FEE"))
.order_side(OrderSide::Buy)
.last_qty(Quantity::from(10))
.last_px(Price::from("1.00000"))
.currency(Currency::USD())
.position_id(position_id)
.commission(Money::from("1.00 USD"))
.build();
let fill_voided = OrderFillVoidedSpec::builder()
.instrument_id(fill.instrument_id)
.client_order_id(fill.client_order_id)
.venue_order_id(fill.venue_order_id)
.account_id(fill.account_id)
.trade_id(fill.trade_id)
.voided_qty(fill.last_qty)
.order_side(fill.order_side)
.order_type(fill.order_type)
.last_px(fill.last_px)
.currency(fill.currency)
.liquidity_side(fill.liquidity_side)
.build();
let mut position = Position::new(&instrument, fill);
position
.apply_fill_void(fill_voided, Quantity::from(10), None)
.unwrap();
assert_eq!(position.side, PositionSide::Flat);
assert_eq!(position.quantity, Quantity::from(0));
assert_eq!(position.commissions(), vec![Money::from("1.00 USD")]);
assert_eq!(position.realized_pnl, Some(Money::from("-1.00 USD")));
assert!(position.events.is_empty());
}
#[rstest]
fn test_fill_void_replays_netting_flip_fragments_with_one_trade_id(audusd_sim: CurrencyPair) {
let instrument = InstrumentAny::CurrencyPair(audusd_sim);
let position_id = PositionId::from("P-FLIP-VOID");
let opening = OrderFilledSpec::builder()
.instrument_id(instrument.id())
.client_order_id(ClientOrderId::from("O-OPEN"))
.trade_id(TradeId::from("T-OPEN"))
.order_side(OrderSide::Buy)
.last_qty(Quantity::from(10))
.last_px(Price::from("1.00000"))
.currency(Currency::USD())
.position_id(position_id)
.build();
let closing = OrderFilledSpec::builder()
.instrument_id(instrument.id())
.client_order_id(ClientOrderId::from("O-FLIP"))
.trade_id(TradeId::from("T-FLIP"))
.order_side(OrderSide::Sell)
.last_qty(Quantity::from(10))
.last_px(Price::from("1.10000"))
.currency(Currency::USD())
.position_id(position_id)
.build();
let mut reopening = closing.clone();
reopening.last_qty = Quantity::from(5);
reopening.event_id = uuid4();
reopening.causation_id = Some(closing.event_id);
let fill_voided = OrderFillVoidedSpec::builder()
.instrument_id(closing.instrument_id)
.client_order_id(closing.client_order_id)
.venue_order_id(closing.venue_order_id)
.account_id(closing.account_id)
.trade_id(closing.trade_id)
.voided_qty(Quantity::from(12))
.order_side(closing.order_side)
.order_type(closing.order_type)
.last_px(closing.last_px)
.currency(closing.currency)
.liquidity_side(closing.liquidity_side)
.position_id(position_id)
.build();
let mut position = Position::new(&instrument, opening);
position.apply(&closing);
assert!(!position.is_duplicate_replay_fill(&reopening));
position.apply(&reopening);
position
.apply_fill_void(fill_voided, Quantity::from(12), None)
.unwrap();
assert_eq!(position.side, PositionSide::Long);
assert_eq!(position.quantity, Quantity::from(7));
assert_eq!(position.buy_qty, Quantity::from(10));
assert_eq!(position.sell_qty, Quantity::from(3));
assert_eq!(position.replay_events.len(), 3);
assert!(position.is_duplicate_replay_fill(&reopening));
}
#[rstest]
fn test_fill_void_replays_split_fragments_in_one_corrected_cycle(audusd_sim: CurrencyPair) {
let instrument = InstrumentAny::CurrencyPair(audusd_sim);
let position_id = PositionId::from("P-FLIP-CYCLE-VOID");
let opening = OrderFilledSpec::builder()
.instrument_id(instrument.id())
.client_order_id(ClientOrderId::from("O-SELL-1"))
.trade_id(TradeId::from("T-SELL-1"))
.order_side(OrderSide::Sell)
.last_qty(Quantity::from(17))
.last_px(Price::from("1.00000"))
.currency(Currency::USD())
.position_id(position_id)
.build();
let second_sell = OrderFilledSpec::builder()
.instrument_id(instrument.id())
.client_order_id(ClientOrderId::from("O-SELL-2"))
.trade_id(TradeId::from("T-SELL-2"))
.order_side(OrderSide::Sell)
.last_qty(Quantity::from(17))
.last_px(Price::from("1.00000"))
.currency(Currency::USD())
.position_id(position_id)
.build();
let closing = OrderFilledSpec::builder()
.instrument_id(instrument.id())
.client_order_id(ClientOrderId::from("O-FLIP"))
.trade_id(TradeId::from("T-FLIP"))
.order_side(OrderSide::Buy)
.last_qty(Quantity::from(34))
.last_px(Price::from("1.10000"))
.currency(Currency::USD())
.position_id(position_id)
.build();
let mut reopening = closing.clone();
reopening.last_qty = Quantity::from(591);
reopening.event_id = uuid4();
reopening.causation_id = Some(closing.event_id);
let fill_voided = OrderFillVoidedSpec::builder()
.instrument_id(second_sell.instrument_id)
.client_order_id(second_sell.client_order_id)
.venue_order_id(second_sell.venue_order_id)
.account_id(second_sell.account_id)
.trade_id(second_sell.trade_id)
.voided_qty(Quantity::from(2))
.order_side(second_sell.order_side)
.order_type(second_sell.order_type)
.last_px(second_sell.last_px)
.currency(second_sell.currency)
.liquidity_side(second_sell.liquidity_side)
.position_id(position_id)
.build();
let mut position = Position::new(&instrument, opening);
position.apply(&second_sell);
position.apply(&closing);
position.apply(&reopening);
position
.apply_fill_void(fill_voided, Quantity::from(2), None)
.unwrap();
assert_eq!(position.side, PositionSide::Long);
assert_eq!(position.quantity, Quantity::from(593));
assert_eq!(position.buy_qty, Quantity::from(625));
assert_eq!(position.sell_qty, Quantity::from(32));
assert_eq!(position.events.len(), 4);
assert_eq!(position.replay_events.len(), 4);
assert_eq!(position.fill_voids.len(), 1);
assert_eq!(position.trade_ids.len(), 3);
assert!(position.trade_ids.contains(&TradeId::from("T-FLIP")));
}
#[rstest]
fn test_position_realized_pnl_with_interleaved_order_sides(
currency_pair_btcusdt: CurrencyPair,
) {
let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
let order1 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btcusdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(12))
.build();
let commission1 =
calculate_commission(&btcusdt, order1.quantity(), Price::from("10000.0"), None);
let fill1 = TestOrderEventStubs::filled(
&order1,
&btcusdt,
Some(TradeId::from("1")),
Some(PositionId::from("P-19700101-000000-001-001-1")),
Some(Price::from("10000.0")),
None,
None,
Some(commission1),
None,
None,
);
let mut position = Position::new(&btcusdt, fill1.into());
let order2 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btcusdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(17))
.build();
let commission2 =
calculate_commission(&btcusdt, order2.quantity(), Price::from("9999.0"), None);
let fill2 = TestOrderEventStubs::filled(
&order2,
&btcusdt,
Some(TradeId::from("2")),
Some(PositionId::from("P-19700101-000000-001-001-1")),
Some(Price::from("9999.0")),
None,
None,
Some(commission2),
None,
None,
);
position.apply(&fill2.into());
assert_eq!(position.quantity, Quantity::from(29));
assert_eq!(
position.realized_pnl,
Some(Money::from("-289.98300000 USDT"))
);
assert_eq!(position.avg_px_open, 9_999.413_793_103_447);
let order3 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btcusdt.id())
.side(OrderSide::Sell)
.quantity(Quantity::from(9))
.build();
let commission3 =
calculate_commission(&btcusdt, order3.quantity(), Price::from("10001.0"), None);
let fill3 = TestOrderEventStubs::filled(
&order3,
&btcusdt,
Some(TradeId::from("3")),
Some(PositionId::from("P-19700101-000000-001-001-1")),
Some(Price::from("10001.0")),
None,
None,
Some(commission3),
None,
None,
);
position.apply(&fill3.into());
assert_eq!(position.quantity, Quantity::from(20));
assert_eq!(
position.realized_pnl,
Some(Money::from("-365.71613793 USDT"))
);
assert_eq!(position.avg_px_open, 9_999.413_793_103_447);
let order4 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btcusdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(3))
.build();
let commission4 =
calculate_commission(&btcusdt, order4.quantity(), Price::from("10003.0"), None);
let fill4 = TestOrderEventStubs::filled(
&order4,
&btcusdt,
Some(TradeId::from("4")),
Some(PositionId::from("P-19700101-000000-001-001-1")),
Some(Price::from("10003.0")),
None,
None,
Some(commission4),
None,
None,
);
position.apply(&fill4.into());
assert_eq!(position.quantity, Quantity::from(23));
assert_eq!(
position.realized_pnl,
Some(Money::from("-395.72513793 USDT"))
);
assert_eq!(position.avg_px_open, 9_999.881_559_220_39);
let order5 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btcusdt.id())
.side(OrderSide::Sell)
.quantity(Quantity::from(4))
.build();
let commission5 =
calculate_commission(&btcusdt, order5.quantity(), Price::from("10005.0"), None);
let fill5 = TestOrderEventStubs::filled(
&order5,
&btcusdt,
Some(TradeId::from("5")),
Some(PositionId::from("P-19700101-000000-001-001-1")),
Some(Price::from("10005.0")),
None,
None,
Some(commission5),
None,
None,
);
position.apply(&fill5.into());
assert_eq!(position.quantity, Quantity::from(19));
assert_eq!(
position.realized_pnl,
Some(Money::from("-415.27137481 USDT"))
);
assert_eq!(position.avg_px_open, 9_999.881_559_220_39);
assert_eq!(
format!("{position}"),
"Position(LONG 19.000000 BTCUSDT.BINANCE, id=P-19700101-000000-001-001-1)"
);
}
#[rstest]
fn test_calculate_pnl_when_given_position_side_flat_returns_zero(
currency_pair_btcusdt: CurrencyPair,
) {
let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btcusdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(12))
.build();
let fill = TestOrderEventStubs::filled(
&order,
&btcusdt,
None,
Some(PositionId::from("P-123456")),
Some(Price::from("10500.0")),
None,
None,
None,
None,
None,
);
let position = Position::new(&btcusdt, fill.into());
let result = position.calculate_pnl(10500.0, 10500.0, Quantity::from("100000.0"));
assert_eq!(result, Money::from("0 USDT"));
}
#[rstest]
fn test_calculate_pnl_for_long_position_win(currency_pair_btcusdt: CurrencyPair) {
let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btcusdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(12))
.build();
let commission =
calculate_commission(&btcusdt, order.quantity(), Price::from("10500.0"), None);
let fill = TestOrderEventStubs::filled(
&order,
&btcusdt,
None,
Some(PositionId::from("P-123456")),
Some(Price::from("10500.0")),
None,
None,
Some(commission),
None,
None,
);
let position = Position::new(&btcusdt, fill.into());
let pnl = position.calculate_pnl(10500.0, 10510.0, Quantity::from("12.0"));
assert_eq!(pnl, Money::from("120 USDT"));
assert_eq!(position.realized_pnl, Some(Money::from("-126 USDT")));
assert_eq!(
position.unrealized_pnl(Price::from("10510.0")),
Money::from("120.0 USDT")
);
assert_eq!(
position.total_pnl(Price::from("10510.0")),
Money::from("-6 USDT")
);
assert_eq!(position.commissions(), vec![Money::from("126.0 USDT")]);
}
#[rstest]
fn test_calculate_pnl_for_long_position_loss(currency_pair_btcusdt: CurrencyPair) {
let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btcusdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(12))
.build();
let commission =
calculate_commission(&btcusdt, order.quantity(), Price::from("10500.0"), None);
let fill = TestOrderEventStubs::filled(
&order,
&btcusdt,
None,
Some(PositionId::from("P-123456")),
Some(Price::from("10500.0")),
None,
None,
Some(commission),
None,
None,
);
let position = Position::new(&btcusdt, fill.into());
let pnl = position.calculate_pnl(10500.0, 10480.5, Quantity::from("10.0"));
assert_eq!(pnl, Money::from("-195 USDT"));
assert_eq!(position.realized_pnl, Some(Money::from("-126 USDT")));
assert_eq!(
position.unrealized_pnl(Price::from("10480.50")),
Money::from("-234.0 USDT")
);
assert_eq!(
position.total_pnl(Price::from("10480.50")),
Money::from("-360 USDT")
);
assert_eq!(position.commissions(), vec![Money::from("126.0 USDT")]);
}
#[rstest]
fn test_calculate_pnl_for_short_position_winning(currency_pair_btcusdt: CurrencyPair) {
let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btcusdt.id())
.side(OrderSide::Sell)
.quantity(Quantity::from("10.15"))
.build();
let commission =
calculate_commission(&btcusdt, order.quantity(), Price::from("10500.0"), None);
let fill = TestOrderEventStubs::filled(
&order,
&btcusdt,
None,
Some(PositionId::from("P-123456")),
Some(Price::from("10500.0")),
None,
None,
Some(commission),
None,
None,
);
let position = Position::new(&btcusdt, fill.into());
let pnl = position.calculate_pnl(10500.0, 10390.0, Quantity::from("10.15"));
assert_eq!(pnl, Money::from("1116.5 USDT"));
assert_eq!(
position.unrealized_pnl(Price::from("10390.0")),
Money::from("1116.5 USDT")
);
assert_eq!(position.realized_pnl, Some(Money::from("-106.575 USDT")));
assert_eq!(position.commissions(), vec![Money::from("106.575 USDT")]);
assert_eq!(
position.notional_value(Price::from("10390.0")),
Money::from("105458.5 USDT")
);
}
#[rstest]
fn test_calculate_pnl_for_short_position_loss(currency_pair_btcusdt: CurrencyPair) {
let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btcusdt.id())
.side(OrderSide::Sell)
.quantity(Quantity::from("10.0"))
.build();
let commission =
calculate_commission(&btcusdt, order.quantity(), Price::from("10500.0"), None);
let fill = TestOrderEventStubs::filled(
&order,
&btcusdt,
None,
Some(PositionId::from("P-123456")),
Some(Price::from("10500.0")),
None,
None,
Some(commission),
None,
None,
);
let position = Position::new(&btcusdt, fill.into());
let pnl = position.calculate_pnl(10500.0, 10670.5, Quantity::from("10.0"));
assert_eq!(pnl, Money::from("-1705 USDT"));
assert_eq!(
position.unrealized_pnl(Price::from("10670.5")),
Money::from("-1705 USDT")
);
assert_eq!(position.realized_pnl, Some(Money::from("-105 USDT")));
assert_eq!(position.commissions(), vec![Money::from("105 USDT")]);
assert_eq!(
position.notional_value(Price::from("10670.5")),
Money::from("106705 USDT")
);
}
#[rstest]
fn test_calculate_pnl_for_inverse1(xbtusd_bitmex: CryptoPerpetual) {
let xbtusd_bitmex = InstrumentAny::CryptoPerpetual(xbtusd_bitmex);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(xbtusd_bitmex.id())
.side(OrderSide::Sell)
.quantity(Quantity::from("100000"))
.build();
let commission = calculate_commission(
&xbtusd_bitmex,
order.quantity(),
Price::from("10000.0"),
None,
);
let fill = TestOrderEventStubs::filled(
&order,
&xbtusd_bitmex,
None,
Some(PositionId::from("P-123456")),
Some(Price::from("10000.0")),
None,
None,
Some(commission),
None,
None,
);
let position = Position::new(&xbtusd_bitmex, fill.into());
let pnl = position.calculate_pnl(10000.0, 11000.0, Quantity::from("100000.0"));
assert_eq!(pnl, Money::from("-0.90909091 BTC"));
assert_eq!(
position.unrealized_pnl(Price::from("11000.0")),
Money::from("-0.90909091 BTC")
);
assert_eq!(position.realized_pnl, Some(Money::from("-0.00750000 BTC")));
assert_eq!(
position.notional_value(Price::from("11000.0")),
Money::from("9.09090909 BTC")
);
}
#[rstest]
fn test_try_notional_value_for_inverse_zero_price_returns_error(
xbtusd_bitmex: CryptoPerpetual,
) {
let xbtusd_bitmex = InstrumentAny::CryptoPerpetual(xbtusd_bitmex);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(xbtusd_bitmex.id())
.side(OrderSide::Sell)
.quantity(Quantity::from("100000"))
.build();
let fill = TestOrderEventStubs::filled(
&order,
&xbtusd_bitmex,
None,
Some(PositionId::from("P-ZERO-PRICE")),
Some(Price::from("10000.0")),
None,
None,
None,
None,
None,
);
let mut position = Position::new(&xbtusd_bitmex, fill.into());
let result = position.try_notional_value(Price::new(0.0, 1));
assert_eq!(
result.unwrap_err().to_string(),
"price must be positive for inverse notional valuation"
);
assert!(
position
.try_calculate_pnl(10_000.0, 0.0, position.quantity)
.is_err()
);
assert!(position.try_unrealized_pnl(Price::new(0.0, 1)).is_err());
assert!(position.try_total_pnl(Price::new(0.0, 1)).is_err());
assert!(position.try_unrealized_pnl(Price::new(-1.0, 1)).is_err());
position.base_currency = None;
let result = position.try_notional_value(Price::from("10000.0"));
assert_eq!(
result.unwrap_err().to_string(),
"inverse position BTCUSDT.BITMEX has no base currency"
);
assert!(position.try_unrealized_pnl(Price::from("10000.0")).is_err());
}
#[rstest]
fn test_calculate_pnl_for_inverse2(ethusdt_bitmex: CryptoPerpetual) {
let ethusdt_bitmex = InstrumentAny::CryptoPerpetual(ethusdt_bitmex);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(ethusdt_bitmex.id())
.side(OrderSide::Sell)
.quantity(Quantity::from("100000"))
.build();
let commission = calculate_commission(
ðusdt_bitmex,
order.quantity(),
Price::from("375.95"),
None,
);
let fill = TestOrderEventStubs::filled(
&order,
ðusdt_bitmex,
None,
Some(PositionId::from("P-123456")),
Some(Price::from("375.95")),
None,
None,
Some(commission),
None,
None,
);
let position = Position::new(ðusdt_bitmex, fill.into());
assert_eq!(
position.unrealized_pnl(Price::from("370.00")),
Money::from("4.27745208 ETH")
);
assert_eq!(
position.notional_value(Price::from("370.00")),
Money::from("270.27027027 ETH")
);
}
#[rstest]
fn test_notional_value_for_quanto_uses_settlement_currency(ethbtc_quanto: CryptoFuture) {
let instrument = InstrumentAny::CryptoFuture(ethbtc_quanto);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(instrument.id())
.side(OrderSide::Buy)
.quantity(Quantity::from("5"))
.build();
let price = Price::from("0.03600");
let fill = TestOrderEventStubs::filled(
&order,
&instrument,
None,
Some(PositionId::from("P-QUANTO-NOTIONAL")),
Some(price),
None,
None,
None,
None,
None,
);
let position = Position::new(&instrument, fill.into());
let position_notional = position.notional_value(price);
let instrument_notional =
instrument.calculate_notional_value(position.quantity, price, None);
assert_eq!(position_notional, instrument_notional);
assert_eq!(position_notional, Money::from("0.18 USDT"));
}
#[rstest]
fn test_calculate_unrealized_pnl_for_long(currency_pair_btcusdt: CurrencyPair) {
let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
let order1 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btcusdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from("2.000000"))
.build();
let order2 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btcusdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from("2.000000"))
.build();
let commission1 =
calculate_commission(&btcusdt, order1.quantity(), Price::from("10500.0"), None);
let fill1 = TestOrderEventStubs::filled(
&order1,
&btcusdt,
Some(TradeId::new("1")),
Some(PositionId::new("P-123456")),
Some(Price::from("10500.00")),
None,
None,
Some(commission1),
None,
None,
);
let commission2 =
calculate_commission(&btcusdt, order2.quantity(), Price::from("10500.0"), None);
let fill2 = TestOrderEventStubs::filled(
&order2,
&btcusdt,
Some(TradeId::new("2")),
Some(PositionId::new("P-123456")),
Some(Price::from("10500.00")),
None,
None,
Some(commission2),
None,
None,
);
let mut position = Position::new(&btcusdt, fill1.into());
position.apply(&fill2.into());
let pnl = position.unrealized_pnl(Price::from("11505.60"));
assert_eq!(pnl, Money::from("4022.40000000 USDT"));
assert_eq!(
position.realized_pnl,
Some(Money::from("-42.00000000 USDT"))
);
assert_eq!(
position.commissions(),
vec![Money::from("42.00000000 USDT")]
);
}
#[rstest]
fn test_calculate_unrealized_pnl_for_short(currency_pair_btcusdt: CurrencyPair) {
let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btcusdt.id())
.side(OrderSide::Sell)
.quantity(Quantity::from("5.912000"))
.build();
let commission =
calculate_commission(&btcusdt, order.quantity(), Price::from("10505.60"), None);
let fill = TestOrderEventStubs::filled(
&order,
&btcusdt,
Some(TradeId::new("1")),
Some(PositionId::new("P-123456")),
Some(Price::from("10505.60")),
None,
None,
Some(commission),
None,
None,
);
let position = Position::new(&btcusdt, fill.into());
let pnl = position.unrealized_pnl(Price::from("10407.15"));
assert_eq!(pnl, Money::from("582.03640000 USDT"));
assert_eq!(
position.realized_pnl,
Some(Money::from("-62.10910720 USDT"))
);
assert_eq!(
position.commissions(),
vec![Money::from("62.10910720 USDT")]
);
}
#[rstest]
fn test_calculate_unrealized_pnl_for_long_inverse(xbtusd_bitmex: CryptoPerpetual) {
let xbtusd_bitmex = InstrumentAny::CryptoPerpetual(xbtusd_bitmex);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(xbtusd_bitmex.id())
.side(OrderSide::Buy)
.quantity(Quantity::from("100000"))
.build();
let commission = calculate_commission(
&xbtusd_bitmex,
order.quantity(),
Price::from("10500.0"),
None,
);
let fill = TestOrderEventStubs::filled(
&order,
&xbtusd_bitmex,
Some(TradeId::new("1")),
Some(PositionId::new("P-123456")),
Some(Price::from("10500.00")),
None,
None,
Some(commission),
None,
None,
);
let position = Position::new(&xbtusd_bitmex, fill.into());
let pnl = position.unrealized_pnl(Price::from("11505.60"));
assert_eq!(pnl, Money::from("0.83238969 BTC"));
assert_eq!(position.realized_pnl, Some(Money::from("-0.00714286 BTC")));
assert_eq!(position.commissions(), vec![Money::from("0.00714286 BTC")]);
}
#[rstest]
fn test_calculate_unrealized_pnl_for_short_inverse(xbtusd_bitmex: CryptoPerpetual) {
let xbtusd_bitmex = InstrumentAny::CryptoPerpetual(xbtusd_bitmex);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(xbtusd_bitmex.id())
.side(OrderSide::Sell)
.quantity(Quantity::from("1250000"))
.build();
let commission = calculate_commission(
&xbtusd_bitmex,
order.quantity(),
Price::from("15500.00"),
None,
);
let fill = TestOrderEventStubs::filled(
&order,
&xbtusd_bitmex,
Some(TradeId::new("1")),
Some(PositionId::new("P-123456")),
Some(Price::from("15500.00")),
None,
None,
Some(commission),
None,
None,
);
let position = Position::new(&xbtusd_bitmex, fill.into());
let pnl = position.unrealized_pnl(Price::from("12506.65"));
assert_eq!(pnl, Money::from("19.30166700 BTC"));
assert_eq!(position.realized_pnl, Some(Money::from("-0.06048387 BTC")));
assert_eq!(position.commissions(), vec![Money::from("0.06048387 BTC")]);
}
#[rstest]
#[case(OrderSide::Buy, 25, 25.0)]
#[case(OrderSide::Sell,25,-25.0)]
fn test_signed_qty_decimal_qty_for_equity(
#[case] order_side: OrderSide,
#[case] quantity: i64,
#[case] expected: f64,
audusd_sim: CurrencyPair,
) {
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(order_side)
.quantity(Quantity::from(quantity))
.build();
let commission =
calculate_commission(&audusd_sim, order.quantity(), Price::from("1.0"), None);
let fill = TestOrderEventStubs::filled(
&order,
&audusd_sim,
None,
Some(PositionId::from("P-123456")),
None,
None,
None,
Some(commission),
None,
None,
);
let position = Position::new(&audusd_sim, fill.into());
assert_eq!(position.signed_qty, expected);
}
#[rstest]
fn test_position_with_commission_none(audusd_sim: CurrencyPair) {
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let fill = OrderFilledSpec::builder()
.position_id(PositionId::from("1"))
.build();
let position = Position::new(&audusd_sim, fill);
assert_eq!(position.realized_pnl, Some(Money::from("0 USD")));
}
#[rstest]
fn test_position_with_commission_zero(audusd_sim: CurrencyPair) {
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let fill = OrderFilledSpec::builder()
.position_id(PositionId::from("1"))
.commission(Money::from("0 USD"))
.build();
let position = Position::new(&audusd_sim, fill);
assert_eq!(position.realized_pnl, Some(Money::from("0 USD")));
}
#[rstest]
fn test_cache_purge_order_events() {
let audusd_sim = audusd_sim();
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let order1 = OrderTestBuilder::new(OrderType::Market)
.client_order_id(ClientOrderId::new("O-1"))
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(50_000))
.build();
let order2 = OrderTestBuilder::new(OrderType::Market)
.client_order_id(ClientOrderId::new("O-2"))
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(50_000))
.build();
let position_id = PositionId::new("P-123456");
let fill1 = TestOrderEventStubs::filled(
&order1,
&audusd_sim,
Some(TradeId::new("1")),
Some(position_id),
Some(Price::from("1.00001")),
None,
None,
None,
None,
None,
);
let mut position = Position::new(&audusd_sim, fill1.into());
let fill2 = TestOrderEventStubs::filled(
&order2,
&audusd_sim,
Some(TradeId::new("2")),
Some(position_id),
Some(Price::from("1.00002")),
None,
None,
None,
None,
None,
);
position.apply(&fill2.into());
position.purge_events_for_order(order1.client_order_id());
assert_eq!(position.events.len(), 1);
assert_eq!(position.trade_ids.len(), 1);
assert_eq!(position.events[0].client_order_id, order2.client_order_id());
assert!(position.trade_ids.contains(&TradeId::new("2")));
}
#[rstest]
fn test_purge_all_events_returns_none_for_last_event_and_trade_id() {
let audusd_sim = audusd_sim();
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let order = OrderTestBuilder::new(OrderType::Market)
.client_order_id(ClientOrderId::new("O-1"))
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(100_000))
.build();
let position_id = PositionId::new("P-123456");
let fill = TestOrderEventStubs::filled(
&order,
&audusd_sim,
Some(TradeId::new("1")),
Some(position_id),
Some(Price::from("1.00050")),
None,
None,
None,
Some(UnixNanos::from(1_000_000_000)), None,
);
let mut position = Position::new(&audusd_sim, fill.into());
assert_eq!(position.events.len(), 1);
assert!(position.last_event().is_some());
assert!(position.last_trade_id().is_some());
let original_ts_opened = position.ts_opened;
let original_ts_last = position.ts_last;
assert_ne!(original_ts_opened, UnixNanos::default());
assert_ne!(original_ts_last, UnixNanos::default());
position.purge_events_for_order(order.client_order_id());
assert_eq!(position.events.len(), 0);
assert_eq!(position.trade_ids.len(), 0);
assert!(position.last_event().is_none());
assert!(position.last_trade_id().is_none());
assert_eq!(position.ts_opened, UnixNanos::default());
assert_eq!(position.ts_last, UnixNanos::default());
assert_eq!(position.ts_closed, Some(UnixNanos::default()));
assert_eq!(position.duration_ns, 0);
assert!(position.is_closed());
assert!(!position.is_open());
assert_eq!(position.side, PositionSide::Flat);
}
#[rstest]
fn test_revive_from_empty_shell(audusd_sim: CurrencyPair) {
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let order1 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(100_000))
.build();
let fill1 = TestOrderEventStubs::filled(
&order1,
&audusd_sim,
None,
Some(PositionId::new("P-1")),
Some(Price::from("1.00000")),
None,
None,
None,
Some(UnixNanos::from(1_000_000_000)),
None,
);
let mut position = Position::new(&audusd_sim, fill1.into());
position.purge_events_for_order(order1.client_order_id());
assert!(position.is_closed());
assert_eq!(position.ts_closed, Some(UnixNanos::default()));
assert_eq!(position.event_count(), 0);
let order2 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(50_000))
.build();
let fill2 = TestOrderEventStubs::filled(
&order2,
&audusd_sim,
None,
Some(PositionId::new("P-1")),
Some(Price::from("1.00020")),
None,
None,
None,
Some(UnixNanos::from(3_000_000_000)),
None,
);
let fill2_typed: OrderFilled = fill2.clone().into();
position.apply(&fill2_typed);
assert!(position.is_long());
assert!(!position.is_closed());
assert!(position.ts_closed.is_none());
assert_eq!(position.ts_opened, fill2.ts_event());
assert_eq!(position.ts_last, fill2.ts_event());
assert_eq!(position.event_count(), 1);
assert_eq!(position.quantity, Quantity::from(50_000));
}
#[rstest]
fn test_empty_shell_position_invariants(audusd_sim: CurrencyPair) {
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(100_000))
.build();
let fill = TestOrderEventStubs::filled(
&order,
&audusd_sim,
None,
Some(PositionId::new("P-1")),
Some(Price::from("1.00000")),
None,
None,
None,
Some(UnixNanos::from(1_000_000_000)),
None,
);
let mut position = Position::new(&audusd_sim, fill.into());
position.purge_events_for_order(order.client_order_id());
assert_eq!(
position.event_count(),
0,
"Precondition: event_count must be 0"
);
assert!(
position.is_closed(),
"INV1: Empty shell must report is_closed() == true"
);
assert!(
!position.is_open(),
"INV1: Empty shell must report is_open() == false"
);
assert_eq!(
position.side,
PositionSide::Flat,
"INV2: Empty shell must be FLAT"
);
assert!(
position.ts_closed.is_some(),
"INV3: Empty shell must have ts_closed.is_some()"
);
assert_eq!(
position.ts_closed,
Some(UnixNanos::default()),
"INV3: Empty shell ts_closed must be 0"
);
assert_eq!(
position.ts_opened,
UnixNanos::default(),
"INV4: Empty shell ts_opened must be 0"
);
assert_eq!(
position.ts_last,
UnixNanos::default(),
"INV4: Empty shell ts_last must be 0"
);
assert_eq!(
position.duration_ns, 0,
"INV4: Empty shell duration_ns must be 0"
);
assert_eq!(
position.quantity,
Quantity::zero(audusd_sim.size_precision()),
"INV5: Empty shell quantity must be 0"
);
assert!(
position.events.is_empty(),
"INV6: Empty shell must have no events"
);
assert!(
position.trade_ids.is_empty(),
"INV6: Empty shell must have no trade IDs"
);
assert!(
position.last_event().is_none(),
"INV6: Empty shell must have no last event"
);
assert!(
position.last_trade_id().is_none(),
"INV6: Empty shell must have no last trade ID"
);
}
#[rstest]
fn test_position_pnl_precision_with_very_small_amounts(audusd_sim: CurrencyPair) {
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(100))
.build();
let small_commission = Money::new(0.01, Currency::USD());
let fill = TestOrderEventStubs::filled(
&order,
&audusd_sim,
None,
None,
Some(Price::from("1.00001")),
Some(Quantity::from(100)),
None,
Some(small_commission),
None,
None,
);
let position = Position::new(&audusd_sim, fill.into());
assert_eq!(position.commissions().len(), 1);
let recorded_commission = position.commissions()[0];
assert!(
recorded_commission.as_f64() > 0.0,
"Commission of 0.01 should be preserved"
);
let realized = position.realized_pnl.unwrap().as_f64();
assert!(
realized < 0.0,
"Realized PnL should be negative due to commission"
);
}
#[rstest]
fn test_position_pnl_precision_with_high_precision_instrument() {
use crate::instruments::stubs::crypto_perpetual_ethusdt;
let ethusdt = crypto_perpetual_ethusdt();
let ethusdt = InstrumentAny::CryptoPerpetual(ethusdt);
let size_precision = ethusdt.size_precision();
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(ethusdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from("1.123456789"))
.build();
let fill = TestOrderEventStubs::filled(
&order,
ðusdt,
None,
None,
Some(Price::from("2345.123456789")),
Some(Quantity::from("1.123456789")),
None,
Some(Money::from("0.1 USDT")),
None,
None,
);
let position = Position::new(ðusdt, fill.into());
let avg_px = position.avg_px_open;
assert!(
(avg_px - 2_345.123_456_789).abs() < 1e-6,
"High precision price should be preserved within f64 tolerance"
);
assert_eq!(
position.quantity.precision, size_precision,
"Quantity precision should match instrument"
);
let qty_f64 = position.quantity.as_f64();
assert!(
qty_f64 > 1.0 && qty_f64 < 2.0,
"Quantity should be in expected range"
);
}
#[rstest]
fn test_position_pnl_accumulation_across_many_fills(audusd_sim: CurrencyPair) {
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(1000))
.build();
let initial_fill = TestOrderEventStubs::filled(
&order,
&audusd_sim,
Some(TradeId::new("1")),
None,
Some(Price::from("1.00000")),
Some(Quantity::from(10)),
None,
Some(Money::from("0.01 USD")),
None,
None,
);
let mut position = Position::new(&audusd_sim, initial_fill.into());
for i in 2..=100 {
let price_offset = f64::from(i) * 0.00001;
let fill = TestOrderEventStubs::filled(
&order,
&audusd_sim,
Some(TradeId::new(i.to_string())),
None,
Some(Price::from(&format!("{:.5}", 1.0 + price_offset))),
Some(Quantity::from(10)),
None,
Some(Money::from("0.01 USD")),
None,
None,
);
position.apply(&fill.into());
}
assert_eq!(position.events.len(), 100);
assert_eq!(position.quantity, Quantity::from(1000));
let total_commission: f64 = position.commissions().iter().map(|c| c.as_f64()).sum();
assert!(
(total_commission - 1.0).abs() < 1e-10,
"Commission accumulation should be accurate: expected 1.0, was {total_commission}"
);
let avg_px = position.avg_px_open;
assert!(
avg_px > 1.0 && avg_px < 1.001,
"Average price should be reasonable: got {avg_px}"
);
}
#[rstest]
fn test_position_pnl_with_extreme_price_values(audusd_sim: CurrencyPair) {
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let order_small = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(100_000))
.build();
let fill_small = TestOrderEventStubs::filled(
&order_small,
&audusd_sim,
None,
None,
Some(Price::from("0.00001")),
Some(Quantity::from(100_000)),
None,
None,
None,
None,
);
let position_small = Position::new(&audusd_sim, fill_small.into());
assert_eq!(position_small.avg_px_open, 0.00001);
let last_price_small = Price::from("0.00002");
let unrealized = position_small.unrealized_pnl(last_price_small);
assert!(
unrealized.as_f64() > 0.0,
"Unrealized PnL should be positive when price doubles"
);
let order_large = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(100))
.build();
let fill_large = TestOrderEventStubs::filled(
&order_large,
&audusd_sim,
None,
None,
Some(Price::from("99999.99999")),
Some(Quantity::from(100)),
None,
None,
None,
None,
);
let position_large = Position::new(&audusd_sim, fill_large.into());
assert!(
(position_large.avg_px_open - 99999.99999).abs() < 1e-6,
"Large price should be preserved within f64 tolerance"
);
}
#[rstest]
fn test_position_pnl_roundtrip_precision(audusd_sim: CurrencyPair) {
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let buy_order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(100_000))
.build();
let sell_order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Sell)
.quantity(Quantity::from(100_000))
.build();
let open_fill = TestOrderEventStubs::filled(
&buy_order,
&audusd_sim,
Some(TradeId::new("1")),
None,
Some(Price::from("1.123456")),
None,
None,
Some(Money::from("0.50 USD")),
None,
None,
);
let mut position = Position::new(&audusd_sim, open_fill.into());
let close_fill = TestOrderEventStubs::filled(
&sell_order,
&audusd_sim,
Some(TradeId::new("2")),
None,
Some(Price::from("1.123456")),
None,
None,
Some(Money::from("0.50 USD")),
None,
None,
);
position.apply(&close_fill.into());
assert!(position.is_closed());
let realized = position.realized_pnl.unwrap().as_f64();
assert!(
(realized - (-1.0)).abs() < 1e-10,
"Realized PnL should be exactly -1.0 USD (commissions), was {realized}"
);
}
#[rstest]
fn test_position_commission_in_base_currency_buy() {
let btc_usdt = currency_pair_btcusdt();
let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btc_usdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from("1.0"))
.build();
let fill = match TestOrderEventStubs::filled(
&order,
&btc_usdt,
Some(TradeId::new("1")),
None,
Some(Price::from("50000.0")),
Some(Quantity::from("1.0")),
None,
Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
None,
None,
) {
OrderEventAny::Filled(fill) => fill,
_ => unreachable!(),
};
let position = Position::new(&btc_usdt, fill.clone());
let replayed_position = Position::new(&btc_usdt, fill);
assert!(
(position.quantity.as_f64() - 0.999).abs() < 1e-9,
"Position quantity should be 0.999 BTC (1.0 - 0.001 commission), was {}",
position.quantity.as_f64()
);
assert!(
(position.signed_qty - 0.999).abs() < 1e-9,
"Signed qty should be 0.999, was {}",
position.signed_qty
);
assert_eq!(
position.adjustments.len(),
1,
"Should have 1 adjustment event"
);
let adjustment = &position.adjustments[0];
assert_eq!(
adjustment.adjustment_type,
PositionAdjustmentType::Commission
);
assert_eq!(
adjustment.quantity_change,
Some(rust_decimal_macros::dec!(-0.001))
);
assert_eq!(adjustment.pnl_change, None);
assert_eq!(
adjustment.event_id,
replayed_position.adjustments[0].event_id
);
}
#[rstest]
fn test_position_commission_in_base_currency_sell() {
let btc_usdt = currency_pair_btcusdt();
let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btc_usdt.id())
.side(OrderSide::Sell)
.quantity(Quantity::from("1.0"))
.build();
let fill = TestOrderEventStubs::filled(
&order,
&btc_usdt,
Some(TradeId::new("1")),
None,
Some(Price::from("50000.0")),
Some(Quantity::from("1.0")),
None,
Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
None,
None,
);
let position = Position::new(&btc_usdt, fill.into());
assert!(
(position.quantity.as_f64() - 1.001).abs() < 1e-9,
"Position quantity should be 1.001 BTC (1.0 + 0.001 commission), was {}",
position.quantity.as_f64()
);
assert!(
(position.signed_qty - (-1.001)).abs() < 1e-9,
"Signed qty should be -1.001, was {}",
position.signed_qty
);
assert_eq!(
position.adjustments.len(),
1,
"Should have 1 adjustment event"
);
let adjustment = &position.adjustments[0];
assert_eq!(
adjustment.adjustment_type,
PositionAdjustmentType::Commission
);
assert_eq!(
adjustment.quantity_change,
Some(rust_decimal_macros::dec!(-0.001))
);
assert_eq!(adjustment.pnl_change, None);
}
#[rstest]
fn test_position_commission_in_quote_currency_no_adjustment() {
let btc_usdt = currency_pair_btcusdt();
let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btc_usdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from("1.0"))
.build();
let fill = TestOrderEventStubs::filled(
&order,
&btc_usdt,
Some(TradeId::new("1")),
None,
Some(Price::from("50000.0")),
Some(Quantity::from("1.0")),
None,
Some(Money::new(50.0, Currency::USD())),
None,
None,
);
let position = Position::new(&btc_usdt, fill.into());
assert!(
(position.quantity.as_f64() - 1.0).abs() < 1e-9,
"Position quantity should be 1.0 BTC (no adjustment for quote currency commission), was {}",
position.quantity.as_f64()
);
assert_eq!(
position.adjustments.len(),
0,
"Should have no adjustment events for quote currency commission"
);
}
#[rstest]
fn test_position_reset_clears_adjustments() {
let btc_usdt = currency_pair_btcusdt();
let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
let buy_order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btc_usdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from("1.0"))
.build();
let buy_fill = TestOrderEventStubs::filled(
&buy_order,
&btc_usdt,
Some(TradeId::new("1")),
None,
Some(Price::from("50000.0")),
Some(Quantity::from("1.0")),
None,
Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
None,
None,
);
let mut position = Position::new(&btc_usdt, buy_fill.into());
assert_eq!(position.adjustments.len(), 1, "Should have 1 adjustment");
let sell_order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btc_usdt.id())
.side(OrderSide::Sell)
.quantity(Quantity::from("0.999"))
.build();
let sell_fill = TestOrderEventStubs::filled(
&sell_order,
&btc_usdt,
Some(TradeId::new("2")),
None,
Some(Price::from("51000.0")),
Some(Quantity::from("0.999")),
None,
Some(Money::new(50.0, Currency::USD())), None,
None,
);
position.apply(&sell_fill.into());
assert_eq!(position.side, PositionSide::Flat);
assert_eq!(
position.adjustments.len(),
1,
"Should still have 1 adjustment (no new one from quote commission)"
);
let buy_order2 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btc_usdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from("2.0"))
.build();
let buy_fill2 = TestOrderEventStubs::filled(
&buy_order2,
&btc_usdt,
Some(TradeId::new("3")),
None,
Some(Price::from("52000.0")),
Some(Quantity::from("2.0")),
None,
Some(Money::new(0.002, btc_usdt.base_currency().unwrap())),
None,
None,
);
position.apply(&buy_fill2.into());
assert_eq!(
position.adjustments.len(),
1,
"Adjustments should be cleared on position reset, only new adjustment"
);
assert_eq!(
position.adjustments[0].quantity_change,
Some(rust_decimal_macros::dec!(-0.002)),
"New adjustment should be for the new fill"
);
assert_eq!(position.events.len(), 1, "Events should also be reset");
}
#[rstest]
fn test_purge_events_for_order_clears_adjustments_when_flat() {
let btc_usdt = currency_pair_btcusdt();
let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btc_usdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from("1.0"))
.build();
let fill = TestOrderEventStubs::filled(
&order,
&btc_usdt,
Some(TradeId::new("1")),
None,
Some(Price::from("50000.0")),
Some(Quantity::from("1.0")),
None,
Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
None,
None,
);
let mut position = Position::new(&btc_usdt, fill.into());
assert_eq!(position.adjustments.len(), 1, "Should have 1 adjustment");
assert_eq!(position.events.len(), 1);
position.purge_events_for_order(order.client_order_id());
assert_eq!(position.side, PositionSide::Flat);
assert_eq!(position.events.len(), 0, "Events should be cleared");
assert_eq!(
position.adjustments.len(),
0,
"Adjustments should be cleared when position goes flat"
);
assert_eq!(position.quantity, Quantity::zero(btc_usdt.size_precision()));
}
#[rstest]
fn test_purge_events_for_order_clears_adjustments_on_rebuild() {
let btc_usdt = currency_pair_btcusdt();
let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
let order1 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btc_usdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from("1.0"))
.client_order_id(ClientOrderId::new("O-001"))
.build();
let fill1 = TestOrderEventStubs::filled(
&order1,
&btc_usdt,
Some(TradeId::new("1")),
None,
Some(Price::from("50000.0")),
Some(Quantity::from("1.0")),
None,
Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
None,
None,
);
let mut position = Position::new(&btc_usdt, fill1.into());
assert_eq!(position.adjustments.len(), 1);
let order2 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btc_usdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from("2.0"))
.client_order_id(ClientOrderId::new("O-002"))
.build();
let fill2 = TestOrderEventStubs::filled(
&order2,
&btc_usdt,
Some(TradeId::new("2")),
None,
Some(Price::from("51000.0")),
Some(Quantity::from("2.0")),
None,
Some(Money::new(0.002, btc_usdt.base_currency().unwrap())),
None,
None,
);
position.apply(&fill2.into());
assert_eq!(position.adjustments.len(), 2, "Should have 2 adjustments");
assert_eq!(position.events.len(), 2);
position.purge_events_for_order(order1.client_order_id());
assert_eq!(position.events.len(), 1, "Should have 1 remaining event");
assert_eq!(
position.adjustments.len(),
1,
"Should have only the adjustment from remaining fill"
);
assert_eq!(
position.adjustments[0].quantity_change,
Some(rust_decimal_macros::dec!(-0.002)),
"Should be the adjustment from order2"
);
assert!(
(position.quantity.as_f64() - 1.998).abs() < 1e-9,
"Quantity should be 2.0 - 0.002 commission"
);
}
#[rstest]
fn test_purge_events_preserves_manual_adjustments() {
let btc_usdt = currency_pair_btcusdt();
let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
let order1 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btc_usdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from("1.0"))
.client_order_id(ClientOrderId::new("O-001"))
.build();
let fill1 = TestOrderEventStubs::filled(
&order1,
&btc_usdt,
Some(TradeId::new("1")),
None,
Some(Price::from("50000.0")),
Some(Quantity::from("1.0")),
None,
Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
None,
None,
);
let mut position = Position::new(&btc_usdt, fill1.into());
assert_eq!(position.adjustments.len(), 1);
let funding_adjustment = PositionAdjusted::new(
position.trader_id,
position.strategy_id,
position.instrument_id,
position.id,
position.account_id,
PositionAdjustmentType::Funding,
None,
Some(Money::new(10.0, btc_usdt.quote_currency())),
None, uuid4(),
UnixNanos::default(),
UnixNanos::default(),
);
position.apply_adjustment(funding_adjustment);
assert_eq!(position.adjustments.len(), 2);
let order2 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btc_usdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from("2.0"))
.client_order_id(ClientOrderId::new("O-002"))
.build();
let fill2 = TestOrderEventStubs::filled(
&order2,
&btc_usdt,
Some(TradeId::new("2")),
None,
Some(Price::from("51000.0")),
Some(Quantity::from("2.0")),
None,
Some(Money::new(0.002, btc_usdt.base_currency().unwrap())),
None,
None,
);
position.apply(&fill2.into());
assert_eq!(
position.adjustments.len(),
3,
"Should have 3 adjustments: 2 commissions + 1 funding"
);
position.purge_events_for_order(order1.client_order_id());
assert_eq!(position.events.len(), 1, "Should have 1 remaining event");
assert_eq!(
position.adjustments.len(),
2,
"Should have funding adjustment + commission from remaining fill"
);
let has_funding = position.adjustments.iter().any(|adj| {
adj.adjustment_type == PositionAdjustmentType::Funding
&& adj.pnl_change == Some(Money::new(10.0, btc_usdt.quote_currency()))
});
assert!(has_funding, "Funding adjustment should be preserved");
assert_eq!(
position.realized_pnl,
Some(Money::new(10.0, btc_usdt.quote_currency())),
"Realized PnL should be the funding payment only (commission is in BTC, not USDT)"
);
}
#[rstest]
fn test_position_commission_affects_buy_and_sell_qty() {
let btc_usdt = currency_pair_btcusdt();
let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
let buy_order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btc_usdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from("1.0"))
.build();
let fill = TestOrderEventStubs::filled(
&buy_order,
&btc_usdt,
Some(TradeId::new("1")),
None,
Some(Price::from("50000.0")),
Some(Quantity::from("1.0")),
None,
Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
None,
None,
);
let position = Position::new(&btc_usdt, fill.into());
assert!(
(position.buy_qty.as_f64() - 1.0).abs() < 1e-9,
"buy_qty should be 1.0 (order fill amount), was {}",
position.buy_qty.as_f64()
);
assert!(
(position.quantity.as_f64() - 0.999).abs() < 1e-9,
"position.quantity should be 0.999 (1.0 - 0.001 commission), was {}",
position.quantity.as_f64()
);
assert_eq!(position.adjustments.len(), 1);
assert_eq!(
position.adjustments[0].quantity_change,
Some(rust_decimal_macros::dec!(-0.001))
);
}
#[rstest]
fn test_position_perpetual_commission_no_adjustment() {
let eth_perp = crypto_perpetual_ethusdt();
let eth_perp = InstrumentAny::CryptoPerpetual(eth_perp);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(eth_perp.id())
.side(OrderSide::Buy)
.quantity(Quantity::from("1.0"))
.build();
let fill = TestOrderEventStubs::filled(
&order,
ð_perp,
Some(TradeId::new("1")),
None,
Some(Price::from("3000.0")),
Some(Quantity::from("1.0")),
None,
Some(Money::new(0.001, eth_perp.base_currency().unwrap())),
None,
None,
);
let position = Position::new(ð_perp, fill.into());
assert!(
(position.quantity.as_f64() - 1.0).abs() < 1e-9,
"Perpetual position should be 1.0 contracts (no adjustment), was {}",
position.quantity.as_f64()
);
assert!(
(position.signed_qty - 1.0).abs() < 1e-9,
"Signed qty should be 1.0, was {}",
position.signed_qty
);
}
#[rstest]
fn test_signed_decimal_qty_long(stub_position_long: Position) {
let signed_qty = stub_position_long.signed_decimal_qty();
assert!(signed_qty > Decimal::ZERO);
assert_eq!(
signed_qty,
Decimal::try_from(stub_position_long.signed_qty).unwrap()
);
}
#[rstest]
fn test_signed_decimal_qty_short(stub_position_short: Position) {
let signed_qty = stub_position_short.signed_decimal_qty();
assert!(signed_qty < Decimal::ZERO);
assert_eq!(
signed_qty,
Decimal::try_from(stub_position_short.signed_qty).unwrap()
);
}
#[rstest]
fn test_signed_decimal_qty_flat(audusd_sim: CurrencyPair) {
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(100_000))
.build();
let fill = TestOrderEventStubs::filled(
&order,
&audusd_sim,
Some(TradeId::new("1")),
None,
Some(Price::from("1.00001")),
None,
None,
None,
None,
None,
);
let mut position = Position::new(&audusd_sim, fill.into());
let close_order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Sell)
.quantity(Quantity::from(100_000))
.build();
let close_fill = TestOrderEventStubs::filled(
&close_order,
&audusd_sim,
Some(TradeId::new("2")),
None,
Some(Price::from("1.00002")),
None,
None,
None,
None,
None,
);
position.apply(&close_fill.into());
assert_eq!(position.side, PositionSide::Flat);
assert_eq!(position.signed_decimal_qty(), Decimal::ZERO);
}
#[rstest]
fn test_position_flat_with_floating_point_precision_edge_case() {
let btc_usdt = currency_pair_btcusdt();
let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
let order1 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btc_usdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from("0.123456789"))
.build();
let fill1 = TestOrderEventStubs::filled(
&order1,
&btc_usdt,
Some(TradeId::new("1")),
None,
Some(Price::from("50000.00")),
None,
None,
None,
None,
None,
);
let mut position = Position::new(&btc_usdt, fill1.into());
assert_eq!(position.side, PositionSide::Long);
assert!(position.quantity.is_positive());
let order2 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btc_usdt.id())
.side(OrderSide::Sell)
.quantity(Quantity::from("0.123456789"))
.build();
let fill2 = TestOrderEventStubs::filled(
&order2,
&btc_usdt,
Some(TradeId::new("2")),
None,
Some(Price::from("50000.00")),
None,
None,
None,
None,
None,
);
position.apply(&fill2.into());
assert_eq!(
position.side,
PositionSide::Flat,
"Position should be FLAT, not {:?}",
position.side
);
assert!(
position.quantity.is_zero(),
"Quantity should be zero, was {}",
position.quantity
);
assert_eq!(
position.signed_qty, 0.0,
"signed_qty should be normalized to 0.0, was {}",
position.signed_qty
);
assert!(position.is_closed());
}
#[rstest]
fn test_position_adjustment_floating_point_precision_edge_case() {
let btc_usdt = currency_pair_btcusdt();
let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btc_usdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from("1.0"))
.build();
let fill = TestOrderEventStubs::filled(
&order,
&btc_usdt,
Some(TradeId::new("1")),
None,
Some(Price::from("50000.00")),
None,
None,
None,
None,
None,
);
let mut position = Position::new(&btc_usdt, fill.into());
let adjustment = PositionAdjusted::new(
position.trader_id,
position.strategy_id,
position.instrument_id,
position.id,
position.account_id,
PositionAdjustmentType::Commission,
Some(Decimal::from_str("-1.0").unwrap()),
None,
None,
uuid4(),
UnixNanos::default(),
UnixNanos::default(),
);
position.apply_adjustment(adjustment);
assert_eq!(
position.side,
PositionSide::Flat,
"Position should be FLAT after zeroing adjustment"
);
assert!(
position.quantity.is_zero(),
"Quantity should be zero after adjustment"
);
assert_eq!(
position.signed_qty, 0.0,
"signed_qty should be normalized to 0.0"
);
}
#[rstest]
fn test_position_spot_buy_partial_fills_with_base_commission() {
let eth_usdt = currency_pair_ethusdt();
let eth_usdt = InstrumentAny::CurrencyPair(eth_usdt);
let order1 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(eth_usdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from("0.00350"))
.build();
let fill1 = TestOrderEventStubs::filled(
&order1,
ð_usdt,
Some(TradeId::new("1")),
None,
Some(Price::from("2042.69")),
Some(Quantity::from("0.00350")),
None,
Some(Money::new(0.00001, eth_usdt.base_currency().unwrap())),
None,
None,
);
let mut position = Position::new(ð_usdt, fill1.into());
assert_eq!(position.quantity, Quantity::from("0.00349"));
assert!((position.signed_qty - 0.00349).abs() < 1e-9);
assert_eq!(position.side, PositionSide::Long);
assert_eq!(position.adjustments.len(), 1);
assert_eq!(
position.adjustments[0].quantity_change,
Some(rust_decimal_macros::dec!(-0.00001))
);
let order2 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(eth_usdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from("0.00350"))
.build();
let fill2 = TestOrderEventStubs::filled(
&order2,
ð_usdt,
Some(TradeId::new("2")),
None,
Some(Price::from("2042.69")),
Some(Quantity::from("0.00350")),
None,
Some(Money::new(0.00001, eth_usdt.base_currency().unwrap())),
None,
None,
);
position.apply(&fill2.into());
assert_eq!(position.quantity, Quantity::from("0.00698"));
assert!((position.signed_qty - 0.00698).abs() < 1e-9);
assert_eq!(position.adjustments.len(), 2);
let order3 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(eth_usdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from("0.00300"))
.build();
let fill3 = TestOrderEventStubs::filled(
&order3,
ð_usdt,
Some(TradeId::new("3")),
None,
Some(Price::from("2042.69")),
Some(Quantity::from("0.00300")),
None,
Some(Money::new(0.00001, eth_usdt.base_currency().unwrap())),
None,
None,
);
position.apply(&fill3.into());
assert_eq!(position.quantity, Quantity::from("0.00997"));
assert!((position.signed_qty - 0.00997).abs() < 1e-9);
assert_eq!(position.side, PositionSide::Long);
assert_eq!(position.adjustments.len(), 3);
assert_eq!(position.buy_qty, Quantity::from("0.01000"));
}
#[rstest]
fn test_position_spot_sell_partial_fills_with_base_commission() {
let btc_usdt = currency_pair_btcusdt();
let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
let order1 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btc_usdt.id())
.side(OrderSide::Sell)
.quantity(Quantity::from("0.5"))
.build();
let fill1 = TestOrderEventStubs::filled(
&order1,
&btc_usdt,
Some(TradeId::new("1")),
None,
Some(Price::from("50000.0")),
Some(Quantity::from("0.5")),
None,
Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
None,
None,
);
let mut position = Position::new(&btc_usdt, fill1.into());
assert!((position.signed_qty - (-0.501)).abs() < 1e-9);
assert_eq!(position.side, PositionSide::Short);
assert_eq!(position.adjustments.len(), 1);
let order2 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(btc_usdt.id())
.side(OrderSide::Sell)
.quantity(Quantity::from("0.5"))
.build();
let fill2 = TestOrderEventStubs::filled(
&order2,
&btc_usdt,
Some(TradeId::new("2")),
None,
Some(Price::from("50000.0")),
Some(Quantity::from("0.5")),
None,
Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
None,
None,
);
position.apply(&fill2.into());
assert!((position.signed_qty - (-1.002)).abs() < 1e-9);
assert!((position.quantity.as_f64() - 1.002).abs() < 1e-9);
assert_eq!(position.adjustments.len(), 2);
assert_eq!(position.sell_qty, Quantity::from("1.0"));
}
#[rstest]
fn test_position_spot_round_trip_close_flat_with_quote_commission() {
let eth_usdt = currency_pair_ethusdt();
let eth_usdt = InstrumentAny::CurrencyPair(eth_usdt);
let buy_order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(eth_usdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from("1.00000"))
.build();
let buy_fill = TestOrderEventStubs::filled(
&buy_order,
ð_usdt,
Some(TradeId::new("1")),
None,
Some(Price::from("2000.00")),
Some(Quantity::from("1.00000")),
None,
Some(Money::new(0.001, eth_usdt.base_currency().unwrap())),
None,
None,
);
let mut position = Position::new(ð_usdt, buy_fill.into());
assert_eq!(position.quantity, Quantity::from("0.99900"));
assert_eq!(position.side, PositionSide::Long);
let sell_order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(eth_usdt.id())
.side(OrderSide::Sell)
.quantity(Quantity::from("0.99900"))
.build();
let sell_fill = TestOrderEventStubs::filled(
&sell_order,
ð_usdt,
Some(TradeId::new("2")),
None,
Some(Price::from("2100.00")),
Some(Quantity::from("0.99900")),
None,
Some(Money::new(2.0, Currency::USDT())),
None,
None,
);
position.apply(&sell_fill.into());
assert_eq!(position.side, PositionSide::Flat);
assert_eq!(position.signed_qty, 0.0);
assert!(position.is_closed());
assert_eq!(position.adjustments.len(), 1);
let realized = position.realized_pnl.unwrap().as_f64();
assert!(
(realized - 97.9).abs() < 0.01,
"Realized PnL should be ~97.90 USDT, was {realized}"
);
}
#[rstest]
fn test_position_spot_commission_accumulation_multiple_partial_fills() {
let eth_usdt = currency_pair_ethusdt();
let eth_usdt = InstrumentAny::CurrencyPair(eth_usdt);
let order1 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(eth_usdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from("0.50000"))
.build();
let fill1 = TestOrderEventStubs::filled(
&order1,
ð_usdt,
Some(TradeId::new("1")),
None,
Some(Price::from("2000.00")),
Some(Quantity::from("0.50000")),
None,
Some(Money::new(0.0005, eth_usdt.base_currency().unwrap())),
None,
None,
);
let mut position = Position::new(ð_usdt, fill1.into());
let order2 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(eth_usdt.id())
.side(OrderSide::Buy)
.quantity(Quantity::from("0.50000"))
.build();
let fill2 = TestOrderEventStubs::filled(
&order2,
ð_usdt,
Some(TradeId::new("2")),
None,
Some(Price::from("2010.00")),
Some(Quantity::from("0.50000")),
None,
Some(Money::new(0.0005, eth_usdt.base_currency().unwrap())),
None,
None,
);
position.apply(&fill2.into());
assert_eq!(position.quantity, Quantity::from("0.99900"));
assert_eq!(position.buy_qty, Quantity::from("1.00000"));
assert_eq!(position.adjustments.len(), 2);
for adj in &position.adjustments {
assert_eq!(adj.adjustment_type, PositionAdjustmentType::Commission);
assert_eq!(
adj.quantity_change,
Some(rust_decimal_macros::dec!(-0.0005))
);
}
let commissions = position.commissions();
assert_eq!(commissions.len(), 1);
let eth_commission = commissions[0];
assert!(
(eth_commission.as_f64() - 0.001).abs() < 1e-9,
"Total ETH commission should be 0.001, was {}",
eth_commission.as_f64()
);
}
#[rstest]
fn test_position_apply_fill_with_earlier_timestamp_adjusts_ts_opened(audusd_sim: CurrencyPair) {
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let order1 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(100_000))
.build();
let order2 = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(100_000))
.build();
let fill1 = TestOrderEventStubs::filled(
&order1,
&audusd_sim,
Some(TradeId::new("t1")),
None,
Some(Price::from("1.00001")),
None,
None,
None,
Some(UnixNanos::from(2_000u64)),
None,
);
let mut position = Position::new(&audusd_sim, fill1.into());
assert_eq!(position.ts_opened, UnixNanos::from(2_000u64));
let fill2 = TestOrderEventStubs::filled(
&order2,
&audusd_sim,
Some(TradeId::new("t2")),
None,
Some(Price::from("1.00002")),
None,
None,
None,
Some(UnixNanos::from(1_000u64)),
None,
);
position.apply(&fill2.into());
assert_eq!(position.ts_opened, UnixNanos::from(2_000u64));
assert_eq!(position.opening_order_id, order1.client_order_id());
assert_eq!(position.events.len(), 2);
}
#[rstest]
fn test_position_close_before_open_clamps_duration(audusd_sim: CurrencyPair) {
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let opening_order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(100_000))
.build();
let closing_order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Sell)
.quantity(Quantity::from(100_000))
.build();
let opening_fill = TestOrderEventStubs::filled(
&opening_order,
&audusd_sim,
Some(TradeId::new("OPEN")),
None,
Some(Price::from("1.00001")),
None,
None,
None,
Some(UnixNanos::from(2_000u64)),
None,
);
let closing_fill = TestOrderEventStubs::filled(
&closing_order,
&audusd_sim,
Some(TradeId::new("CLOSE")),
None,
Some(Price::from("1.00002")),
None,
None,
None,
Some(UnixNanos::from(1_000u64)),
None,
);
let mut position = Position::new(&audusd_sim, opening_fill.into());
position.apply(&closing_fill.into());
assert_eq!(position.side, PositionSide::Flat);
assert_eq!(position.ts_opened, UnixNanos::from(2_000u64));
assert_eq!(position.ts_closed, Some(UnixNanos::from(1_000u64)));
assert_eq!(position.duration_ns, 0);
assert_eq!(
position.closing_order_id,
Some(closing_order.client_order_id())
);
}
#[rstest]
fn test_position_commissions_multi_currency_insertion_order(audusd_sim: CurrencyPair) {
let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
let order_template = OrderTestBuilder::new(OrderType::Market)
.instrument_id(audusd_sim.id())
.side(OrderSide::Buy)
.quantity(Quantity::from(100_000))
.build();
let fill_usd = TestOrderEventStubs::filled(
&order_template,
&audusd_sim,
Some(TradeId::new("t1")),
None,
Some(Price::from("1.00001")),
None,
None,
Some(Money::from("1.0 USD")),
None,
None,
);
let mut position = Position::new(&audusd_sim, fill_usd.into());
let fill_usdt = TestOrderEventStubs::filled(
&order_template,
&audusd_sim,
Some(TradeId::new("t2")),
None,
Some(Price::from("1.00001")),
None,
None,
Some(Money::from("2.0 USDT")),
None,
None,
);
position.apply(&fill_usdt.into());
let fill_usd_again = TestOrderEventStubs::filled(
&order_template,
&audusd_sim,
Some(TradeId::new("t3")),
None,
Some(Price::from("1.00001")),
None,
None,
Some(Money::from("0.5 USD")),
None,
None,
);
position.apply(&fill_usd_again.into());
let fill_btc = TestOrderEventStubs::filled(
&order_template,
&audusd_sim,
Some(TradeId::new("t4")),
None,
Some(Price::from("1.00001")),
None,
None,
Some(Money::from("0.0001 BTC")),
None,
None,
);
position.apply(&fill_btc.into());
assert_eq!(
position.commissions(),
vec![
Money::from("1.5 USD"),
Money::from("2.0 USDT"),
Money::from("0.0001 BTC"),
]
);
}
#[rstest]
fn test_fold_net_position_empty() {
let (net_qty, net_px) = fold_net_position(&[]);
assert_eq!(net_qty, Decimal::ZERO);
assert_eq!(net_px, Decimal::ZERO);
}
#[rstest]
fn test_fold_net_position_single_long() {
let legs = [(dec!(100), dec!(1.5), 1u64)];
let (net_qty, net_px) = fold_net_position(&legs);
assert_eq!(net_qty, dec!(100));
assert_eq!(net_px, dec!(1.5));
}
#[rstest]
fn test_fold_net_position_single_short() {
let legs = [(dec!(-100), dec!(1.5), 1u64)];
let (net_qty, net_px) = fold_net_position(&legs);
assert_eq!(net_qty, dec!(-100));
assert_eq!(net_px, dec!(1.5));
}
#[rstest]
fn test_fold_net_position_same_side_weighted_average() {
let legs = [(dec!(100), dec!(1.0), 1u64), (dec!(200), dec!(0.5), 2u64)];
let (net_qty, net_px) = fold_net_position(&legs);
assert_eq!(net_qty, dec!(300));
assert_eq!(net_px, dec!(200) / dec!(300));
}
#[rstest]
fn test_fold_net_position_partial_close_preserves_avg() {
let legs = [
(dec!(300), dec!(0.80), 1u64),
(dec!(-100), dec!(1.00), 2u64),
];
let (net_qty, net_px) = fold_net_position(&legs);
assert_eq!(net_qty, dec!(200));
assert_eq!(net_px, dec!(0.80));
}
#[rstest]
fn test_fold_net_position_full_close() {
let legs = [(dec!(100), dec!(1.0), 1u64), (dec!(-100), dec!(2.0), 2u64)];
let (net_qty, net_px) = fold_net_position(&legs);
assert_eq!(net_qty, Decimal::ZERO);
assert_eq!(net_px, Decimal::ZERO);
}
#[rstest]
fn test_fold_net_position_single_flip_uses_flipping_price() {
let legs = [
(dec!(100), dec!(1.00), 1u64),
(dec!(-50), dec!(2.00), 2u64),
(dec!(-100), dec!(3.00), 3u64),
];
let (net_qty, net_px) = fold_net_position(&legs);
assert_eq!(net_qty, dec!(-50));
assert_eq!(net_px, dec!(3.00));
}
#[rstest]
fn test_fold_net_position_double_flip() {
let legs = [
(dec!(50), dec!(1.00), 1u64),
(dec!(-100), dec!(2.00), 2u64),
(dec!(100), dec!(3.00), 3u64),
];
let (net_qty, net_px) = fold_net_position(&legs);
assert_eq!(net_qty, dec!(50));
assert_eq!(net_px, dec!(3.00));
}
#[rstest]
fn test_fold_net_position_zero_quantity_legs_skipped() {
let legs = [
(dec!(100), dec!(1.0), 1u64),
(Decimal::ZERO, dec!(99.0), 2u64),
(dec!(50), dec!(2.0), 3u64),
];
let (net_qty, net_px) = fold_net_position(&legs);
assert_eq!(net_qty, dec!(150));
assert_eq!(net_px, dec!(200) / dec!(150));
}
#[rstest]
fn test_fold_net_position_stable_sort_preserves_input_order_for_equal_ts() {
let leg_a = (dec!(100), dec!(1.00), 1u64);
let leg_b = (dec!(-100), dec!(2.00), 1u64);
let ab = [leg_a, leg_b];
let ba = [leg_b, leg_a];
assert_eq!(fold_net_position(&ab), (Decimal::ZERO, Decimal::ZERO));
assert_eq!(fold_net_position(&ba), (Decimal::ZERO, Decimal::ZERO));
let leg_c = (dec!(150), dec!(1.00), 1u64);
let leg_d = (dec!(-100), dec!(2.00), 1u64);
let cd = [leg_c, leg_d];
let dc = [leg_d, leg_c];
assert_eq!(fold_net_position(&cd), (dec!(50), dec!(1.00)));
assert_eq!(fold_net_position(&dc), (dec!(50), dec!(1.00)));
}
#[rstest]
fn test_fold_net_position_close_then_reopen() {
let legs = [
(dec!(100), dec!(1.00), 1u64),
(dec!(-100), dec!(1.50), 2u64),
(dec!(50), dec!(3.00), 3u64),
];
let (net_qty, net_px) = fold_net_position(&legs);
assert_eq!(net_qty, dec!(50));
assert_eq!(net_px, dec!(3.00));
}
#[rstest]
fn test_fold_net_position_orders_by_ts_opened() {
let in_order = [
(dec!(100), dec!(1.00), 1u64),
(dec!(-50), dec!(2.00), 2u64),
(dec!(-100), dec!(3.00), 3u64),
];
let shuffled = [
(dec!(-100), dec!(3.00), 3u64),
(dec!(100), dec!(1.00), 1u64),
(dec!(-50), dec!(2.00), 2u64),
];
assert_eq!(fold_net_position(&in_order), fold_net_position(&shuffled));
}
fn netting_reference(
instrument: &InstrumentAny,
fills: &[(OrderSide, u32, u32, u64)],
) -> (Decimal, Decimal) {
let mut sorted_fills = fills.to_vec();
sorted_fills.sort_by_key(|(_, _, _, ts)| *ts);
let mut position: Option<Position> = None;
for (idx, &(side, qty, px, ts)) in sorted_fills.iter().enumerate() {
let order = OrderTestBuilder::new(OrderType::Market)
.instrument_id(instrument.id())
.side(side)
.quantity(Quantity::from(qty))
.build();
let fill = TestOrderEventStubs::filled(
&order,
instrument,
Some(TradeId::new(format!("T{idx}").as_str())),
Some(PositionId::new("P-NET")),
Some(Price::from(px.to_string().as_str())),
None,
None,
Some(Money::new(0.0, instrument.quote_currency())),
Some(UnixNanos::from(ts)),
None,
);
let event: OrderFilled = fill.into();
if let Some(p) = position.as_mut() {
p.apply(&event);
} else {
position = Some(Position::new(instrument, event));
}
}
let p = position.expect("at least one fill");
let signed = Decimal::try_from(p.signed_qty).unwrap_or(Decimal::ZERO);
let px = Decimal::try_from(p.avg_px_open).unwrap_or(Decimal::ZERO);
(signed, px)
}
fn hedging_legs(fills: &[(OrderSide, u32, u32, u64)]) -> Vec<(Decimal, Decimal, u64)> {
fills
.iter()
.map(|&(side, qty, px, ts)| {
let signed = if side == OrderSide::Buy {
Decimal::from(qty)
} else {
-Decimal::from(qty)
};
(signed, Decimal::from(px), ts)
})
.collect()
}
proptest! {
#[rstest]
fn prop_fold_matches_netting_replay(
fills in proptest::collection::vec(
(
prop_oneof![Just(OrderSide::Buy), Just(OrderSide::Sell)],
1u32..1_000u32,
1u32..100u32,
0u64..1_000_000u64,
),
1..6,
)
) {
let mut seen_ts: AHashSet<u64> = AHashSet::new();
for &(_, _, _, ts) in &fills {
if !seen_ts.insert(ts) {
prop_assume!(false);
}
}
let mut sorted_fills = fills.clone();
sorted_fills.sort_by_key(|(_, _, _, ts)| *ts);
let mut running: i64 = 0;
let mut zero_mid = false;
for (idx, &(side, qty, _, _)) in sorted_fills.iter().enumerate() {
let qty_i64 = i64::from(qty);
let signed: i64 = if side == OrderSide::Buy {
qty_i64
} else {
-qty_i64
};
running += signed;
if idx + 1 < sorted_fills.len() && running == 0 {
zero_mid = true;
break;
}
}
prop_assume!(!zero_mid);
let instrument = InstrumentAny::CurrencyPair(audusd_sim());
let (ref_qty, ref_px) = netting_reference(&instrument, &fills);
let legs = hedging_legs(&fills);
let (fold_qty, fold_px) = fold_net_position(&legs);
prop_assert_eq!(fold_qty, ref_qty);
if !ref_qty.is_zero() {
let fold_px_f64 = fold_px.to_f64().unwrap_or(0.0);
let ref_px_f64 = ref_px.to_f64().unwrap_or(0.0);
let max_mag = fold_px_f64.abs().max(ref_px_f64.abs()).max(1.0);
prop_assert!(
(fold_px_f64 - ref_px_f64).abs() < 1e-9 * max_mag,
"fold_px {fold_px_f64} vs ref_px {ref_px_f64}",
);
}
}
}
}