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#[cfg(test)]
980mod tests {
981    use std::str::FromStr;
982
983    use nautilus_core::UnixNanos;
984    use rstest::rstest;
985    use rust_decimal::Decimal;
986
987    use crate::{
988        enums::{LiquiditySide, OrderSide, OrderType, PositionAdjustmentType, PositionSide},
989        events::{OrderEventAny, OrderFilled, PositionAdjusted, order::spec::OrderFilledSpec},
990        identifiers::{
991            AccountId, ClientOrderId, PositionId, StrategyId, TradeId, VenueOrderId, stubs::uuid4,
992        },
993        instruments::{CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny, stubs::*},
994        orders::{Order, builder::OrderTestBuilder, stubs::TestOrderEventStubs},
995        position::Position,
996        stubs::*,
997        types::{Currency, Money, Price, Quantity},
998    };
999
1000    #[rstest]
1001    fn test_position_long_display(stub_position_long: Position) {
1002        let display = format!("{stub_position_long}");
1003        assert_eq!(display, "Position(LONG 1 AUD/USD.SIM, id=1)");
1004    }
1005
1006    #[rstest]
1007    fn test_position_short_display(stub_position_short: Position) {
1008        let display = format!("{stub_position_short}");
1009        assert_eq!(display, "Position(SHORT 1 AUD/USD.SIM, id=1)");
1010    }
1011
1012    #[rstest]
1013    #[should_panic(expected = "`fill.trade_id` already contained in `trade_ids")]
1014    fn test_two_trades_with_same_trade_id_error(audusd_sim: CurrencyPair) {
1015        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
1016        let order1 = OrderTestBuilder::new(OrderType::Market)
1017            .instrument_id(audusd_sim.id())
1018            .side(OrderSide::Buy)
1019            .quantity(Quantity::from(100_000))
1020            .build();
1021        let order2 = OrderTestBuilder::new(OrderType::Market)
1022            .instrument_id(audusd_sim.id())
1023            .side(OrderSide::Buy)
1024            .quantity(Quantity::from(100_000))
1025            .build();
1026        let fill1 = TestOrderEventStubs::filled(
1027            &order1,
1028            &audusd_sim,
1029            Some(TradeId::new("1")),
1030            None,
1031            Some(Price::from("1.00001")),
1032            None,
1033            None,
1034            None,
1035            None,
1036            None,
1037        );
1038        let fill2 = TestOrderEventStubs::filled(
1039            &order2,
1040            &audusd_sim,
1041            Some(TradeId::new("1")),
1042            None,
1043            Some(Price::from("1.00002")),
1044            None,
1045            None,
1046            None,
1047            None,
1048            None,
1049        );
1050        let mut position = Position::new(&audusd_sim, fill1.into());
1051        position.apply(&fill2.into());
1052    }
1053
1054    #[rstest]
1055    fn test_position_applies_fills_with_negative_prices(audusd_sim: CurrencyPair) {
1056        // Options and spreads can trade at negative prices; position average
1057        // price updates must not panic when the stored average or incoming
1058        // fill price is below zero.
1059        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
1060        let order = OrderTestBuilder::new(OrderType::Market)
1061            .instrument_id(audusd_sim.id())
1062            .side(OrderSide::Buy)
1063            .quantity(Quantity::from(100_000))
1064            .build();
1065        let fill1 = TestOrderEventStubs::filled(
1066            &order,
1067            &audusd_sim,
1068            Some(TradeId::new("1")),
1069            None,
1070            Some(Price::from("-5.00000")),
1071            Some(Quantity::from(50_000)),
1072            None,
1073            None,
1074            None,
1075            None,
1076        );
1077        let fill2 = TestOrderEventStubs::filled(
1078            &order,
1079            &audusd_sim,
1080            Some(TradeId::new("2")),
1081            None,
1082            Some(Price::from("-7.00000")),
1083            Some(Quantity::from(50_000)),
1084            None,
1085            None,
1086            None,
1087            None,
1088        );
1089        let mut position = Position::new(&audusd_sim, fill1.into());
1090        position.apply(&fill2.into());
1091
1092        assert_eq!(position.quantity, Quantity::from(100_000));
1093        assert_eq!(position.signed_qty, 100_000.0);
1094        assert_eq!(position.side, PositionSide::Long);
1095        // Weighted avg_px_open: (50_000 * -5 + 50_000 * -7) / 100_000 = -6.0
1096        assert_eq!(position.avg_px_open, -6.0);
1097    }
1098
1099    #[rstest]
1100    fn test_position_filled_with_buy_order(audusd_sim: CurrencyPair) {
1101        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
1102        let order = OrderTestBuilder::new(OrderType::Market)
1103            .instrument_id(audusd_sim.id())
1104            .side(OrderSide::Buy)
1105            .quantity(Quantity::from(100_000))
1106            .build();
1107        let fill = TestOrderEventStubs::filled(
1108            &order,
1109            &audusd_sim,
1110            None,
1111            None,
1112            Some(Price::from("1.00001")),
1113            None,
1114            None,
1115            None,
1116            None,
1117            None,
1118        );
1119        let last_price = Price::from_str("1.0005").unwrap();
1120        let position = Position::new(&audusd_sim, fill.into());
1121        assert_eq!(position.symbol(), audusd_sim.id().symbol);
1122        assert_eq!(position.venue(), audusd_sim.id().venue);
1123        assert_eq!(position.closing_order_side(), OrderSide::Sell);
1124        assert!(!position.is_opposite_side(OrderSide::Buy));
1125        assert_eq!(position, position); // equality operator test
1126        assert!(position.closing_order_id.is_none());
1127        assert_eq!(position.quantity, Quantity::from(100_000));
1128        assert_eq!(position.peak_qty, Quantity::from(100_000));
1129        assert_eq!(position.size_precision, 0);
1130        assert_eq!(position.signed_qty, 100_000.0);
1131        assert_eq!(position.entry, OrderSide::Buy);
1132        assert_eq!(position.side, PositionSide::Long);
1133        assert_eq!(position.ts_opened.as_u64(), 0);
1134        assert_eq!(position.duration_ns, 0);
1135        assert_eq!(position.avg_px_open, 1.00001);
1136        assert_eq!(position.event_count(), 1);
1137        assert_eq!(position.id, PositionId::new("1"));
1138        assert_eq!(position.events.len(), 1);
1139        assert!(position.is_long());
1140        assert!(!position.is_short());
1141        assert!(position.is_open());
1142        assert!(!position.is_closed());
1143        assert_eq!(position.realized_return, 0.0);
1144        assert_eq!(position.realized_pnl, Some(Money::from("-2.0 USD")));
1145        assert_eq!(position.unrealized_pnl(last_price), Money::from("49.0 USD"));
1146        assert_eq!(position.total_pnl(last_price), Money::from("47.0 USD"));
1147        assert_eq!(position.commissions(), vec![Money::from("2.0 USD")]);
1148        assert_eq!(
1149            format!("{position}"),
1150            "Position(LONG 100_000 AUD/USD.SIM, id=1)"
1151        );
1152    }
1153
1154    #[rstest]
1155    fn test_position_filled_with_sell_order(audusd_sim: CurrencyPair) {
1156        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
1157        let order = OrderTestBuilder::new(OrderType::Market)
1158            .instrument_id(audusd_sim.id())
1159            .side(OrderSide::Sell)
1160            .quantity(Quantity::from(100_000))
1161            .build();
1162        let fill = TestOrderEventStubs::filled(
1163            &order,
1164            &audusd_sim,
1165            None,
1166            None,
1167            Some(Price::from("1.00001")),
1168            None,
1169            None,
1170            None,
1171            None,
1172            None,
1173        );
1174        let last_price = Price::from_str("1.00050").unwrap();
1175        let position = Position::new(&audusd_sim, fill.into());
1176        assert_eq!(position.symbol(), audusd_sim.id().symbol);
1177        assert_eq!(position.venue(), audusd_sim.id().venue);
1178        assert_eq!(position.closing_order_side(), OrderSide::Buy);
1179        assert!(!position.is_opposite_side(OrderSide::Sell));
1180        assert_eq!(position, position); // Equality operator test
1181        assert!(position.closing_order_id.is_none());
1182        assert_eq!(position.quantity, Quantity::from(100_000));
1183        assert_eq!(position.peak_qty, Quantity::from(100_000));
1184        assert_eq!(position.signed_qty, -100_000.0);
1185        assert_eq!(position.entry, OrderSide::Sell);
1186        assert_eq!(position.side, PositionSide::Short);
1187        assert_eq!(position.ts_opened.as_u64(), 0);
1188        assert_eq!(position.avg_px_open, 1.00001);
1189        assert_eq!(position.event_count(), 1);
1190        assert_eq!(position.id, PositionId::new("1"));
1191        assert_eq!(position.events.len(), 1);
1192        assert!(!position.is_long());
1193        assert!(position.is_short());
1194        assert!(position.is_open());
1195        assert!(!position.is_closed());
1196        assert_eq!(position.realized_return, 0.0);
1197        assert_eq!(position.realized_pnl, Some(Money::from("-2.0 USD")));
1198        assert_eq!(
1199            position.unrealized_pnl(last_price),
1200            Money::from("-49.0 USD")
1201        );
1202        assert_eq!(position.total_pnl(last_price), Money::from("-51.0 USD"));
1203        assert_eq!(position.commissions(), vec![Money::from("2.0 USD")]);
1204        assert_eq!(
1205            format!("{position}"),
1206            "Position(SHORT 100_000 AUD/USD.SIM, id=1)"
1207        );
1208    }
1209
1210    #[rstest]
1211    fn test_position_partial_fills_with_buy_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::Buy)
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            Some(Quantity::from(50_000)),
1225            None,
1226            None,
1227            None,
1228            None,
1229        );
1230        let last_price = Price::from_str("1.00048").unwrap();
1231        let position = Position::new(&audusd_sim, fill.into());
1232        assert_eq!(position.quantity, Quantity::from(50_000));
1233        assert_eq!(position.peak_qty, Quantity::from(50_000));
1234        assert_eq!(position.side, PositionSide::Long);
1235        assert_eq!(position.signed_qty, 50000.0);
1236        assert_eq!(position.avg_px_open, 1.00001);
1237        assert_eq!(position.event_count(), 1);
1238        assert_eq!(position.ts_opened.as_u64(), 0);
1239        assert!(position.is_long());
1240        assert!(!position.is_short());
1241        assert!(position.is_open());
1242        assert!(!position.is_closed());
1243        assert_eq!(position.realized_return, 0.0);
1244        assert_eq!(position.realized_pnl, Some(Money::from("-2.0 USD")));
1245        assert_eq!(position.unrealized_pnl(last_price), Money::from("23.5 USD"));
1246        assert_eq!(position.total_pnl(last_price), Money::from("21.5 USD"));
1247        assert_eq!(position.commissions(), vec![Money::from("2.0 USD")]);
1248        assert_eq!(
1249            format!("{position}"),
1250            "Position(LONG 50_000 AUD/USD.SIM, id=1)"
1251        );
1252    }
1253
1254    #[rstest]
1255    fn test_position_partial_fills_with_two_sell_orders(audusd_sim: CurrencyPair) {
1256        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
1257        let order = OrderTestBuilder::new(OrderType::Market)
1258            .instrument_id(audusd_sim.id())
1259            .side(OrderSide::Sell)
1260            .quantity(Quantity::from(100_000))
1261            .build();
1262        let fill1 = TestOrderEventStubs::filled(
1263            &order,
1264            &audusd_sim,
1265            Some(TradeId::new("1")),
1266            None,
1267            Some(Price::from("1.00001")),
1268            Some(Quantity::from(50_000)),
1269            None,
1270            None,
1271            None,
1272            None,
1273        );
1274        let fill2 = TestOrderEventStubs::filled(
1275            &order,
1276            &audusd_sim,
1277            Some(TradeId::new("2")),
1278            None,
1279            Some(Price::from("1.00002")),
1280            Some(Quantity::from(50_000)),
1281            None,
1282            None,
1283            None,
1284            None,
1285        );
1286        let last_price = Price::from_str("1.0005").unwrap();
1287        let mut position = Position::new(&audusd_sim, fill1.into());
1288        position.apply(&fill2.into());
1289
1290        assert_eq!(position.quantity, Quantity::from(100_000));
1291        assert_eq!(position.peak_qty, Quantity::from(100_000));
1292        assert_eq!(position.side, PositionSide::Short);
1293        assert_eq!(position.signed_qty, -100_000.0);
1294        assert_eq!(position.avg_px_open, 1.000_015);
1295        assert_eq!(position.event_count(), 2);
1296        assert_eq!(position.ts_opened, 0);
1297        assert!(position.is_short());
1298        assert!(!position.is_long());
1299        assert!(position.is_open());
1300        assert!(!position.is_closed());
1301        assert_eq!(position.realized_return, 0.0);
1302        assert_eq!(position.realized_pnl, Some(Money::from("-4.0 USD")));
1303        assert_eq!(
1304            position.unrealized_pnl(last_price),
1305            Money::from("-48.5 USD")
1306        );
1307        assert_eq!(position.total_pnl(last_price), Money::from("-52.5 USD"));
1308        assert_eq!(position.commissions(), vec![Money::from("4.0 USD")]);
1309    }
1310
1311    #[rstest]
1312    pub fn test_position_filled_with_buy_order_then_sell_order(audusd_sim: CurrencyPair) {
1313        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
1314        let order = OrderTestBuilder::new(OrderType::Market)
1315            .instrument_id(audusd_sim.id())
1316            .side(OrderSide::Buy)
1317            .quantity(Quantity::from(150_000))
1318            .build();
1319        let fill = TestOrderEventStubs::filled(
1320            &order,
1321            &audusd_sim,
1322            Some(TradeId::new("1")),
1323            Some(PositionId::new("P-1")),
1324            Some(Price::from("1.00001")),
1325            None,
1326            None,
1327            None,
1328            Some(UnixNanos::from(1_000_000_000)),
1329            None,
1330        );
1331        let mut position = Position::new(&audusd_sim, fill.into());
1332
1333        let fill2 = OrderFilled::new(
1334            order.trader_id(),
1335            StrategyId::new("S-001"),
1336            order.instrument_id(),
1337            order.client_order_id(),
1338            VenueOrderId::from("2"),
1339            order.account_id().unwrap_or(AccountId::new("SIM-001")),
1340            TradeId::new("2"),
1341            OrderSide::Sell,
1342            OrderType::Market,
1343            order.quantity(),
1344            Price::from("1.00011"),
1345            audusd_sim.quote_currency(),
1346            LiquiditySide::Taker,
1347            uuid4(),
1348            2_000_000_000.into(),
1349            0.into(),
1350            false,
1351            Some(PositionId::new("T1")),
1352            Some(Money::from("0.0 USD")),
1353        );
1354        position.apply(&fill2);
1355        let last = Price::from_str("1.0005").unwrap();
1356
1357        assert!(position.is_opposite_side(fill2.order_side));
1358        assert_eq!(
1359            position.quantity,
1360            Quantity::zero(audusd_sim.price_precision())
1361        );
1362        assert_eq!(position.size_precision, 0);
1363        assert_eq!(position.signed_qty, 0.0);
1364        assert_eq!(position.side, PositionSide::Flat);
1365        assert_eq!(position.ts_opened, 1_000_000_000);
1366        assert_eq!(position.ts_closed, Some(UnixNanos::from(2_000_000_000)));
1367        assert_eq!(position.duration_ns, 1_000_000_000);
1368        assert_eq!(position.avg_px_open, 1.00001);
1369        assert_eq!(position.avg_px_close, Some(1.00011));
1370        assert!(!position.is_long());
1371        assert!(!position.is_short());
1372        assert!(!position.is_open());
1373        assert!(position.is_closed());
1374        assert_eq!(position.realized_return, 9.999_900_000_998_888e-5);
1375        assert_eq!(position.realized_pnl, Some(Money::from("13.0 USD")));
1376        assert_eq!(position.unrealized_pnl(last), Money::from("0 USD"));
1377        assert_eq!(position.commissions(), vec![Money::from("2 USD")]);
1378        assert_eq!(position.total_pnl(last), Money::from("13 USD"));
1379        assert_eq!(format!("{position}"), "Position(FLAT AUD/USD.SIM, id=P-1)");
1380    }
1381
1382    #[rstest]
1383    pub fn test_position_filled_with_sell_order_then_buy_order(audusd_sim: CurrencyPair) {
1384        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
1385        let order1 = OrderTestBuilder::new(OrderType::Market)
1386            .instrument_id(audusd_sim.id())
1387            .side(OrderSide::Sell)
1388            .quantity(Quantity::from(100_000))
1389            .build();
1390        let order2 = OrderTestBuilder::new(OrderType::Market)
1391            .instrument_id(audusd_sim.id())
1392            .side(OrderSide::Buy)
1393            .quantity(Quantity::from(100_000))
1394            .build();
1395        let fill1 = TestOrderEventStubs::filled(
1396            &order1,
1397            &audusd_sim,
1398            None,
1399            Some(PositionId::new("P-19700101-000000-001-001-1")),
1400            Some(Price::from("1.0")),
1401            None,
1402            None,
1403            None,
1404            None,
1405            None,
1406        );
1407        let mut position = Position::new(&audusd_sim, fill1.into());
1408        // create closing from order from different venue but same strategy
1409        let fill2 = TestOrderEventStubs::filled(
1410            &order2,
1411            &audusd_sim,
1412            Some(TradeId::new("1")),
1413            Some(PositionId::new("P-19700101-000000-001-001-1")),
1414            Some(Price::from("1.00001")),
1415            Some(Quantity::from(50_000)),
1416            None,
1417            None,
1418            None,
1419            None,
1420        );
1421        let fill3 = TestOrderEventStubs::filled(
1422            &order2,
1423            &audusd_sim,
1424            Some(TradeId::new("2")),
1425            Some(PositionId::new("P-19700101-000000-001-001-1")),
1426            Some(Price::from("1.00003")),
1427            Some(Quantity::from(50_000)),
1428            None,
1429            None,
1430            None,
1431            None,
1432        );
1433        let last = Price::from("1.0005");
1434        position.apply(&fill2.into());
1435        position.apply(&fill3.into());
1436
1437        assert_eq!(
1438            position.quantity,
1439            Quantity::zero(audusd_sim.price_precision())
1440        );
1441        assert_eq!(position.side, PositionSide::Flat);
1442        assert_eq!(position.ts_opened, 0);
1443        assert_eq!(position.avg_px_open, 1.0);
1444        assert_eq!(position.events.len(), 3);
1445        assert_eq!(position.ts_closed, Some(UnixNanos::default()));
1446        assert_eq!(position.avg_px_close, Some(1.00002));
1447        assert!(!position.is_long());
1448        assert!(!position.is_short());
1449        assert!(!position.is_open());
1450        assert!(position.is_closed());
1451        assert_eq!(position.commissions(), vec![Money::from("6.0 USD")]);
1452        assert_eq!(position.unrealized_pnl(last), Money::from("0 USD"));
1453        assert_eq!(position.realized_pnl, Some(Money::from("-8.0 USD")));
1454        assert_eq!(position.total_pnl(last), Money::from("-8.0 USD"));
1455        assert_eq!(
1456            format!("{position}"),
1457            "Position(FLAT AUD/USD.SIM, id=P-19700101-000000-001-001-1)"
1458        );
1459    }
1460
1461    #[rstest]
1462    fn test_position_filled_with_no_change(audusd_sim: CurrencyPair) {
1463        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
1464        let order1 = OrderTestBuilder::new(OrderType::Market)
1465            .instrument_id(audusd_sim.id())
1466            .side(OrderSide::Buy)
1467            .quantity(Quantity::from(100_000))
1468            .build();
1469        let order2 = OrderTestBuilder::new(OrderType::Market)
1470            .instrument_id(audusd_sim.id())
1471            .side(OrderSide::Sell)
1472            .quantity(Quantity::from(100_000))
1473            .build();
1474        let fill1 = TestOrderEventStubs::filled(
1475            &order1,
1476            &audusd_sim,
1477            Some(TradeId::new("1")),
1478            Some(PositionId::new("P-19700101-000000-001-001-1")),
1479            Some(Price::from("1.0")),
1480            None,
1481            None,
1482            None,
1483            None,
1484            None,
1485        );
1486        let mut position = Position::new(&audusd_sim, fill1.into());
1487        let fill2 = TestOrderEventStubs::filled(
1488            &order2,
1489            &audusd_sim,
1490            Some(TradeId::new("2")),
1491            Some(PositionId::new("P-19700101-000000-001-001-1")),
1492            Some(Price::from("1.0")),
1493            None,
1494            None,
1495            None,
1496            None,
1497            None,
1498        );
1499        let last = Price::from("1.0005");
1500        position.apply(&fill2.into());
1501
1502        assert_eq!(
1503            position.quantity,
1504            Quantity::zero(audusd_sim.price_precision())
1505        );
1506        assert_eq!(position.closing_order_side(), OrderSide::NoOrderSide);
1507        assert_eq!(position.side, PositionSide::Flat);
1508        assert_eq!(position.ts_opened, 0);
1509        assert_eq!(position.avg_px_open, 1.0);
1510        assert_eq!(position.events.len(), 2);
1511        // assert_eq!(position.trade_ids, vec![fill1.trade_id, fill2.trade_id]);  // TODO
1512        assert_eq!(position.ts_closed, Some(UnixNanos::default()));
1513        assert_eq!(position.avg_px_close, Some(1.0));
1514        assert!(!position.is_long());
1515        assert!(!position.is_short());
1516        assert!(!position.is_open());
1517        assert!(position.is_closed());
1518        assert_eq!(position.commissions(), vec![Money::from("4.0 USD")]);
1519        assert_eq!(position.unrealized_pnl(last), Money::from("0 USD"));
1520        assert_eq!(position.realized_pnl, Some(Money::from("-4.0 USD")));
1521        assert_eq!(position.total_pnl(last), Money::from("-4.0 USD"));
1522        assert_eq!(
1523            format!("{position}"),
1524            "Position(FLAT AUD/USD.SIM, id=P-19700101-000000-001-001-1)"
1525        );
1526    }
1527
1528    #[rstest]
1529    fn test_position_long_with_multiple_filled_orders(audusd_sim: CurrencyPair) {
1530        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
1531        let order1 = OrderTestBuilder::new(OrderType::Market)
1532            .instrument_id(audusd_sim.id())
1533            .side(OrderSide::Buy)
1534            .quantity(Quantity::from(100_000))
1535            .build();
1536        let order2 = OrderTestBuilder::new(OrderType::Market)
1537            .instrument_id(audusd_sim.id())
1538            .side(OrderSide::Buy)
1539            .quantity(Quantity::from(100_000))
1540            .build();
1541        let order3 = OrderTestBuilder::new(OrderType::Market)
1542            .instrument_id(audusd_sim.id())
1543            .side(OrderSide::Sell)
1544            .quantity(Quantity::from(200_000))
1545            .build();
1546        let fill1 = TestOrderEventStubs::filled(
1547            &order1,
1548            &audusd_sim,
1549            Some(TradeId::new("1")),
1550            Some(PositionId::new("P-123456")),
1551            Some(Price::from("1.0")),
1552            None,
1553            None,
1554            None,
1555            None,
1556            None,
1557        );
1558        let fill2 = TestOrderEventStubs::filled(
1559            &order2,
1560            &audusd_sim,
1561            Some(TradeId::new("2")),
1562            Some(PositionId::new("P-123456")),
1563            Some(Price::from("1.00001")),
1564            None,
1565            None,
1566            None,
1567            None,
1568            None,
1569        );
1570        let fill3 = TestOrderEventStubs::filled(
1571            &order3,
1572            &audusd_sim,
1573            Some(TradeId::new("3")),
1574            Some(PositionId::new("P-123456")),
1575            Some(Price::from("1.0001")),
1576            None,
1577            None,
1578            None,
1579            None,
1580            None,
1581        );
1582        let mut position = Position::new(&audusd_sim, fill1.into());
1583        let last = Price::from("1.0005");
1584        position.apply(&fill2.into());
1585        position.apply(&fill3.into());
1586
1587        assert_eq!(
1588            position.quantity,
1589            Quantity::zero(audusd_sim.price_precision())
1590        );
1591        assert_eq!(position.side, PositionSide::Flat);
1592        assert_eq!(position.ts_opened, 0);
1593        assert_eq!(position.avg_px_open, 1.000_005);
1594        assert_eq!(position.events.len(), 3);
1595        // assert_eq!(
1596        //     position.trade_ids,
1597        //     vec![fill1.trade_id, fill2.trade_id, fill3.trade_id]
1598        // );
1599        assert_eq!(position.ts_closed, Some(UnixNanos::default()));
1600        assert_eq!(position.avg_px_close, Some(1.0001));
1601        assert!(position.is_closed());
1602        assert!(!position.is_open());
1603        assert!(!position.is_long());
1604        assert!(!position.is_short());
1605        assert_eq!(position.commissions(), vec![Money::from("6.0 USD")]);
1606        assert_eq!(position.realized_pnl, Some(Money::from("13.0 USD")));
1607        assert_eq!(position.unrealized_pnl(last), Money::from("0 USD"));
1608        assert_eq!(position.total_pnl(last), Money::from("13 USD"));
1609        assert_eq!(
1610            format!("{position}"),
1611            "Position(FLAT AUD/USD.SIM, id=P-123456)"
1612        );
1613    }
1614
1615    #[rstest]
1616    fn test_pnl_calculation_from_trading_technologies_example(currency_pair_ethusdt: CurrencyPair) {
1617        let ethusdt = InstrumentAny::CurrencyPair(currency_pair_ethusdt);
1618        let quantity1 = Quantity::from(12);
1619        let price1 = Price::from("100.0");
1620        let order1 = OrderTestBuilder::new(OrderType::Market)
1621            .instrument_id(ethusdt.id())
1622            .side(OrderSide::Buy)
1623            .quantity(quantity1)
1624            .build();
1625        let commission1 = calculate_commission(&ethusdt, order1.quantity(), price1, None);
1626        let fill1 = TestOrderEventStubs::filled(
1627            &order1,
1628            &ethusdt,
1629            Some(TradeId::new("1")),
1630            Some(PositionId::new("P-123456")),
1631            Some(price1),
1632            None,
1633            None,
1634            Some(commission1),
1635            None,
1636            None,
1637        );
1638        let mut position = Position::new(&ethusdt, fill1.into());
1639        let quantity2 = Quantity::from(17);
1640        let order2 = OrderTestBuilder::new(OrderType::Market)
1641            .instrument_id(ethusdt.id())
1642            .side(OrderSide::Buy)
1643            .quantity(quantity2)
1644            .build();
1645        let price2 = Price::from("99.0");
1646        let commission2 = calculate_commission(&ethusdt, order2.quantity(), price2, None);
1647        let fill2 = TestOrderEventStubs::filled(
1648            &order2,
1649            &ethusdt,
1650            Some(TradeId::new("2")),
1651            Some(PositionId::new("P-123456")),
1652            Some(price2),
1653            None,
1654            None,
1655            Some(commission2),
1656            None,
1657            None,
1658        );
1659        position.apply(&fill2.into());
1660        assert_eq!(position.quantity, Quantity::from(29));
1661        assert_eq!(position.realized_pnl, Some(Money::from("-0.28830000 USDT")));
1662        assert_eq!(position.avg_px_open, 99.413_793_103_448_27);
1663        let quantity3 = Quantity::from(9);
1664        let order3 = OrderTestBuilder::new(OrderType::Market)
1665            .instrument_id(ethusdt.id())
1666            .side(OrderSide::Sell)
1667            .quantity(quantity3)
1668            .build();
1669        let price3 = Price::from("101.0");
1670        let commission3 = calculate_commission(&ethusdt, order3.quantity(), price3, None);
1671        let fill3 = TestOrderEventStubs::filled(
1672            &order3,
1673            &ethusdt,
1674            Some(TradeId::new("3")),
1675            Some(PositionId::new("P-123456")),
1676            Some(price3),
1677            None,
1678            None,
1679            Some(commission3),
1680            None,
1681            None,
1682        );
1683        position.apply(&fill3.into());
1684        assert_eq!(position.quantity, Quantity::from(20));
1685        assert_eq!(position.realized_pnl, Some(Money::from("13.89666207 USDT")));
1686        assert_eq!(position.avg_px_open, 99.413_793_103_448_27);
1687        let quantity4 = Quantity::from("4");
1688        let price4 = Price::from("105.0");
1689        let order4 = OrderTestBuilder::new(OrderType::Market)
1690            .instrument_id(ethusdt.id())
1691            .side(OrderSide::Sell)
1692            .quantity(quantity4)
1693            .build();
1694        let commission4 = calculate_commission(&ethusdt, order4.quantity(), price4, None);
1695        let fill4 = TestOrderEventStubs::filled(
1696            &order4,
1697            &ethusdt,
1698            Some(TradeId::new("4")),
1699            Some(PositionId::new("P-123456")),
1700            Some(price4),
1701            None,
1702            None,
1703            Some(commission4),
1704            None,
1705            None,
1706        );
1707        position.apply(&fill4.into());
1708        assert_eq!(position.quantity, Quantity::from("16"));
1709        assert_eq!(position.realized_pnl, Some(Money::from("36.19948966 USDT")));
1710        assert_eq!(position.avg_px_open, 99.413_793_103_448_27);
1711        let quantity5 = Quantity::from("3");
1712        let price5 = Price::from("103.0");
1713        let order5 = OrderTestBuilder::new(OrderType::Market)
1714            .instrument_id(ethusdt.id())
1715            .side(OrderSide::Buy)
1716            .quantity(quantity5)
1717            .build();
1718        let commission5 = calculate_commission(&ethusdt, order5.quantity(), price5, None);
1719        let fill5 = TestOrderEventStubs::filled(
1720            &order5,
1721            &ethusdt,
1722            Some(TradeId::new("5")),
1723            Some(PositionId::new("P-123456")),
1724            Some(price5),
1725            None,
1726            None,
1727            Some(commission5),
1728            None,
1729            None,
1730        );
1731        position.apply(&fill5.into());
1732        assert_eq!(position.quantity, Quantity::from("19"));
1733        assert_eq!(position.realized_pnl, Some(Money::from("36.16858966 USDT")));
1734        assert_eq!(position.avg_px_open, 99.980_036_297_640_65);
1735        assert_eq!(
1736            format!("{position}"),
1737            "Position(LONG 19.00000 ETHUSDT.BINANCE, id=P-123456)"
1738        );
1739    }
1740
1741    #[rstest]
1742    fn test_position_closed_and_reopened(audusd_sim: CurrencyPair) {
1743        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
1744        let quantity1 = Quantity::from(150_000);
1745        let price1 = Price::from("1.00001");
1746        let order = OrderTestBuilder::new(OrderType::Market)
1747            .instrument_id(audusd_sim.id())
1748            .side(OrderSide::Buy)
1749            .quantity(quantity1)
1750            .build();
1751        let commission1 = calculate_commission(&audusd_sim, quantity1, price1, None);
1752        let fill1 = TestOrderEventStubs::filled(
1753            &order,
1754            &audusd_sim,
1755            Some(TradeId::new("5")),
1756            Some(PositionId::new("P-123456")),
1757            Some(Price::from("1.00001")),
1758            None,
1759            None,
1760            Some(commission1),
1761            Some(UnixNanos::from(1_000_000_000)),
1762            None,
1763        );
1764        let mut position = Position::new(&audusd_sim, fill1.into());
1765
1766        let fill2 = OrderFilled::new(
1767            order.trader_id(),
1768            order.strategy_id(),
1769            order.instrument_id(),
1770            order.client_order_id(),
1771            VenueOrderId::from("2"),
1772            order.account_id().unwrap_or(AccountId::new("SIM-001")),
1773            TradeId::from("2"),
1774            OrderSide::Sell,
1775            OrderType::Market,
1776            order.quantity(),
1777            Price::from("1.00011"),
1778            audusd_sim.quote_currency(),
1779            LiquiditySide::Taker,
1780            uuid4(),
1781            UnixNanos::from(2_000_000_000),
1782            UnixNanos::default(),
1783            false,
1784            Some(PositionId::from("P-123456")),
1785            Some(Money::from("0 USD")),
1786        );
1787
1788        position.apply(&fill2);
1789
1790        let fill3 = OrderFilled::new(
1791            order.trader_id(),
1792            order.strategy_id(),
1793            order.instrument_id(),
1794            order.client_order_id(),
1795            VenueOrderId::from("2"),
1796            order.account_id().unwrap_or(AccountId::new("SIM-001")),
1797            TradeId::from("3"),
1798            OrderSide::Buy,
1799            OrderType::Market,
1800            order.quantity(),
1801            Price::from("1.00012"),
1802            audusd_sim.quote_currency(),
1803            LiquiditySide::Taker,
1804            uuid4(),
1805            UnixNanos::from(3_000_000_000),
1806            UnixNanos::default(),
1807            false,
1808            Some(PositionId::from("P-123456")),
1809            Some(Money::from("0 USD")),
1810        );
1811
1812        position.apply(&fill3);
1813
1814        let last = Price::from("1.0003");
1815        assert!(position.is_opposite_side(fill2.order_side));
1816        assert_eq!(position.quantity, Quantity::from(150_000));
1817        assert_eq!(position.peak_qty, Quantity::from(150_000));
1818        assert_eq!(position.side, PositionSide::Long);
1819        assert_eq!(position.opening_order_id, fill3.client_order_id);
1820        assert_eq!(position.closing_order_id, None);
1821        assert_eq!(position.closing_order_id, None);
1822        assert_eq!(position.ts_opened, 3_000_000_000);
1823        assert_eq!(position.duration_ns, 0);
1824        assert_eq!(position.avg_px_open, 1.00012);
1825        assert_eq!(position.event_count(), 1);
1826        assert_eq!(position.ts_closed, None);
1827        assert_eq!(position.avg_px_close, None);
1828        assert!(position.is_long());
1829        assert!(!position.is_short());
1830        assert!(position.is_open());
1831        assert!(!position.is_closed());
1832        assert_eq!(position.realized_return, 0.0);
1833        assert_eq!(position.realized_pnl, Some(Money::from("0 USD")));
1834        assert_eq!(position.unrealized_pnl(last), Money::from("27 USD"));
1835        assert_eq!(position.total_pnl(last), Money::from("27 USD"));
1836        assert_eq!(position.commissions(), vec![Money::from("0 USD")]);
1837        assert_eq!(
1838            format!("{position}"),
1839            "Position(LONG 150_000 AUD/USD.SIM, id=P-123456)"
1840        );
1841    }
1842
1843    #[rstest]
1844    fn test_position_realized_pnl_with_interleaved_order_sides(
1845        currency_pair_btcusdt: CurrencyPair,
1846    ) {
1847        let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
1848        let order1 = OrderTestBuilder::new(OrderType::Market)
1849            .instrument_id(btcusdt.id())
1850            .side(OrderSide::Buy)
1851            .quantity(Quantity::from(12))
1852            .build();
1853        let commission1 =
1854            calculate_commission(&btcusdt, order1.quantity(), Price::from("10000.0"), None);
1855        let fill1 = TestOrderEventStubs::filled(
1856            &order1,
1857            &btcusdt,
1858            Some(TradeId::from("1")),
1859            Some(PositionId::from("P-19700101-000000-001-001-1")),
1860            Some(Price::from("10000.0")),
1861            None,
1862            None,
1863            Some(commission1),
1864            None,
1865            None,
1866        );
1867        let mut position = Position::new(&btcusdt, fill1.into());
1868        let order2 = OrderTestBuilder::new(OrderType::Market)
1869            .instrument_id(btcusdt.id())
1870            .side(OrderSide::Buy)
1871            .quantity(Quantity::from(17))
1872            .build();
1873        let commission2 =
1874            calculate_commission(&btcusdt, order2.quantity(), Price::from("9999.0"), None);
1875        let fill2 = TestOrderEventStubs::filled(
1876            &order2,
1877            &btcusdt,
1878            Some(TradeId::from("2")),
1879            Some(PositionId::from("P-19700101-000000-001-001-1")),
1880            Some(Price::from("9999.0")),
1881            None,
1882            None,
1883            Some(commission2),
1884            None,
1885            None,
1886        );
1887        position.apply(&fill2.into());
1888        assert_eq!(position.quantity, Quantity::from(29));
1889        assert_eq!(
1890            position.realized_pnl,
1891            Some(Money::from("-289.98300000 USDT"))
1892        );
1893        assert_eq!(position.avg_px_open, 9_999.413_793_103_447);
1894        let order3 = OrderTestBuilder::new(OrderType::Market)
1895            .instrument_id(btcusdt.id())
1896            .side(OrderSide::Sell)
1897            .quantity(Quantity::from(9))
1898            .build();
1899        let commission3 =
1900            calculate_commission(&btcusdt, order3.quantity(), Price::from("10001.0"), None);
1901        let fill3 = TestOrderEventStubs::filled(
1902            &order3,
1903            &btcusdt,
1904            Some(TradeId::from("3")),
1905            Some(PositionId::from("P-19700101-000000-001-001-1")),
1906            Some(Price::from("10001.0")),
1907            None,
1908            None,
1909            Some(commission3),
1910            None,
1911            None,
1912        );
1913        position.apply(&fill3.into());
1914        assert_eq!(position.quantity, Quantity::from(20));
1915        assert_eq!(
1916            position.realized_pnl,
1917            Some(Money::from("-365.71613793 USDT"))
1918        );
1919        assert_eq!(position.avg_px_open, 9_999.413_793_103_447);
1920        let order4 = OrderTestBuilder::new(OrderType::Market)
1921            .instrument_id(btcusdt.id())
1922            .side(OrderSide::Buy)
1923            .quantity(Quantity::from(3))
1924            .build();
1925        let commission4 =
1926            calculate_commission(&btcusdt, order4.quantity(), Price::from("10003.0"), None);
1927        let fill4 = TestOrderEventStubs::filled(
1928            &order4,
1929            &btcusdt,
1930            Some(TradeId::from("4")),
1931            Some(PositionId::from("P-19700101-000000-001-001-1")),
1932            Some(Price::from("10003.0")),
1933            None,
1934            None,
1935            Some(commission4),
1936            None,
1937            None,
1938        );
1939        position.apply(&fill4.into());
1940        assert_eq!(position.quantity, Quantity::from(23));
1941        assert_eq!(
1942            position.realized_pnl,
1943            Some(Money::from("-395.72513793 USDT"))
1944        );
1945        assert_eq!(position.avg_px_open, 9_999.881_559_220_39);
1946        let order5 = OrderTestBuilder::new(OrderType::Market)
1947            .instrument_id(btcusdt.id())
1948            .side(OrderSide::Sell)
1949            .quantity(Quantity::from(4))
1950            .build();
1951        let commission5 =
1952            calculate_commission(&btcusdt, order5.quantity(), Price::from("10005.0"), None);
1953        let fill5 = TestOrderEventStubs::filled(
1954            &order5,
1955            &btcusdt,
1956            Some(TradeId::from("5")),
1957            Some(PositionId::from("P-19700101-000000-001-001-1")),
1958            Some(Price::from("10005.0")),
1959            None,
1960            None,
1961            Some(commission5),
1962            None,
1963            None,
1964        );
1965        position.apply(&fill5.into());
1966        assert_eq!(position.quantity, Quantity::from(19));
1967        assert_eq!(
1968            position.realized_pnl,
1969            Some(Money::from("-415.27137481 USDT"))
1970        );
1971        assert_eq!(position.avg_px_open, 9_999.881_559_220_39);
1972        assert_eq!(
1973            format!("{position}"),
1974            "Position(LONG 19.000000 BTCUSDT.BINANCE, id=P-19700101-000000-001-001-1)"
1975        );
1976    }
1977
1978    #[rstest]
1979    fn test_calculate_pnl_when_given_position_side_flat_returns_zero(
1980        currency_pair_btcusdt: CurrencyPair,
1981    ) {
1982        let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
1983        let order = OrderTestBuilder::new(OrderType::Market)
1984            .instrument_id(btcusdt.id())
1985            .side(OrderSide::Buy)
1986            .quantity(Quantity::from(12))
1987            .build();
1988        let fill = TestOrderEventStubs::filled(
1989            &order,
1990            &btcusdt,
1991            None,
1992            Some(PositionId::from("P-123456")),
1993            Some(Price::from("10500.0")),
1994            None,
1995            None,
1996            None,
1997            None,
1998            None,
1999        );
2000        let position = Position::new(&btcusdt, fill.into());
2001        let result = position.calculate_pnl(10500.0, 10500.0, Quantity::from("100000.0"));
2002        assert_eq!(result, Money::from("0 USDT"));
2003    }
2004
2005    #[rstest]
2006    fn test_calculate_pnl_for_long_position_win(currency_pair_btcusdt: CurrencyPair) {
2007        let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
2008        let order = OrderTestBuilder::new(OrderType::Market)
2009            .instrument_id(btcusdt.id())
2010            .side(OrderSide::Buy)
2011            .quantity(Quantity::from(12))
2012            .build();
2013        let commission =
2014            calculate_commission(&btcusdt, order.quantity(), Price::from("10500.0"), None);
2015        let fill = TestOrderEventStubs::filled(
2016            &order,
2017            &btcusdt,
2018            None,
2019            Some(PositionId::from("P-123456")),
2020            Some(Price::from("10500.0")),
2021            None,
2022            None,
2023            Some(commission),
2024            None,
2025            None,
2026        );
2027        let position = Position::new(&btcusdt, fill.into());
2028        let pnl = position.calculate_pnl(10500.0, 10510.0, Quantity::from("12.0"));
2029        assert_eq!(pnl, Money::from("120 USDT"));
2030        assert_eq!(position.realized_pnl, Some(Money::from("-126 USDT")));
2031        assert_eq!(
2032            position.unrealized_pnl(Price::from("10510.0")),
2033            Money::from("120.0 USDT")
2034        );
2035        assert_eq!(
2036            position.total_pnl(Price::from("10510.0")),
2037            Money::from("-6 USDT")
2038        );
2039        assert_eq!(position.commissions(), vec![Money::from("126.0 USDT")]);
2040    }
2041
2042    #[rstest]
2043    fn test_calculate_pnl_for_long_position_loss(currency_pair_btcusdt: CurrencyPair) {
2044        let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
2045        let order = OrderTestBuilder::new(OrderType::Market)
2046            .instrument_id(btcusdt.id())
2047            .side(OrderSide::Buy)
2048            .quantity(Quantity::from(12))
2049            .build();
2050        let commission =
2051            calculate_commission(&btcusdt, order.quantity(), Price::from("10500.0"), None);
2052        let fill = TestOrderEventStubs::filled(
2053            &order,
2054            &btcusdt,
2055            None,
2056            Some(PositionId::from("P-123456")),
2057            Some(Price::from("10500.0")),
2058            None,
2059            None,
2060            Some(commission),
2061            None,
2062            None,
2063        );
2064        let position = Position::new(&btcusdt, fill.into());
2065        let pnl = position.calculate_pnl(10500.0, 10480.5, Quantity::from("10.0"));
2066        assert_eq!(pnl, Money::from("-195 USDT"));
2067        assert_eq!(position.realized_pnl, Some(Money::from("-126 USDT")));
2068        assert_eq!(
2069            position.unrealized_pnl(Price::from("10480.50")),
2070            Money::from("-234.0 USDT")
2071        );
2072        assert_eq!(
2073            position.total_pnl(Price::from("10480.50")),
2074            Money::from("-360 USDT")
2075        );
2076        assert_eq!(position.commissions(), vec![Money::from("126.0 USDT")]);
2077    }
2078
2079    #[rstest]
2080    fn test_calculate_pnl_for_short_position_winning(currency_pair_btcusdt: CurrencyPair) {
2081        let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
2082        let order = OrderTestBuilder::new(OrderType::Market)
2083            .instrument_id(btcusdt.id())
2084            .side(OrderSide::Sell)
2085            .quantity(Quantity::from("10.15"))
2086            .build();
2087        let commission =
2088            calculate_commission(&btcusdt, order.quantity(), Price::from("10500.0"), None);
2089        let fill = TestOrderEventStubs::filled(
2090            &order,
2091            &btcusdt,
2092            None,
2093            Some(PositionId::from("P-123456")),
2094            Some(Price::from("10500.0")),
2095            None,
2096            None,
2097            Some(commission),
2098            None,
2099            None,
2100        );
2101        let position = Position::new(&btcusdt, fill.into());
2102        let pnl = position.calculate_pnl(10500.0, 10390.0, Quantity::from("10.15"));
2103        assert_eq!(pnl, Money::from("1116.5 USDT"));
2104        assert_eq!(
2105            position.unrealized_pnl(Price::from("10390.0")),
2106            Money::from("1116.5 USDT")
2107        );
2108        assert_eq!(position.realized_pnl, Some(Money::from("-106.575 USDT")));
2109        assert_eq!(position.commissions(), vec![Money::from("106.575 USDT")]);
2110        assert_eq!(
2111            position.notional_value(Price::from("10390.0")),
2112            Money::from("105458.5 USDT")
2113        );
2114    }
2115
2116    #[rstest]
2117    fn test_calculate_pnl_for_short_position_loss(currency_pair_btcusdt: CurrencyPair) {
2118        let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
2119        let order = OrderTestBuilder::new(OrderType::Market)
2120            .instrument_id(btcusdt.id())
2121            .side(OrderSide::Sell)
2122            .quantity(Quantity::from("10.0"))
2123            .build();
2124        let commission =
2125            calculate_commission(&btcusdt, order.quantity(), Price::from("10500.0"), None);
2126        let fill = TestOrderEventStubs::filled(
2127            &order,
2128            &btcusdt,
2129            None,
2130            Some(PositionId::from("P-123456")),
2131            Some(Price::from("10500.0")),
2132            None,
2133            None,
2134            Some(commission),
2135            None,
2136            None,
2137        );
2138        let position = Position::new(&btcusdt, fill.into());
2139        let pnl = position.calculate_pnl(10500.0, 10670.5, Quantity::from("10.0"));
2140        assert_eq!(pnl, Money::from("-1705 USDT"));
2141        assert_eq!(
2142            position.unrealized_pnl(Price::from("10670.5")),
2143            Money::from("-1705 USDT")
2144        );
2145        assert_eq!(position.realized_pnl, Some(Money::from("-105 USDT")));
2146        assert_eq!(position.commissions(), vec![Money::from("105 USDT")]);
2147        assert_eq!(
2148            position.notional_value(Price::from("10670.5")),
2149            Money::from("106705 USDT")
2150        );
2151    }
2152
2153    #[rstest]
2154    fn test_calculate_pnl_for_inverse1(xbtusd_bitmex: CryptoPerpetual) {
2155        let xbtusd_bitmex = InstrumentAny::CryptoPerpetual(xbtusd_bitmex);
2156        let order = OrderTestBuilder::new(OrderType::Market)
2157            .instrument_id(xbtusd_bitmex.id())
2158            .side(OrderSide::Sell)
2159            .quantity(Quantity::from("100000"))
2160            .build();
2161        let commission = calculate_commission(
2162            &xbtusd_bitmex,
2163            order.quantity(),
2164            Price::from("10000.0"),
2165            None,
2166        );
2167        let fill = TestOrderEventStubs::filled(
2168            &order,
2169            &xbtusd_bitmex,
2170            None,
2171            Some(PositionId::from("P-123456")),
2172            Some(Price::from("10000.0")),
2173            None,
2174            None,
2175            Some(commission),
2176            None,
2177            None,
2178        );
2179        let position = Position::new(&xbtusd_bitmex, fill.into());
2180        let pnl = position.calculate_pnl(10000.0, 11000.0, Quantity::from("100000.0"));
2181        assert_eq!(pnl, Money::from("-0.90909091 BTC"));
2182        assert_eq!(
2183            position.unrealized_pnl(Price::from("11000.0")),
2184            Money::from("-0.90909091 BTC")
2185        );
2186        assert_eq!(position.realized_pnl, Some(Money::from("-0.00750000 BTC")));
2187        assert_eq!(
2188            position.notional_value(Price::from("11000.0")),
2189            Money::from("9.09090909 BTC")
2190        );
2191    }
2192
2193    #[rstest]
2194    fn test_calculate_pnl_for_inverse2(ethusdt_bitmex: CryptoPerpetual) {
2195        let ethusdt_bitmex = InstrumentAny::CryptoPerpetual(ethusdt_bitmex);
2196        let order = OrderTestBuilder::new(OrderType::Market)
2197            .instrument_id(ethusdt_bitmex.id())
2198            .side(OrderSide::Sell)
2199            .quantity(Quantity::from("100000"))
2200            .build();
2201        let commission = calculate_commission(
2202            &ethusdt_bitmex,
2203            order.quantity(),
2204            Price::from("375.95"),
2205            None,
2206        );
2207        let fill = TestOrderEventStubs::filled(
2208            &order,
2209            &ethusdt_bitmex,
2210            None,
2211            Some(PositionId::from("P-123456")),
2212            Some(Price::from("375.95")),
2213            None,
2214            None,
2215            Some(commission),
2216            None,
2217            None,
2218        );
2219        let position = Position::new(&ethusdt_bitmex, fill.into());
2220
2221        assert_eq!(
2222            position.unrealized_pnl(Price::from("370.00")),
2223            Money::from("4.27745208 ETH")
2224        );
2225        assert_eq!(
2226            position.notional_value(Price::from("370.00")),
2227            Money::from("270.27027027 ETH")
2228        );
2229    }
2230
2231    #[rstest]
2232    fn test_calculate_unrealized_pnl_for_long(currency_pair_btcusdt: CurrencyPair) {
2233        let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
2234        let order1 = OrderTestBuilder::new(OrderType::Market)
2235            .instrument_id(btcusdt.id())
2236            .side(OrderSide::Buy)
2237            .quantity(Quantity::from("2.000000"))
2238            .build();
2239        let order2 = OrderTestBuilder::new(OrderType::Market)
2240            .instrument_id(btcusdt.id())
2241            .side(OrderSide::Buy)
2242            .quantity(Quantity::from("2.000000"))
2243            .build();
2244        let commission1 =
2245            calculate_commission(&btcusdt, order1.quantity(), Price::from("10500.0"), None);
2246        let fill1 = TestOrderEventStubs::filled(
2247            &order1,
2248            &btcusdt,
2249            Some(TradeId::new("1")),
2250            Some(PositionId::new("P-123456")),
2251            Some(Price::from("10500.00")),
2252            None,
2253            None,
2254            Some(commission1),
2255            None,
2256            None,
2257        );
2258        let commission2 =
2259            calculate_commission(&btcusdt, order2.quantity(), Price::from("10500.0"), None);
2260        let fill2 = TestOrderEventStubs::filled(
2261            &order2,
2262            &btcusdt,
2263            Some(TradeId::new("2")),
2264            Some(PositionId::new("P-123456")),
2265            Some(Price::from("10500.00")),
2266            None,
2267            None,
2268            Some(commission2),
2269            None,
2270            None,
2271        );
2272        let mut position = Position::new(&btcusdt, fill1.into());
2273        position.apply(&fill2.into());
2274        let pnl = position.unrealized_pnl(Price::from("11505.60"));
2275        assert_eq!(pnl, Money::from("4022.40000000 USDT"));
2276        assert_eq!(
2277            position.realized_pnl,
2278            Some(Money::from("-42.00000000 USDT"))
2279        );
2280        assert_eq!(
2281            position.commissions(),
2282            vec![Money::from("42.00000000 USDT")]
2283        );
2284    }
2285
2286    #[rstest]
2287    fn test_calculate_unrealized_pnl_for_short(currency_pair_btcusdt: CurrencyPair) {
2288        let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
2289        let order = OrderTestBuilder::new(OrderType::Market)
2290            .instrument_id(btcusdt.id())
2291            .side(OrderSide::Sell)
2292            .quantity(Quantity::from("5.912000"))
2293            .build();
2294        let commission =
2295            calculate_commission(&btcusdt, order.quantity(), Price::from("10505.60"), None);
2296        let fill = TestOrderEventStubs::filled(
2297            &order,
2298            &btcusdt,
2299            Some(TradeId::new("1")),
2300            Some(PositionId::new("P-123456")),
2301            Some(Price::from("10505.60")),
2302            None,
2303            None,
2304            Some(commission),
2305            None,
2306            None,
2307        );
2308        let position = Position::new(&btcusdt, fill.into());
2309        let pnl = position.unrealized_pnl(Price::from("10407.15"));
2310        assert_eq!(pnl, Money::from("582.03640000 USDT"));
2311        assert_eq!(
2312            position.realized_pnl,
2313            Some(Money::from("-62.10910720 USDT"))
2314        );
2315        assert_eq!(
2316            position.commissions(),
2317            vec![Money::from("62.10910720 USDT")]
2318        );
2319    }
2320
2321    #[rstest]
2322    fn test_calculate_unrealized_pnl_for_long_inverse(xbtusd_bitmex: CryptoPerpetual) {
2323        let xbtusd_bitmex = InstrumentAny::CryptoPerpetual(xbtusd_bitmex);
2324        let order = OrderTestBuilder::new(OrderType::Market)
2325            .instrument_id(xbtusd_bitmex.id())
2326            .side(OrderSide::Buy)
2327            .quantity(Quantity::from("100000"))
2328            .build();
2329        let commission = calculate_commission(
2330            &xbtusd_bitmex,
2331            order.quantity(),
2332            Price::from("10500.0"),
2333            None,
2334        );
2335        let fill = TestOrderEventStubs::filled(
2336            &order,
2337            &xbtusd_bitmex,
2338            Some(TradeId::new("1")),
2339            Some(PositionId::new("P-123456")),
2340            Some(Price::from("10500.00")),
2341            None,
2342            None,
2343            Some(commission),
2344            None,
2345            None,
2346        );
2347
2348        let position = Position::new(&xbtusd_bitmex, fill.into());
2349        let pnl = position.unrealized_pnl(Price::from("11505.60"));
2350        assert_eq!(pnl, Money::from("0.83238969 BTC"));
2351        assert_eq!(position.realized_pnl, Some(Money::from("-0.00714286 BTC")));
2352        assert_eq!(position.commissions(), vec![Money::from("0.00714286 BTC")]);
2353    }
2354
2355    #[rstest]
2356    fn test_calculate_unrealized_pnl_for_short_inverse(xbtusd_bitmex: CryptoPerpetual) {
2357        let xbtusd_bitmex = InstrumentAny::CryptoPerpetual(xbtusd_bitmex);
2358        let order = OrderTestBuilder::new(OrderType::Market)
2359            .instrument_id(xbtusd_bitmex.id())
2360            .side(OrderSide::Sell)
2361            .quantity(Quantity::from("1250000"))
2362            .build();
2363        let commission = calculate_commission(
2364            &xbtusd_bitmex,
2365            order.quantity(),
2366            Price::from("15500.00"),
2367            None,
2368        );
2369        let fill = TestOrderEventStubs::filled(
2370            &order,
2371            &xbtusd_bitmex,
2372            Some(TradeId::new("1")),
2373            Some(PositionId::new("P-123456")),
2374            Some(Price::from("15500.00")),
2375            None,
2376            None,
2377            Some(commission),
2378            None,
2379            None,
2380        );
2381        let position = Position::new(&xbtusd_bitmex, fill.into());
2382        let pnl = position.unrealized_pnl(Price::from("12506.65"));
2383
2384        assert_eq!(pnl, Money::from("19.30166700 BTC"));
2385        assert_eq!(position.realized_pnl, Some(Money::from("-0.06048387 BTC")));
2386        assert_eq!(position.commissions(), vec![Money::from("0.06048387 BTC")]);
2387    }
2388
2389    #[rstest]
2390    #[case(OrderSide::Buy, 25, 25.0)]
2391    #[case(OrderSide::Sell,25,-25.0)]
2392    fn test_signed_qty_decimal_qty_for_equity(
2393        #[case] order_side: OrderSide,
2394        #[case] quantity: i64,
2395        #[case] expected: f64,
2396        audusd_sim: CurrencyPair,
2397    ) {
2398        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2399        let order = OrderTestBuilder::new(OrderType::Market)
2400            .instrument_id(audusd_sim.id())
2401            .side(order_side)
2402            .quantity(Quantity::from(quantity))
2403            .build();
2404
2405        let commission =
2406            calculate_commission(&audusd_sim, order.quantity(), Price::from("1.0"), None);
2407        let fill = TestOrderEventStubs::filled(
2408            &order,
2409            &audusd_sim,
2410            None,
2411            Some(PositionId::from("P-123456")),
2412            None,
2413            None,
2414            None,
2415            Some(commission),
2416            None,
2417            None,
2418        );
2419        let position = Position::new(&audusd_sim, fill.into());
2420        assert_eq!(position.signed_qty, expected);
2421    }
2422
2423    #[rstest]
2424    fn test_position_with_commission_none(audusd_sim: CurrencyPair) {
2425        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2426        let fill = OrderFilledSpec::builder()
2427            .position_id(PositionId::from("1"))
2428            .build();
2429
2430        let position = Position::new(&audusd_sim, fill);
2431        assert_eq!(position.realized_pnl, Some(Money::from("0 USD")));
2432    }
2433
2434    #[rstest]
2435    fn test_position_with_commission_zero(audusd_sim: CurrencyPair) {
2436        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2437        let fill = OrderFilledSpec::builder()
2438            .position_id(PositionId::from("1"))
2439            .commission(Money::from("0 USD"))
2440            .build();
2441
2442        let position = Position::new(&audusd_sim, fill);
2443        assert_eq!(position.realized_pnl, Some(Money::from("0 USD")));
2444    }
2445
2446    #[rstest]
2447    fn test_cache_purge_order_events() {
2448        let audusd_sim = audusd_sim();
2449        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2450
2451        let order1 = OrderTestBuilder::new(OrderType::Market)
2452            .client_order_id(ClientOrderId::new("O-1"))
2453            .instrument_id(audusd_sim.id())
2454            .side(OrderSide::Buy)
2455            .quantity(Quantity::from(50_000))
2456            .build();
2457
2458        let order2 = OrderTestBuilder::new(OrderType::Market)
2459            .client_order_id(ClientOrderId::new("O-2"))
2460            .instrument_id(audusd_sim.id())
2461            .side(OrderSide::Buy)
2462            .quantity(Quantity::from(50_000))
2463            .build();
2464
2465        let position_id = PositionId::new("P-123456");
2466
2467        let fill1 = TestOrderEventStubs::filled(
2468            &order1,
2469            &audusd_sim,
2470            Some(TradeId::new("1")),
2471            Some(position_id),
2472            Some(Price::from("1.00001")),
2473            None,
2474            None,
2475            None,
2476            None,
2477            None,
2478        );
2479
2480        let mut position = Position::new(&audusd_sim, fill1.into());
2481
2482        let fill2 = TestOrderEventStubs::filled(
2483            &order2,
2484            &audusd_sim,
2485            Some(TradeId::new("2")),
2486            Some(position_id),
2487            Some(Price::from("1.00002")),
2488            None,
2489            None,
2490            None,
2491            None,
2492            None,
2493        );
2494
2495        position.apply(&fill2.into());
2496        position.purge_events_for_order(order1.client_order_id());
2497
2498        assert_eq!(position.events.len(), 1);
2499        assert_eq!(position.trade_ids.len(), 1);
2500        assert_eq!(position.events[0].client_order_id, order2.client_order_id());
2501        assert!(position.trade_ids.contains(&TradeId::new("2")));
2502    }
2503
2504    #[rstest]
2505    fn test_purge_all_events_returns_none_for_last_event_and_trade_id() {
2506        let audusd_sim = audusd_sim();
2507        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2508
2509        let order = OrderTestBuilder::new(OrderType::Market)
2510            .client_order_id(ClientOrderId::new("O-1"))
2511            .instrument_id(audusd_sim.id())
2512            .side(OrderSide::Buy)
2513            .quantity(Quantity::from(100_000))
2514            .build();
2515
2516        let position_id = PositionId::new("P-123456");
2517        let fill = TestOrderEventStubs::filled(
2518            &order,
2519            &audusd_sim,
2520            Some(TradeId::new("1")),
2521            Some(position_id),
2522            Some(Price::from("1.00050")),
2523            None,
2524            None,
2525            None,
2526            Some(UnixNanos::from(1_000_000_000)), // Explicit non-zero timestamp
2527            None,
2528        );
2529
2530        let mut position = Position::new(&audusd_sim, fill.into());
2531
2532        assert_eq!(position.events.len(), 1);
2533        assert!(position.last_event().is_some());
2534        assert!(position.last_trade_id().is_some());
2535
2536        // Store original timestamps (should be non-zero)
2537        let original_ts_opened = position.ts_opened;
2538        let original_ts_last = position.ts_last;
2539        assert_ne!(original_ts_opened, UnixNanos::default());
2540        assert_ne!(original_ts_last, UnixNanos::default());
2541
2542        position.purge_events_for_order(order.client_order_id());
2543
2544        assert_eq!(position.events.len(), 0);
2545        assert_eq!(position.trade_ids.len(), 0);
2546        assert!(position.last_event().is_none());
2547        assert!(position.last_trade_id().is_none());
2548
2549        // Verify timestamps are zeroed - empty shell has no meaningful history
2550        // ts_closed is set to Some(0) so position reports as closed and is eligible for purge
2551        assert_eq!(position.ts_opened, UnixNanos::default());
2552        assert_eq!(position.ts_last, UnixNanos::default());
2553        assert_eq!(position.ts_closed, Some(UnixNanos::default()));
2554        assert_eq!(position.duration_ns, 0);
2555
2556        // Verify empty shell reports as closed (this was the bug we fixed!)
2557        // is_closed() must return true so cache purge logic recognizes empty shells
2558        assert!(position.is_closed());
2559        assert!(!position.is_open());
2560        assert_eq!(position.side, PositionSide::Flat);
2561    }
2562
2563    #[rstest]
2564    fn test_revive_from_empty_shell(audusd_sim: CurrencyPair) {
2565        // Test adding a fill to an empty shell position
2566        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2567
2568        // Create and then purge position to get empty shell
2569        let order1 = OrderTestBuilder::new(OrderType::Market)
2570            .instrument_id(audusd_sim.id())
2571            .side(OrderSide::Buy)
2572            .quantity(Quantity::from(100_000))
2573            .build();
2574
2575        let fill1 = TestOrderEventStubs::filled(
2576            &order1,
2577            &audusd_sim,
2578            None,
2579            Some(PositionId::new("P-1")),
2580            Some(Price::from("1.00000")),
2581            None,
2582            None,
2583            None,
2584            Some(UnixNanos::from(1_000_000_000)),
2585            None,
2586        );
2587
2588        let mut position = Position::new(&audusd_sim, fill1.into());
2589        position.purge_events_for_order(order1.client_order_id());
2590
2591        // Verify it's an empty shell
2592        assert!(position.is_closed());
2593        assert_eq!(position.ts_closed, Some(UnixNanos::default()));
2594        assert_eq!(position.event_count(), 0);
2595
2596        // Add new fill to revive the position
2597        let order2 = OrderTestBuilder::new(OrderType::Market)
2598            .instrument_id(audusd_sim.id())
2599            .side(OrderSide::Buy)
2600            .quantity(Quantity::from(50_000))
2601            .build();
2602
2603        let fill2 = TestOrderEventStubs::filled(
2604            &order2,
2605            &audusd_sim,
2606            None,
2607            Some(PositionId::new("P-1")),
2608            Some(Price::from("1.00020")),
2609            None,
2610            None,
2611            None,
2612            Some(UnixNanos::from(3_000_000_000)),
2613            None,
2614        );
2615
2616        let fill2_typed: OrderFilled = fill2.clone().into();
2617        position.apply(&fill2_typed);
2618
2619        // Position should be alive with new timestamps
2620        assert!(position.is_long());
2621        assert!(!position.is_closed());
2622        assert!(position.ts_closed.is_none());
2623        assert_eq!(position.ts_opened, fill2.ts_event());
2624        assert_eq!(position.ts_last, fill2.ts_event());
2625        assert_eq!(position.event_count(), 1);
2626        assert_eq!(position.quantity, Quantity::from(50_000));
2627    }
2628
2629    #[rstest]
2630    fn test_empty_shell_position_invariants(audusd_sim: CurrencyPair) {
2631        // Property-based test: Any position with event_count == 0 must satisfy invariants
2632        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2633
2634        let order = OrderTestBuilder::new(OrderType::Market)
2635            .instrument_id(audusd_sim.id())
2636            .side(OrderSide::Buy)
2637            .quantity(Quantity::from(100_000))
2638            .build();
2639
2640        let fill = TestOrderEventStubs::filled(
2641            &order,
2642            &audusd_sim,
2643            None,
2644            Some(PositionId::new("P-1")),
2645            Some(Price::from("1.00000")),
2646            None,
2647            None,
2648            None,
2649            Some(UnixNanos::from(1_000_000_000)),
2650            None,
2651        );
2652
2653        let mut position = Position::new(&audusd_sim, fill.into());
2654        position.purge_events_for_order(order.client_order_id());
2655
2656        // INVARIANTS: When event_count == 0, the following MUST be true
2657        assert_eq!(
2658            position.event_count(),
2659            0,
2660            "Precondition: event_count must be 0"
2661        );
2662
2663        // Invariant 1: Position must report as closed
2664        assert!(
2665            position.is_closed(),
2666            "INV1: Empty shell must report is_closed() == true"
2667        );
2668        assert!(
2669            !position.is_open(),
2670            "INV1: Empty shell must report is_open() == false"
2671        );
2672
2673        // Invariant 2: Position must be FLAT
2674        assert_eq!(
2675            position.side,
2676            PositionSide::Flat,
2677            "INV2: Empty shell must be FLAT"
2678        );
2679
2680        // Invariant 3: ts_closed must be Some (not None)
2681        assert!(
2682            position.ts_closed.is_some(),
2683            "INV3: Empty shell must have ts_closed.is_some()"
2684        );
2685        assert_eq!(
2686            position.ts_closed,
2687            Some(UnixNanos::default()),
2688            "INV3: Empty shell ts_closed must be 0"
2689        );
2690
2691        // Invariant 4: All lifecycle timestamps must be zeroed
2692        assert_eq!(
2693            position.ts_opened,
2694            UnixNanos::default(),
2695            "INV4: Empty shell ts_opened must be 0"
2696        );
2697        assert_eq!(
2698            position.ts_last,
2699            UnixNanos::default(),
2700            "INV4: Empty shell ts_last must be 0"
2701        );
2702        assert_eq!(
2703            position.duration_ns, 0,
2704            "INV4: Empty shell duration_ns must be 0"
2705        );
2706
2707        // Invariant 5: Quantity must be zero
2708        assert_eq!(
2709            position.quantity,
2710            Quantity::zero(audusd_sim.size_precision()),
2711            "INV5: Empty shell quantity must be 0"
2712        );
2713
2714        // Invariant 6: No events or trade IDs
2715        assert!(
2716            position.events.is_empty(),
2717            "INV6: Empty shell must have no events"
2718        );
2719        assert!(
2720            position.trade_ids.is_empty(),
2721            "INV6: Empty shell must have no trade IDs"
2722        );
2723        assert!(
2724            position.last_event().is_none(),
2725            "INV6: Empty shell must have no last event"
2726        );
2727        assert!(
2728            position.last_trade_id().is_none(),
2729            "INV6: Empty shell must have no last trade ID"
2730        );
2731    }
2732
2733    #[rstest]
2734    fn test_position_pnl_precision_with_very_small_amounts(audusd_sim: CurrencyPair) {
2735        // Tests behavior with very small commission amounts
2736        // NOTE: Amounts below f64 epsilon (~1e-15) may be lost to precision
2737        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2738        let order = OrderTestBuilder::new(OrderType::Market)
2739            .instrument_id(audusd_sim.id())
2740            .side(OrderSide::Buy)
2741            .quantity(Quantity::from(100))
2742            .build();
2743
2744        // Test with a commission that won't be lost to Money precision (0.01 USD)
2745        let small_commission = Money::new(0.01, Currency::USD());
2746        let fill = TestOrderEventStubs::filled(
2747            &order,
2748            &audusd_sim,
2749            None,
2750            None,
2751            Some(Price::from("1.00001")),
2752            Some(Quantity::from(100)),
2753            None,
2754            Some(small_commission),
2755            None,
2756            None,
2757        );
2758
2759        let position = Position::new(&audusd_sim, fill.into());
2760
2761        // Commission is recorded and preserved in f64 arithmetic
2762        assert_eq!(position.commissions().len(), 1);
2763        let recorded_commission = position.commissions()[0];
2764        assert!(
2765            recorded_commission.as_f64() > 0.0,
2766            "Commission of 0.01 should be preserved"
2767        );
2768
2769        // Realized PnL should include commission (negative)
2770        let realized = position.realized_pnl.unwrap().as_f64();
2771        assert!(
2772            realized < 0.0,
2773            "Realized PnL should be negative due to commission"
2774        );
2775    }
2776
2777    #[rstest]
2778    fn test_position_pnl_precision_with_high_precision_instrument() {
2779        // Tests precision with high-precision crypto instrument
2780        use crate::instruments::stubs::crypto_perpetual_ethusdt;
2781        let ethusdt = crypto_perpetual_ethusdt();
2782        let ethusdt = InstrumentAny::CryptoPerpetual(ethusdt);
2783
2784        // Check instrument precision
2785        let size_precision = ethusdt.size_precision();
2786
2787        let order = OrderTestBuilder::new(OrderType::Market)
2788            .instrument_id(ethusdt.id())
2789            .side(OrderSide::Buy)
2790            .quantity(Quantity::from("1.123456789"))
2791            .build();
2792
2793        let fill = TestOrderEventStubs::filled(
2794            &order,
2795            &ethusdt,
2796            None,
2797            None,
2798            Some(Price::from("2345.123456789")),
2799            Some(Quantity::from("1.123456789")),
2800            None,
2801            Some(Money::from("0.1 USDT")),
2802            None,
2803            None,
2804        );
2805
2806        let position = Position::new(&ethusdt, fill.into());
2807
2808        // Verify high-precision price is preserved in f64 (within tolerance)
2809        let avg_px = position.avg_px_open;
2810        assert!(
2811            (avg_px - 2_345.123_456_789).abs() < 1e-6,
2812            "High precision price should be preserved within f64 tolerance"
2813        );
2814
2815        // Quantity will be rounded to instrument's size_precision
2816        // Verify it matches the instrument's precision
2817        assert_eq!(
2818            position.quantity.precision, size_precision,
2819            "Quantity precision should match instrument"
2820        );
2821
2822        // f64 representation will be close but may have rounding based on precision
2823        let qty_f64 = position.quantity.as_f64();
2824        assert!(
2825            qty_f64 > 1.0 && qty_f64 < 2.0,
2826            "Quantity should be in expected range"
2827        );
2828    }
2829
2830    #[rstest]
2831    fn test_position_pnl_accumulation_across_many_fills(audusd_sim: CurrencyPair) {
2832        // Tests precision drift across 100 fills
2833        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2834        let order = OrderTestBuilder::new(OrderType::Market)
2835            .instrument_id(audusd_sim.id())
2836            .side(OrderSide::Buy)
2837            .quantity(Quantity::from(1000))
2838            .build();
2839
2840        let initial_fill = TestOrderEventStubs::filled(
2841            &order,
2842            &audusd_sim,
2843            Some(TradeId::new("1")),
2844            None,
2845            Some(Price::from("1.00000")),
2846            Some(Quantity::from(10)),
2847            None,
2848            Some(Money::from("0.01 USD")),
2849            None,
2850            None,
2851        );
2852
2853        let mut position = Position::new(&audusd_sim, initial_fill.into());
2854
2855        // Apply 99 more fills with varying prices
2856        for i in 2..=100 {
2857            let price_offset = f64::from(i) * 0.00001;
2858            let fill = TestOrderEventStubs::filled(
2859                &order,
2860                &audusd_sim,
2861                Some(TradeId::new(i.to_string())),
2862                None,
2863                Some(Price::from(&format!("{:.5}", 1.0 + price_offset))),
2864                Some(Quantity::from(10)),
2865                None,
2866                Some(Money::from("0.01 USD")),
2867                None,
2868                None,
2869            );
2870            position.apply(&fill.into());
2871        }
2872
2873        // Verify we accumulated 100 fills
2874        assert_eq!(position.events.len(), 100);
2875        assert_eq!(position.quantity, Quantity::from(1000));
2876
2877        // Verify commissions accumulated (should be 100 * 0.01 = 1.0 USD)
2878        let total_commission: f64 = position.commissions().iter().map(|c| c.as_f64()).sum();
2879        assert!(
2880            (total_commission - 1.0).abs() < 1e-10,
2881            "Commission accumulation should be accurate: expected 1.0, was {total_commission}"
2882        );
2883
2884        // Verify average price is reasonable (should be around 1.0005)
2885        let avg_px = position.avg_px_open;
2886        assert!(
2887            avg_px > 1.0 && avg_px < 1.001,
2888            "Average price should be reasonable: got {avg_px}"
2889        );
2890    }
2891
2892    #[rstest]
2893    fn test_position_pnl_with_extreme_price_values(audusd_sim: CurrencyPair) {
2894        // Tests position handling with very large and very small prices
2895        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2896
2897        // Test with very small price
2898        let order_small = OrderTestBuilder::new(OrderType::Market)
2899            .instrument_id(audusd_sim.id())
2900            .side(OrderSide::Buy)
2901            .quantity(Quantity::from(100_000))
2902            .build();
2903
2904        let fill_small = TestOrderEventStubs::filled(
2905            &order_small,
2906            &audusd_sim,
2907            None,
2908            None,
2909            Some(Price::from("0.00001")),
2910            Some(Quantity::from(100_000)),
2911            None,
2912            None,
2913            None,
2914            None,
2915        );
2916
2917        let position_small = Position::new(&audusd_sim, fill_small.into());
2918        assert_eq!(position_small.avg_px_open, 0.00001);
2919
2920        // Verify notional calculation doesn't underflow
2921        let last_price_small = Price::from("0.00002");
2922        let unrealized = position_small.unrealized_pnl(last_price_small);
2923        assert!(
2924            unrealized.as_f64() > 0.0,
2925            "Unrealized PnL should be positive when price doubles"
2926        );
2927
2928        // Test with very large price
2929        let order_large = OrderTestBuilder::new(OrderType::Market)
2930            .instrument_id(audusd_sim.id())
2931            .side(OrderSide::Buy)
2932            .quantity(Quantity::from(100))
2933            .build();
2934
2935        let fill_large = TestOrderEventStubs::filled(
2936            &order_large,
2937            &audusd_sim,
2938            None,
2939            None,
2940            Some(Price::from("99999.99999")),
2941            Some(Quantity::from(100)),
2942            None,
2943            None,
2944            None,
2945            None,
2946        );
2947
2948        let position_large = Position::new(&audusd_sim, fill_large.into());
2949        assert!(
2950            (position_large.avg_px_open - 99999.99999).abs() < 1e-6,
2951            "Large price should be preserved within f64 tolerance"
2952        );
2953    }
2954
2955    #[rstest]
2956    fn test_position_pnl_roundtrip_precision(audusd_sim: CurrencyPair) {
2957        // Tests that opening and closing a position preserves precision
2958        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
2959        let buy_order = OrderTestBuilder::new(OrderType::Market)
2960            .instrument_id(audusd_sim.id())
2961            .side(OrderSide::Buy)
2962            .quantity(Quantity::from(100_000))
2963            .build();
2964
2965        let sell_order = OrderTestBuilder::new(OrderType::Market)
2966            .instrument_id(audusd_sim.id())
2967            .side(OrderSide::Sell)
2968            .quantity(Quantity::from(100_000))
2969            .build();
2970
2971        // Open at precise price
2972        let open_fill = TestOrderEventStubs::filled(
2973            &buy_order,
2974            &audusd_sim,
2975            Some(TradeId::new("1")),
2976            None,
2977            Some(Price::from("1.123456")),
2978            None,
2979            None,
2980            Some(Money::from("0.50 USD")),
2981            None,
2982            None,
2983        );
2984
2985        let mut position = Position::new(&audusd_sim, open_fill.into());
2986
2987        // Close at same price (no profit/loss except commission)
2988        let close_fill = TestOrderEventStubs::filled(
2989            &sell_order,
2990            &audusd_sim,
2991            Some(TradeId::new("2")),
2992            None,
2993            Some(Price::from("1.123456")),
2994            None,
2995            None,
2996            Some(Money::from("0.50 USD")),
2997            None,
2998            None,
2999        );
3000
3001        position.apply(&close_fill.into());
3002
3003        // Position should be flat
3004        assert!(position.is_closed());
3005
3006        // Realized PnL should be exactly -1.0 USD (two commissions of 0.50)
3007        let realized = position.realized_pnl.unwrap().as_f64();
3008        assert!(
3009            (realized - (-1.0)).abs() < 1e-10,
3010            "Realized PnL should be exactly -1.0 USD (commissions), was {realized}"
3011        );
3012    }
3013
3014    #[rstest]
3015    fn test_position_commission_in_base_currency_buy() {
3016        // Test that commission in base currency reduces position quantity on buy (SPOT only)
3017        let btc_usdt = currency_pair_btcusdt();
3018        let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
3019
3020        let order = OrderTestBuilder::new(OrderType::Market)
3021            .instrument_id(btc_usdt.id())
3022            .side(OrderSide::Buy)
3023            .quantity(Quantity::from("1.0"))
3024            .build();
3025
3026        // Buy 1.0 BTC with 0.001 BTC commission
3027        let fill = match TestOrderEventStubs::filled(
3028            &order,
3029            &btc_usdt,
3030            Some(TradeId::new("1")),
3031            None,
3032            Some(Price::from("50000.0")),
3033            Some(Quantity::from("1.0")),
3034            None,
3035            Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
3036            None,
3037            None,
3038        ) {
3039            OrderEventAny::Filled(fill) => fill,
3040            _ => unreachable!(),
3041        };
3042
3043        let position = Position::new(&btc_usdt, fill);
3044        let replayed_position = Position::new(&btc_usdt, fill);
3045
3046        // Position quantity should be 1.0 - 0.001 = 0.999 BTC
3047        assert!(
3048            (position.quantity.as_f64() - 0.999).abs() < 1e-9,
3049            "Position quantity should be 0.999 BTC (1.0 - 0.001 commission), was {}",
3050            position.quantity.as_f64()
3051        );
3052
3053        // Signed qty should also be 0.999
3054        assert!(
3055            (position.signed_qty - 0.999).abs() < 1e-9,
3056            "Signed qty should be 0.999, was {}",
3057            position.signed_qty
3058        );
3059
3060        // Verify PositionAdjusted event was created
3061        assert_eq!(
3062            position.adjustments.len(),
3063            1,
3064            "Should have 1 adjustment event"
3065        );
3066        let adjustment = &position.adjustments[0];
3067        assert_eq!(
3068            adjustment.adjustment_type,
3069            PositionAdjustmentType::Commission
3070        );
3071        assert_eq!(
3072            adjustment.quantity_change,
3073            Some(rust_decimal_macros::dec!(-0.001))
3074        );
3075        assert_eq!(adjustment.pnl_change, None);
3076        assert_eq!(
3077            adjustment.event_id,
3078            replayed_position.adjustments[0].event_id
3079        );
3080    }
3081
3082    #[rstest]
3083    fn test_position_commission_in_base_currency_sell() {
3084        // Test that commission in base currency increases short position on sell
3085        let btc_usdt = currency_pair_btcusdt();
3086        let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
3087
3088        let order = OrderTestBuilder::new(OrderType::Market)
3089            .instrument_id(btc_usdt.id())
3090            .side(OrderSide::Sell)
3091            .quantity(Quantity::from("1.0"))
3092            .build();
3093
3094        // Sell 1.0 BTC with 0.001 BTC commission
3095        let fill = TestOrderEventStubs::filled(
3096            &order,
3097            &btc_usdt,
3098            Some(TradeId::new("1")),
3099            None,
3100            Some(Price::from("50000.0")),
3101            Some(Quantity::from("1.0")),
3102            None,
3103            Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
3104            None,
3105            None,
3106        );
3107
3108        let position = Position::new(&btc_usdt, fill.into());
3109
3110        // Position quantity should be 1.0 + 0.001 = 1.001 BTC
3111        // (you sold 1.0 and paid 0.001 commission, so total short exposure is 1.001)
3112        assert!(
3113            (position.quantity.as_f64() - 1.001).abs() < 1e-9,
3114            "Position quantity should be 1.001 BTC (1.0 + 0.001 commission), was {}",
3115            position.quantity.as_f64()
3116        );
3117
3118        // Signed qty should be -1.001 (short position)
3119        assert!(
3120            (position.signed_qty - (-1.001)).abs() < 1e-9,
3121            "Signed qty should be -1.001, was {}",
3122            position.signed_qty
3123        );
3124
3125        // Verify PositionAdjusted event was created
3126        assert_eq!(
3127            position.adjustments.len(),
3128            1,
3129            "Should have 1 adjustment event"
3130        );
3131        let adjustment = &position.adjustments[0];
3132        assert_eq!(
3133            adjustment.adjustment_type,
3134            PositionAdjustmentType::Commission
3135        );
3136        // For sell, commission increases the short (negative adjustment)
3137        assert_eq!(
3138            adjustment.quantity_change,
3139            Some(rust_decimal_macros::dec!(-0.001))
3140        );
3141        assert_eq!(adjustment.pnl_change, None);
3142    }
3143
3144    #[rstest]
3145    fn test_position_commission_in_quote_currency_no_adjustment() {
3146        // Test that commission in quote currency does NOT reduce position quantity
3147        let btc_usdt = currency_pair_btcusdt();
3148        let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
3149
3150        let order = OrderTestBuilder::new(OrderType::Market)
3151            .instrument_id(btc_usdt.id())
3152            .side(OrderSide::Buy)
3153            .quantity(Quantity::from("1.0"))
3154            .build();
3155
3156        // Buy 1.0 BTC with 50 USDT commission (in quote currency)
3157        let fill = TestOrderEventStubs::filled(
3158            &order,
3159            &btc_usdt,
3160            Some(TradeId::new("1")),
3161            None,
3162            Some(Price::from("50000.0")),
3163            Some(Quantity::from("1.0")),
3164            None,
3165            Some(Money::new(50.0, Currency::USD())),
3166            None,
3167            None,
3168        );
3169
3170        let position = Position::new(&btc_usdt, fill.into());
3171
3172        // Position quantity should be exactly 1.0 BTC (no adjustment)
3173        assert!(
3174            (position.quantity.as_f64() - 1.0).abs() < 1e-9,
3175            "Position quantity should be 1.0 BTC (no adjustment for quote currency commission), was {}",
3176            position.quantity.as_f64()
3177        );
3178
3179        // Verify NO PositionAdjusted event was created (commission in quote currency)
3180        assert_eq!(
3181            position.adjustments.len(),
3182            0,
3183            "Should have no adjustment events for quote currency commission"
3184        );
3185    }
3186
3187    #[rstest]
3188    fn test_position_reset_clears_adjustments() {
3189        // Test that closing and reopening a position clears adjustment history
3190        let btc_usdt = currency_pair_btcusdt();
3191        let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
3192
3193        // Open long position with commission adjustment
3194        let buy_order = OrderTestBuilder::new(OrderType::Market)
3195            .instrument_id(btc_usdt.id())
3196            .side(OrderSide::Buy)
3197            .quantity(Quantity::from("1.0"))
3198            .build();
3199
3200        let buy_fill = TestOrderEventStubs::filled(
3201            &buy_order,
3202            &btc_usdt,
3203            Some(TradeId::new("1")),
3204            None,
3205            Some(Price::from("50000.0")),
3206            Some(Quantity::from("1.0")),
3207            None,
3208            Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
3209            None,
3210            None,
3211        );
3212
3213        let mut position = Position::new(&btc_usdt, buy_fill.into());
3214        assert_eq!(position.adjustments.len(), 1, "Should have 1 adjustment");
3215
3216        // Close the position (sell the actual quantity, use quote currency commission to avoid complexity)
3217        let sell_order = OrderTestBuilder::new(OrderType::Market)
3218            .instrument_id(btc_usdt.id())
3219            .side(OrderSide::Sell)
3220            .quantity(Quantity::from("0.999"))
3221            .build();
3222
3223        let sell_fill = TestOrderEventStubs::filled(
3224            &sell_order,
3225            &btc_usdt,
3226            Some(TradeId::new("2")),
3227            None,
3228            Some(Price::from("51000.0")),
3229            Some(Quantity::from("0.999")),
3230            None,
3231            Some(Money::new(50.0, Currency::USD())), // Quote currency commission - no adjustment
3232            None,
3233            None,
3234        );
3235
3236        position.apply(&sell_fill.into());
3237        assert_eq!(position.side, PositionSide::Flat);
3238        assert_eq!(
3239            position.adjustments.len(),
3240            1,
3241            "Should still have 1 adjustment (no new one from quote commission)"
3242        );
3243
3244        // Reopen the position - adjustments should be cleared
3245        let buy_order2 = OrderTestBuilder::new(OrderType::Market)
3246            .instrument_id(btc_usdt.id())
3247            .side(OrderSide::Buy)
3248            .quantity(Quantity::from("2.0"))
3249            .build();
3250
3251        let buy_fill2 = TestOrderEventStubs::filled(
3252            &buy_order2,
3253            &btc_usdt,
3254            Some(TradeId::new("3")),
3255            None,
3256            Some(Price::from("52000.0")),
3257            Some(Quantity::from("2.0")),
3258            None,
3259            Some(Money::new(0.002, btc_usdt.base_currency().unwrap())),
3260            None,
3261            None,
3262        );
3263
3264        position.apply(&buy_fill2.into());
3265
3266        // Verify adjustments were cleared and only new adjustment exists
3267        assert_eq!(
3268            position.adjustments.len(),
3269            1,
3270            "Adjustments should be cleared on position reset, only new adjustment"
3271        );
3272        assert_eq!(
3273            position.adjustments[0].quantity_change,
3274            Some(rust_decimal_macros::dec!(-0.002)),
3275            "New adjustment should be for the new fill"
3276        );
3277        assert_eq!(position.events.len(), 1, "Events should also be reset");
3278    }
3279
3280    #[rstest]
3281    fn test_purge_events_for_order_clears_adjustments_when_flat() {
3282        // Test that purging all fills clears adjustment history
3283        let btc_usdt = currency_pair_btcusdt();
3284        let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
3285
3286        let order = OrderTestBuilder::new(OrderType::Market)
3287            .instrument_id(btc_usdt.id())
3288            .side(OrderSide::Buy)
3289            .quantity(Quantity::from("1.0"))
3290            .build();
3291
3292        let fill = TestOrderEventStubs::filled(
3293            &order,
3294            &btc_usdt,
3295            Some(TradeId::new("1")),
3296            None,
3297            Some(Price::from("50000.0")),
3298            Some(Quantity::from("1.0")),
3299            None,
3300            Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
3301            None,
3302            None,
3303        );
3304
3305        let mut position = Position::new(&btc_usdt, fill.into());
3306        assert_eq!(position.adjustments.len(), 1, "Should have 1 adjustment");
3307        assert_eq!(position.events.len(), 1);
3308
3309        // Purge the only fill - should go to flat and clear everything
3310        position.purge_events_for_order(order.client_order_id());
3311
3312        assert_eq!(position.side, PositionSide::Flat);
3313        assert_eq!(position.events.len(), 0, "Events should be cleared");
3314        assert_eq!(
3315            position.adjustments.len(),
3316            0,
3317            "Adjustments should be cleared when position goes flat"
3318        );
3319        assert_eq!(position.quantity, Quantity::zero(btc_usdt.size_precision()));
3320    }
3321
3322    #[rstest]
3323    fn test_purge_events_for_order_clears_adjustments_on_rebuild() {
3324        // Test that rebuilding position from remaining fills clears and recreates adjustments
3325        let btc_usdt = currency_pair_btcusdt();
3326        let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
3327
3328        // First fill with adjustment
3329        let order1 = OrderTestBuilder::new(OrderType::Market)
3330            .instrument_id(btc_usdt.id())
3331            .side(OrderSide::Buy)
3332            .quantity(Quantity::from("1.0"))
3333            .client_order_id(ClientOrderId::new("O-001"))
3334            .build();
3335
3336        let fill1 = TestOrderEventStubs::filled(
3337            &order1,
3338            &btc_usdt,
3339            Some(TradeId::new("1")),
3340            None,
3341            Some(Price::from("50000.0")),
3342            Some(Quantity::from("1.0")),
3343            None,
3344            Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
3345            None,
3346            None,
3347        );
3348
3349        let mut position = Position::new(&btc_usdt, fill1.into());
3350        assert_eq!(position.adjustments.len(), 1);
3351
3352        // Second fill with different order and adjustment
3353        let order2 = OrderTestBuilder::new(OrderType::Market)
3354            .instrument_id(btc_usdt.id())
3355            .side(OrderSide::Buy)
3356            .quantity(Quantity::from("2.0"))
3357            .client_order_id(ClientOrderId::new("O-002"))
3358            .build();
3359
3360        let fill2 = TestOrderEventStubs::filled(
3361            &order2,
3362            &btc_usdt,
3363            Some(TradeId::new("2")),
3364            None,
3365            Some(Price::from("51000.0")),
3366            Some(Quantity::from("2.0")),
3367            None,
3368            Some(Money::new(0.002, btc_usdt.base_currency().unwrap())),
3369            None,
3370            None,
3371        );
3372
3373        position.apply(&fill2.into());
3374        assert_eq!(position.adjustments.len(), 2, "Should have 2 adjustments");
3375        assert_eq!(position.events.len(), 2);
3376
3377        // Purge first order - should rebuild from remaining fill
3378        position.purge_events_for_order(order1.client_order_id());
3379
3380        assert_eq!(position.events.len(), 1, "Should have 1 remaining event");
3381        assert_eq!(
3382            position.adjustments.len(),
3383            1,
3384            "Should have only the adjustment from remaining fill"
3385        );
3386        assert_eq!(
3387            position.adjustments[0].quantity_change,
3388            Some(rust_decimal_macros::dec!(-0.002)),
3389            "Should be the adjustment from order2"
3390        );
3391        assert!(
3392            (position.quantity.as_f64() - 1.998).abs() < 1e-9,
3393            "Quantity should be 2.0 - 0.002 commission"
3394        );
3395    }
3396
3397    #[rstest]
3398    fn test_purge_events_preserves_manual_adjustments() {
3399        // Test that manual adjustments (e.g., funding payments) are preserved when purging unrelated fills
3400        let btc_usdt = currency_pair_btcusdt();
3401        let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
3402
3403        // First fill
3404        let order1 = OrderTestBuilder::new(OrderType::Market)
3405            .instrument_id(btc_usdt.id())
3406            .side(OrderSide::Buy)
3407            .quantity(Quantity::from("1.0"))
3408            .client_order_id(ClientOrderId::new("O-001"))
3409            .build();
3410
3411        let fill1 = TestOrderEventStubs::filled(
3412            &order1,
3413            &btc_usdt,
3414            Some(TradeId::new("1")),
3415            None,
3416            Some(Price::from("50000.0")),
3417            Some(Quantity::from("1.0")),
3418            None,
3419            Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
3420            None,
3421            None,
3422        );
3423
3424        let mut position = Position::new(&btc_usdt, fill1.into());
3425        assert_eq!(position.adjustments.len(), 1);
3426
3427        // Apply a manual funding payment adjustment (no reason field)
3428        let funding_adjustment = PositionAdjusted::new(
3429            position.trader_id,
3430            position.strategy_id,
3431            position.instrument_id,
3432            position.id,
3433            position.account_id,
3434            PositionAdjustmentType::Funding,
3435            None,
3436            Some(Money::new(10.0, btc_usdt.quote_currency())),
3437            None, // No reason - this is a manual adjustment
3438            uuid4(),
3439            UnixNanos::default(),
3440            UnixNanos::default(),
3441        );
3442        position.apply_adjustment(funding_adjustment);
3443        assert_eq!(position.adjustments.len(), 2);
3444
3445        // Second fill with different order
3446        let order2 = OrderTestBuilder::new(OrderType::Market)
3447            .instrument_id(btc_usdt.id())
3448            .side(OrderSide::Buy)
3449            .quantity(Quantity::from("2.0"))
3450            .client_order_id(ClientOrderId::new("O-002"))
3451            .build();
3452
3453        let fill2 = TestOrderEventStubs::filled(
3454            &order2,
3455            &btc_usdt,
3456            Some(TradeId::new("2")),
3457            None,
3458            Some(Price::from("51000.0")),
3459            Some(Quantity::from("2.0")),
3460            None,
3461            Some(Money::new(0.002, btc_usdt.base_currency().unwrap())),
3462            None,
3463            None,
3464        );
3465
3466        position.apply(&fill2.into());
3467        assert_eq!(
3468            position.adjustments.len(),
3469            3,
3470            "Should have 3 adjustments: 2 commissions + 1 funding"
3471        );
3472
3473        // Purge first order - manual funding adjustment should be preserved
3474        position.purge_events_for_order(order1.client_order_id());
3475
3476        assert_eq!(position.events.len(), 1, "Should have 1 remaining event");
3477        assert_eq!(
3478            position.adjustments.len(),
3479            2,
3480            "Should have funding adjustment + commission from remaining fill"
3481        );
3482
3483        // Verify funding adjustment is preserved
3484        let has_funding = position.adjustments.iter().any(|adj| {
3485            adj.adjustment_type == PositionAdjustmentType::Funding
3486                && adj.pnl_change == Some(Money::new(10.0, btc_usdt.quote_currency()))
3487        });
3488        assert!(has_funding, "Funding adjustment should be preserved");
3489
3490        // Verify realized_pnl includes the funding payment
3491        // Note: Commission is in BTC (base currency), so it doesn't directly affect USDT realized_pnl
3492        assert_eq!(
3493            position.realized_pnl,
3494            Some(Money::new(10.0, btc_usdt.quote_currency())),
3495            "Realized PnL should be the funding payment only (commission is in BTC, not USDT)"
3496        );
3497    }
3498
3499    #[rstest]
3500    fn test_position_commission_affects_buy_and_sell_qty() {
3501        // Test that commission in base currency affects both buy_qty and sell_qty tracking
3502        let btc_usdt = currency_pair_btcusdt();
3503        let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
3504
3505        let buy_order = OrderTestBuilder::new(OrderType::Market)
3506            .instrument_id(btc_usdt.id())
3507            .side(OrderSide::Buy)
3508            .quantity(Quantity::from("1.0"))
3509            .build();
3510
3511        // Buy 1.0 BTC with 0.001 BTC commission
3512        let fill = TestOrderEventStubs::filled(
3513            &buy_order,
3514            &btc_usdt,
3515            Some(TradeId::new("1")),
3516            None,
3517            Some(Price::from("50000.0")),
3518            Some(Quantity::from("1.0")),
3519            None,
3520            Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
3521            None,
3522            None,
3523        );
3524
3525        let position = Position::new(&btc_usdt, fill.into());
3526
3527        // buy_qty tracks order fills (1.0 BTC), adjustments tracked separately
3528        assert!(
3529            (position.buy_qty.as_f64() - 1.0).abs() < 1e-9,
3530            "buy_qty should be 1.0 (order fill amount), was {}",
3531            position.buy_qty.as_f64()
3532        );
3533
3534        // Position quantity reflects both order fill and commission adjustment
3535        assert!(
3536            (position.quantity.as_f64() - 0.999).abs() < 1e-9,
3537            "position.quantity should be 0.999 (1.0 - 0.001 commission), was {}",
3538            position.quantity.as_f64()
3539        );
3540
3541        // Adjustment event tracks the commission
3542        assert_eq!(position.adjustments.len(), 1);
3543        assert_eq!(
3544            position.adjustments[0].quantity_change,
3545            Some(rust_decimal_macros::dec!(-0.001))
3546        );
3547    }
3548
3549    #[rstest]
3550    fn test_position_perpetual_commission_no_adjustment() {
3551        // Test that perpetuals/futures do NOT adjust quantity for base currency commission
3552        let eth_perp = crypto_perpetual_ethusdt();
3553        let eth_perp = InstrumentAny::CryptoPerpetual(eth_perp);
3554
3555        let order = OrderTestBuilder::new(OrderType::Market)
3556            .instrument_id(eth_perp.id())
3557            .side(OrderSide::Buy)
3558            .quantity(Quantity::from("1.0"))
3559            .build();
3560
3561        // Buy 1.0 ETH-PERP contracts with 0.001 ETH commission
3562        let fill = TestOrderEventStubs::filled(
3563            &order,
3564            &eth_perp,
3565            Some(TradeId::new("1")),
3566            None,
3567            Some(Price::from("3000.0")),
3568            Some(Quantity::from("1.0")),
3569            None,
3570            Some(Money::new(0.001, eth_perp.base_currency().unwrap())),
3571            None,
3572            None,
3573        );
3574
3575        let position = Position::new(&eth_perp, fill.into());
3576
3577        // Position quantity should be exactly 1.0 (NO adjustment for derivatives)
3578        assert!(
3579            (position.quantity.as_f64() - 1.0).abs() < 1e-9,
3580            "Perpetual position should be 1.0 contracts (no adjustment), was {}",
3581            position.quantity.as_f64()
3582        );
3583
3584        // Signed qty should also be 1.0
3585        assert!(
3586            (position.signed_qty - 1.0).abs() < 1e-9,
3587            "Signed qty should be 1.0, was {}",
3588            position.signed_qty
3589        );
3590    }
3591
3592    #[rstest]
3593    fn test_signed_decimal_qty_long(stub_position_long: Position) {
3594        let signed_qty = stub_position_long.signed_decimal_qty();
3595        assert!(signed_qty > Decimal::ZERO);
3596        assert_eq!(
3597            signed_qty,
3598            Decimal::try_from(stub_position_long.signed_qty).unwrap()
3599        );
3600    }
3601
3602    #[rstest]
3603    fn test_signed_decimal_qty_short(stub_position_short: Position) {
3604        let signed_qty = stub_position_short.signed_decimal_qty();
3605        assert!(signed_qty < Decimal::ZERO);
3606        assert_eq!(
3607            signed_qty,
3608            Decimal::try_from(stub_position_short.signed_qty).unwrap()
3609        );
3610    }
3611
3612    #[rstest]
3613    fn test_signed_decimal_qty_flat(audusd_sim: CurrencyPair) {
3614        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3615        let order = OrderTestBuilder::new(OrderType::Market)
3616            .instrument_id(audusd_sim.id())
3617            .side(OrderSide::Buy)
3618            .quantity(Quantity::from(100_000))
3619            .build();
3620        let fill = TestOrderEventStubs::filled(
3621            &order,
3622            &audusd_sim,
3623            Some(TradeId::new("1")),
3624            None,
3625            Some(Price::from("1.00001")),
3626            None,
3627            None,
3628            None,
3629            None,
3630            None,
3631        );
3632        let mut position = Position::new(&audusd_sim, fill.into());
3633
3634        let close_order = OrderTestBuilder::new(OrderType::Market)
3635            .instrument_id(audusd_sim.id())
3636            .side(OrderSide::Sell)
3637            .quantity(Quantity::from(100_000))
3638            .build();
3639        let close_fill = TestOrderEventStubs::filled(
3640            &close_order,
3641            &audusd_sim,
3642            Some(TradeId::new("2")),
3643            None,
3644            Some(Price::from("1.00002")),
3645            None,
3646            None,
3647            None,
3648            None,
3649            None,
3650        );
3651        position.apply(&close_fill.into());
3652
3653        assert_eq!(position.side, PositionSide::Flat);
3654        assert_eq!(position.signed_decimal_qty(), Decimal::ZERO);
3655    }
3656
3657    #[rstest]
3658    fn test_position_flat_with_floating_point_precision_edge_case() {
3659        // This test verifies that when signed_qty has accumulated floating-point
3660        // errors (tiny non-zero value) but quantity rounds to zero, the position
3661        // correctly becomes FLAT with signed_qty normalized to 0.0
3662        let btc_usdt = currency_pair_btcusdt();
3663        let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
3664
3665        let order1 = OrderTestBuilder::new(OrderType::Market)
3666            .instrument_id(btc_usdt.id())
3667            .side(OrderSide::Buy)
3668            .quantity(Quantity::from("0.123456789"))
3669            .build();
3670        let fill1 = TestOrderEventStubs::filled(
3671            &order1,
3672            &btc_usdt,
3673            Some(TradeId::new("1")),
3674            None,
3675            Some(Price::from("50000.00")),
3676            None,
3677            None,
3678            None,
3679            None,
3680            None,
3681        );
3682        let mut position = Position::new(&btc_usdt, fill1.into());
3683
3684        assert_eq!(position.side, PositionSide::Long);
3685        assert!(position.quantity.is_positive());
3686
3687        let order2 = OrderTestBuilder::new(OrderType::Market)
3688            .instrument_id(btc_usdt.id())
3689            .side(OrderSide::Sell)
3690            .quantity(Quantity::from("0.123456789"))
3691            .build();
3692        let fill2 = TestOrderEventStubs::filled(
3693            &order2,
3694            &btc_usdt,
3695            Some(TradeId::new("2")),
3696            None,
3697            Some(Price::from("50000.00")),
3698            None,
3699            None,
3700            None,
3701            None,
3702            None,
3703        );
3704        position.apply(&fill2.into());
3705
3706        assert_eq!(
3707            position.side,
3708            PositionSide::Flat,
3709            "Position should be FLAT, not {:?}",
3710            position.side
3711        );
3712        assert!(
3713            position.quantity.is_zero(),
3714            "Quantity should be zero, was {}",
3715            position.quantity
3716        );
3717        assert_eq!(
3718            position.signed_qty, 0.0,
3719            "signed_qty should be normalized to 0.0, was {}",
3720            position.signed_qty
3721        );
3722        assert!(position.is_closed());
3723    }
3724
3725    #[rstest]
3726    fn test_position_adjustment_floating_point_precision_edge_case() {
3727        // Test that apply_adjustment handles precision edge cases correctly
3728        let btc_usdt = currency_pair_btcusdt();
3729        let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
3730
3731        let order = OrderTestBuilder::new(OrderType::Market)
3732            .instrument_id(btc_usdt.id())
3733            .side(OrderSide::Buy)
3734            .quantity(Quantity::from("1.0"))
3735            .build();
3736        let fill = TestOrderEventStubs::filled(
3737            &order,
3738            &btc_usdt,
3739            Some(TradeId::new("1")),
3740            None,
3741            Some(Price::from("50000.00")),
3742            None,
3743            None,
3744            None,
3745            None,
3746            None,
3747        );
3748        let mut position = Position::new(&btc_usdt, fill.into());
3749
3750        let adjustment = PositionAdjusted::new(
3751            position.trader_id,
3752            position.strategy_id,
3753            position.instrument_id,
3754            position.id,
3755            position.account_id,
3756            PositionAdjustmentType::Commission,
3757            Some(Decimal::from_str("-1.0").unwrap()),
3758            None,
3759            None,
3760            uuid4(),
3761            UnixNanos::default(),
3762            UnixNanos::default(),
3763        );
3764        position.apply_adjustment(adjustment);
3765
3766        assert_eq!(
3767            position.side,
3768            PositionSide::Flat,
3769            "Position should be FLAT after zeroing adjustment"
3770        );
3771        assert!(
3772            position.quantity.is_zero(),
3773            "Quantity should be zero after adjustment"
3774        );
3775        assert_eq!(
3776            position.signed_qty, 0.0,
3777            "signed_qty should be normalized to 0.0"
3778        );
3779    }
3780
3781    #[rstest]
3782    fn test_position_spot_buy_partial_fills_with_base_commission() {
3783        // Reproduce GitHub issue #3546: partial fills with base currency commission
3784        // should reduce position quantity, not increase it
3785        let eth_usdt = currency_pair_ethusdt();
3786        let eth_usdt = InstrumentAny::CurrencyPair(eth_usdt);
3787
3788        let order1 = OrderTestBuilder::new(OrderType::Market)
3789            .instrument_id(eth_usdt.id())
3790            .side(OrderSide::Buy)
3791            .quantity(Quantity::from("0.00350"))
3792            .build();
3793
3794        let fill1 = TestOrderEventStubs::filled(
3795            &order1,
3796            &eth_usdt,
3797            Some(TradeId::new("1")),
3798            None,
3799            Some(Price::from("2042.69")),
3800            Some(Quantity::from("0.00350")),
3801            None,
3802            Some(Money::new(0.00001, eth_usdt.base_currency().unwrap())),
3803            None,
3804            None,
3805        );
3806
3807        let mut position = Position::new(&eth_usdt, fill1.into());
3808
3809        assert_eq!(position.quantity, Quantity::from("0.00349"));
3810        assert!((position.signed_qty - 0.00349).abs() < 1e-9);
3811        assert_eq!(position.side, PositionSide::Long);
3812        assert_eq!(position.adjustments.len(), 1);
3813        assert_eq!(
3814            position.adjustments[0].quantity_change,
3815            Some(rust_decimal_macros::dec!(-0.00001))
3816        );
3817
3818        let order2 = OrderTestBuilder::new(OrderType::Market)
3819            .instrument_id(eth_usdt.id())
3820            .side(OrderSide::Buy)
3821            .quantity(Quantity::from("0.00350"))
3822            .build();
3823
3824        let fill2 = TestOrderEventStubs::filled(
3825            &order2,
3826            &eth_usdt,
3827            Some(TradeId::new("2")),
3828            None,
3829            Some(Price::from("2042.69")),
3830            Some(Quantity::from("0.00350")),
3831            None,
3832            Some(Money::new(0.00001, eth_usdt.base_currency().unwrap())),
3833            None,
3834            None,
3835        );
3836
3837        position.apply(&fill2.into());
3838
3839        assert_eq!(position.quantity, Quantity::from("0.00698"));
3840        assert!((position.signed_qty - 0.00698).abs() < 1e-9);
3841        assert_eq!(position.adjustments.len(), 2);
3842
3843        let order3 = OrderTestBuilder::new(OrderType::Market)
3844            .instrument_id(eth_usdt.id())
3845            .side(OrderSide::Buy)
3846            .quantity(Quantity::from("0.00300"))
3847            .build();
3848
3849        let fill3 = TestOrderEventStubs::filled(
3850            &order3,
3851            &eth_usdt,
3852            Some(TradeId::new("3")),
3853            None,
3854            Some(Price::from("2042.69")),
3855            Some(Quantity::from("0.00300")),
3856            None,
3857            Some(Money::new(0.00001, eth_usdt.base_currency().unwrap())),
3858            None,
3859            None,
3860        );
3861
3862        position.apply(&fill3.into());
3863
3864        // Total filled: 0.01000, total commission: 0.00003
3865        // Position should be 0.01000 - 0.00003 = 0.00997
3866        assert_eq!(position.quantity, Quantity::from("0.00997"));
3867        assert!((position.signed_qty - 0.00997).abs() < 1e-9);
3868        assert_eq!(position.side, PositionSide::Long);
3869        assert_eq!(position.adjustments.len(), 3);
3870
3871        // buy_qty tracks order fill amounts, not commission-adjusted
3872        assert_eq!(position.buy_qty, Quantity::from("0.01000"));
3873    }
3874
3875    #[rstest]
3876    fn test_position_spot_sell_partial_fills_with_base_commission() {
3877        let btc_usdt = currency_pair_btcusdt();
3878        let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
3879
3880        let order1 = OrderTestBuilder::new(OrderType::Market)
3881            .instrument_id(btc_usdt.id())
3882            .side(OrderSide::Sell)
3883            .quantity(Quantity::from("0.5"))
3884            .build();
3885
3886        let fill1 = TestOrderEventStubs::filled(
3887            &order1,
3888            &btc_usdt,
3889            Some(TradeId::new("1")),
3890            None,
3891            Some(Price::from("50000.0")),
3892            Some(Quantity::from("0.5")),
3893            None,
3894            Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
3895            None,
3896            None,
3897        );
3898
3899        let mut position = Position::new(&btc_usdt, fill1.into());
3900
3901        // Short: sold 0.5 + paid 0.001 commission = -0.501 exposure
3902        assert!((position.signed_qty - (-0.501)).abs() < 1e-9);
3903        assert_eq!(position.side, PositionSide::Short);
3904        assert_eq!(position.adjustments.len(), 1);
3905
3906        let order2 = OrderTestBuilder::new(OrderType::Market)
3907            .instrument_id(btc_usdt.id())
3908            .side(OrderSide::Sell)
3909            .quantity(Quantity::from("0.5"))
3910            .build();
3911
3912        let fill2 = TestOrderEventStubs::filled(
3913            &order2,
3914            &btc_usdt,
3915            Some(TradeId::new("2")),
3916            None,
3917            Some(Price::from("50000.0")),
3918            Some(Quantity::from("0.5")),
3919            None,
3920            Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
3921            None,
3922            None,
3923        );
3924
3925        position.apply(&fill2.into());
3926
3927        // Total short: 1.0 sold + 0.002 commission = -1.002
3928        assert!((position.signed_qty - (-1.002)).abs() < 1e-9);
3929        assert!((position.quantity.as_f64() - 1.002).abs() < 1e-9);
3930        assert_eq!(position.adjustments.len(), 2);
3931        assert_eq!(position.sell_qty, Quantity::from("1.0"));
3932    }
3933
3934    #[rstest]
3935    fn test_position_spot_round_trip_close_flat_with_quote_commission() {
3936        let eth_usdt = currency_pair_ethusdt();
3937        let eth_usdt = InstrumentAny::CurrencyPair(eth_usdt);
3938
3939        let buy_order = OrderTestBuilder::new(OrderType::Market)
3940            .instrument_id(eth_usdt.id())
3941            .side(OrderSide::Buy)
3942            .quantity(Quantity::from("1.00000"))
3943            .build();
3944
3945        let buy_fill = TestOrderEventStubs::filled(
3946            &buy_order,
3947            &eth_usdt,
3948            Some(TradeId::new("1")),
3949            None,
3950            Some(Price::from("2000.00")),
3951            Some(Quantity::from("1.00000")),
3952            None,
3953            Some(Money::new(0.001, eth_usdt.base_currency().unwrap())),
3954            None,
3955            None,
3956        );
3957
3958        let mut position = Position::new(&eth_usdt, buy_fill.into());
3959
3960        // Position = 1.0 - 0.001 = 0.999
3961        assert_eq!(position.quantity, Quantity::from("0.99900"));
3962        assert_eq!(position.side, PositionSide::Long);
3963
3964        let sell_order = OrderTestBuilder::new(OrderType::Market)
3965            .instrument_id(eth_usdt.id())
3966            .side(OrderSide::Sell)
3967            .quantity(Quantity::from("0.99900"))
3968            .build();
3969
3970        let sell_fill = TestOrderEventStubs::filled(
3971            &sell_order,
3972            &eth_usdt,
3973            Some(TradeId::new("2")),
3974            None,
3975            Some(Price::from("2100.00")),
3976            Some(Quantity::from("0.99900")),
3977            None,
3978            Some(Money::new(2.0, Currency::USDT())),
3979            None,
3980            None,
3981        );
3982
3983        position.apply(&sell_fill.into());
3984
3985        assert_eq!(position.side, PositionSide::Flat);
3986        assert_eq!(position.signed_qty, 0.0);
3987        assert!(position.is_closed());
3988        // Only 1 adjustment from the buy (quote commission doesn't create adjustment)
3989        assert_eq!(position.adjustments.len(), 1);
3990
3991        // PnL: 0.999 ETH * $100 price move = $99.90, minus $2 commission
3992        let realized = position.realized_pnl.unwrap().as_f64();
3993        assert!(
3994            (realized - 97.9).abs() < 0.01,
3995            "Realized PnL should be ~97.90 USDT, was {realized}"
3996        );
3997    }
3998
3999    #[rstest]
4000    fn test_position_spot_commission_accumulation_multiple_partial_fills() {
4001        let eth_usdt = currency_pair_ethusdt();
4002        let eth_usdt = InstrumentAny::CurrencyPair(eth_usdt);
4003
4004        let order1 = OrderTestBuilder::new(OrderType::Market)
4005            .instrument_id(eth_usdt.id())
4006            .side(OrderSide::Buy)
4007            .quantity(Quantity::from("0.50000"))
4008            .build();
4009
4010        let fill1 = TestOrderEventStubs::filled(
4011            &order1,
4012            &eth_usdt,
4013            Some(TradeId::new("1")),
4014            None,
4015            Some(Price::from("2000.00")),
4016            Some(Quantity::from("0.50000")),
4017            None,
4018            Some(Money::new(0.0005, eth_usdt.base_currency().unwrap())),
4019            None,
4020            None,
4021        );
4022
4023        let mut position = Position::new(&eth_usdt, fill1.into());
4024
4025        let order2 = OrderTestBuilder::new(OrderType::Market)
4026            .instrument_id(eth_usdt.id())
4027            .side(OrderSide::Buy)
4028            .quantity(Quantity::from("0.50000"))
4029            .build();
4030
4031        let fill2 = TestOrderEventStubs::filled(
4032            &order2,
4033            &eth_usdt,
4034            Some(TradeId::new("2")),
4035            None,
4036            Some(Price::from("2010.00")),
4037            Some(Quantity::from("0.50000")),
4038            None,
4039            Some(Money::new(0.0005, eth_usdt.base_currency().unwrap())),
4040            None,
4041            None,
4042        );
4043
4044        position.apply(&fill2.into());
4045
4046        // Total: 1.0 filled, 0.001 total commission
4047        assert_eq!(position.quantity, Quantity::from("0.99900"));
4048        assert_eq!(position.buy_qty, Quantity::from("1.00000"));
4049
4050        assert_eq!(position.adjustments.len(), 2);
4051        for adj in &position.adjustments {
4052            assert_eq!(adj.adjustment_type, PositionAdjustmentType::Commission);
4053            assert_eq!(
4054                adj.quantity_change,
4055                Some(rust_decimal_macros::dec!(-0.0005))
4056            );
4057        }
4058
4059        let commissions = position.commissions();
4060        assert_eq!(commissions.len(), 1);
4061        let eth_commission = commissions[0];
4062        assert!(
4063            (eth_commission.as_f64() - 0.001).abs() < 1e-9,
4064            "Total ETH commission should be 0.001, was {}",
4065            eth_commission.as_f64()
4066        );
4067    }
4068
4069    #[rstest]
4070    fn test_position_apply_fill_with_earlier_timestamp_adjusts_ts_opened(audusd_sim: CurrencyPair) {
4071        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
4072        let order1 = OrderTestBuilder::new(OrderType::Market)
4073            .instrument_id(audusd_sim.id())
4074            .side(OrderSide::Buy)
4075            .quantity(Quantity::from(100_000))
4076            .build();
4077        let order2 = OrderTestBuilder::new(OrderType::Market)
4078            .instrument_id(audusd_sim.id())
4079            .side(OrderSide::Buy)
4080            .quantity(Quantity::from(100_000))
4081            .build();
4082
4083        // First fill at ts=2000
4084        let fill1 = TestOrderEventStubs::filled(
4085            &order1,
4086            &audusd_sim,
4087            Some(TradeId::new("t1")),
4088            None,
4089            Some(Price::from("1.00001")),
4090            None,
4091            None,
4092            None,
4093            Some(UnixNanos::from(2_000u64)),
4094            None,
4095        );
4096        let mut position = Position::new(&audusd_sim, fill1.into());
4097        assert_eq!(position.ts_opened, UnixNanos::from(2_000u64));
4098
4099        // Second fill at ts=1000 (earlier than position open)
4100        let fill2 = TestOrderEventStubs::filled(
4101            &order2,
4102            &audusd_sim,
4103            Some(TradeId::new("t2")),
4104            None,
4105            Some(Price::from("1.00002")),
4106            None,
4107            None,
4108            None,
4109            Some(UnixNanos::from(1_000u64)),
4110            None,
4111        );
4112
4113        // Should not panic; ts_opened and opening_order_id stay unchanged
4114        position.apply(&fill2.into());
4115        assert_eq!(position.ts_opened, UnixNanos::from(2_000u64));
4116        assert_eq!(position.opening_order_id, order1.client_order_id());
4117        assert_eq!(position.events.len(), 2);
4118    }
4119
4120    #[rstest]
4121    fn test_position_commissions_multi_currency_insertion_order(audusd_sim: CurrencyPair) {
4122        // Locks in IndexMap iteration order for Position::commissions:
4123        // new currencies append to the end, existing currencies accumulate
4124        // in place. PositionSnapshot.commissions builds its Vec from this
4125        // iteration; the order must be deterministic across runs.
4126        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
4127        let order_template = OrderTestBuilder::new(OrderType::Market)
4128            .instrument_id(audusd_sim.id())
4129            .side(OrderSide::Buy)
4130            .quantity(Quantity::from(100_000))
4131            .build();
4132
4133        let fill_usd = TestOrderEventStubs::filled(
4134            &order_template,
4135            &audusd_sim,
4136            Some(TradeId::new("t1")),
4137            None,
4138            Some(Price::from("1.00001")),
4139            None,
4140            None,
4141            Some(Money::from("1.0 USD")),
4142            None,
4143            None,
4144        );
4145        let mut position = Position::new(&audusd_sim, fill_usd.into());
4146
4147        let fill_usdt = TestOrderEventStubs::filled(
4148            &order_template,
4149            &audusd_sim,
4150            Some(TradeId::new("t2")),
4151            None,
4152            Some(Price::from("1.00001")),
4153            None,
4154            None,
4155            Some(Money::from("2.0 USDT")),
4156            None,
4157            None,
4158        );
4159        position.apply(&fill_usdt.into());
4160
4161        let fill_usd_again = TestOrderEventStubs::filled(
4162            &order_template,
4163            &audusd_sim,
4164            Some(TradeId::new("t3")),
4165            None,
4166            Some(Price::from("1.00001")),
4167            None,
4168            None,
4169            Some(Money::from("0.5 USD")),
4170            None,
4171            None,
4172        );
4173        position.apply(&fill_usd_again.into());
4174
4175        let fill_btc = TestOrderEventStubs::filled(
4176            &order_template,
4177            &audusd_sim,
4178            Some(TradeId::new("t4")),
4179            None,
4180            Some(Price::from("1.00001")),
4181            None,
4182            None,
4183            Some(Money::from("0.0001 BTC")),
4184            None,
4185            None,
4186        );
4187        position.apply(&fill_btc.into());
4188
4189        // USD entered first and accumulates in place, USDT appends second,
4190        // BTC appends third
4191        assert_eq!(
4192            position.commissions(),
4193            vec![
4194                Money::from("1.5 USD"),
4195                Money::from("2.0 USDT"),
4196                Money::from("0.0001 BTC"),
4197            ]
4198        );
4199    }
4200}