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