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