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