Skip to main content

nautilus_model/
position.rs

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