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    DurationNanos, 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    data::InstrumentClose,
40    enums::{
41        InstrumentClass, InstrumentCloseType, OrderSide, PositionAdjustmentType, PositionSide,
42    },
43    events::{OrderFillVoided, OrderFilled, PositionAdjusted},
44    identifiers::{
45        AccountId, ClientOrderId, InstrumentId, PositionId, StrategyId, Symbol, TradeId, TraderId,
46        Venue, VenueOrderId,
47    },
48    instruments::{Instrument, InstrumentAny},
49    types::{Currency, Money, Price, Quantity, fixed::check_float_precision},
50};
51
52/// Represents a position in a market.
53///
54/// The position ID may be assigned at the trading venue, or can be system
55/// generated depending on a strategies OMS (Order Management System) settings.
56/// Replay events and cumulative fill corrections preserve derived state across close and reopen
57/// cycles.
58#[repr(C)]
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[cfg_attr(
61    feature = "python",
62    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
63)]
64#[cfg_attr(
65    feature = "python",
66    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
67)]
68pub struct Position {
69    pub events: Vec<OrderFilled>,
70    pub adjustments: Vec<PositionAdjusted>,
71    #[serde(default)]
72    pub replay_events: Vec<PositionReplayEvent>,
73    #[serde(default)]
74    pub fill_voids: Vec<PositionFillVoid>,
75    pub trader_id: TraderId,
76    pub strategy_id: StrategyId,
77    pub instrument_id: InstrumentId,
78    pub id: PositionId,
79    pub account_id: AccountId,
80    pub opening_order_id: ClientOrderId,
81    pub closing_order_id: Option<ClientOrderId>,
82    pub entry: OrderSide,
83    pub side: PositionSide,
84    pub signed_qty: f64,
85    pub quantity: Quantity,
86    pub peak_qty: Quantity,
87    pub price_precision: u8,
88    pub size_precision: u8,
89    pub multiplier: Quantity,
90    pub is_inverse: bool,
91    pub is_currency_pair: bool,
92    pub instrument_class: InstrumentClass,
93    pub base_currency: Option<Currency>,
94    pub quote_currency: Currency,
95    pub settlement_currency: Currency,
96    pub ts_init: UnixNanos,
97    pub ts_opened: UnixNanos,
98    pub ts_last: UnixNanos,
99    pub ts_closed: Option<UnixNanos>,
100    pub duration_ns: DurationNanos,
101    pub avg_px_open: f64,
102    pub avg_px_close: Option<f64>,
103    pub realized_return: f64,
104    pub realized_pnl: Option<Money>,
105    #[serde(with = "nautilus_core::serialization::sorted_hashset")]
106    pub trade_ids: AHashSet<TradeId>,
107    /// Total buy quantity in the current position episode.
108    pub buy_qty: Quantity,
109    /// Total sell quantity in the current position episode.
110    pub sell_qty: Quantity,
111    pub commissions: IndexMap<Currency, Money>,
112}
113
114#[expect(clippy::large_enum_variant)]
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub enum PositionReplayEvent {
117    Filled(OrderFilled),
118    Adjusted(PositionAdjusted),
119    InstrumentClosed(InstrumentClose),
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct PositionFillVoid {
124    pub event: OrderFillVoided,
125    pub voided_qty: Quantity,
126    pub commission_voided: Option<Money>,
127}
128
129impl Position {
130    /// Creates a new [`Position`] instance.
131    ///
132    /// # Panics
133    ///
134    /// Panics if [`Position::new_checked`] returns an error.
135    #[must_use]
136    #[allow(
137        clippy::needless_pass_by_value,
138        reason = "constructor takes the opening fill by value as the position's seed event"
139    )]
140    pub fn new(instrument: &InstrumentAny, fill: OrderFilled) -> Self {
141        Self::new_checked(instrument, fill).expect_display(FAILED)
142    }
143
144    /// Creates a new [`Position`] instance with correctness checking.
145    ///
146    /// # Errors
147    ///
148    /// Returns an error if the instrument ID does not match the fill, the fill has no position
149    /// ID, or the precision of the instrument size, multiplier, or settlement currency, or of the
150    /// fill price, quantity, or a commission converted through `f64`, exceeds
151    /// [`MAX_FLOAT_PRECISION`](crate::types::fixed::MAX_FLOAT_PRECISION).
152    #[allow(
153        clippy::needless_pass_by_value,
154        reason = "constructor takes the opening fill by value as the position's seed event"
155    )]
156    pub fn new_checked(instrument: &InstrumentAny, fill: OrderFilled) -> CorrectnessResult<Self> {
157        Self::check_fill_instrument(instrument.id(), "instrument.id()", &fill)?;
158        let position_id = Self::fill_position_id(&fill)?;
159        check_float_precision(fill.last_px.precision)?;
160
161        let mut item = Self {
162            events: Vec::<OrderFilled>::new(),
163            adjustments: Vec::<PositionAdjusted>::new(),
164            replay_events: Vec::new(),
165            fill_voids: Vec::new(),
166            trade_ids: AHashSet::<TradeId>::new(),
167            buy_qty: Quantity::zero(instrument.size_precision()),
168            sell_qty: Quantity::zero(instrument.size_precision()),
169            commissions: IndexMap::<Currency, Money>::new(),
170            trader_id: fill.trader_id,
171            strategy_id: fill.strategy_id,
172            instrument_id: fill.instrument_id,
173            id: position_id,
174            account_id: fill.account_id,
175            opening_order_id: fill.client_order_id,
176            closing_order_id: None,
177            entry: fill.order_side,
178            side: PositionSide::Flat,
179            signed_qty: 0.0,
180            quantity: fill.last_qty,
181            peak_qty: fill.last_qty,
182            price_precision: instrument.price_precision(),
183            size_precision: instrument.size_precision(),
184            multiplier: instrument.multiplier(),
185            is_inverse: instrument.is_inverse(),
186            is_currency_pair: matches!(instrument, InstrumentAny::CurrencyPair(_)),
187            instrument_class: instrument.instrument_class(),
188            base_currency: instrument.base_currency(),
189            quote_currency: instrument.quote_currency(),
190            settlement_currency: instrument.cost_currency(),
191            ts_init: fill.ts_init,
192            ts_opened: fill.ts_event,
193            ts_last: fill.ts_event,
194            ts_closed: None,
195            duration_ns: DurationNanos::default(),
196            avg_px_open: fill.last_px.as_f64(),
197            avg_px_close: None,
198            realized_return: 0.0,
199            realized_pnl: None,
200        };
201
202        item.check_state_float_precision()?;
203        item.check_fill_float_precision(&fill)?;
204        item.apply_fill(&fill, true)?;
205        Ok(item)
206    }
207
208    /// Returns a copy without stored events, adjustments, replay events, fill voids, or trade IDs.
209    ///
210    /// # Warning
211    ///
212    /// Use this copy only as transient read state. Applying events or caching this copy can bypass
213    /// replay and duplicate-fill checks and discard position history.
214    #[must_use]
215    pub fn clone_without_events(&self) -> Self {
216        Self {
217            events: Vec::new(),
218            adjustments: Vec::new(),
219            replay_events: Vec::new(),
220            fill_voids: Vec::new(),
221            trader_id: self.trader_id,
222            strategy_id: self.strategy_id,
223            instrument_id: self.instrument_id,
224            id: self.id,
225            account_id: self.account_id,
226            opening_order_id: self.opening_order_id,
227            closing_order_id: self.closing_order_id,
228            entry: self.entry,
229            side: self.side,
230            signed_qty: self.signed_qty,
231            quantity: self.quantity,
232            peak_qty: self.peak_qty,
233            price_precision: self.price_precision,
234            size_precision: self.size_precision,
235            multiplier: self.multiplier,
236            is_inverse: self.is_inverse,
237            is_currency_pair: self.is_currency_pair,
238            instrument_class: self.instrument_class,
239            base_currency: self.base_currency,
240            quote_currency: self.quote_currency,
241            settlement_currency: self.settlement_currency,
242            ts_init: self.ts_init,
243            ts_opened: self.ts_opened,
244            ts_last: self.ts_last,
245            ts_closed: self.ts_closed,
246            duration_ns: self.duration_ns,
247            avg_px_open: self.avg_px_open,
248            avg_px_close: self.avg_px_close,
249            realized_return: self.realized_return,
250            realized_pnl: self.realized_pnl,
251            trade_ids: AHashSet::new(),
252            buy_qty: self.buy_qty,
253            sell_qty: self.sell_qty,
254            commissions: self.commissions.clone(),
255        }
256    }
257
258    /// Returns a snapshot copy without durable replay or fill-void history.
259    #[must_use]
260    pub fn clone_for_snapshot(&self) -> Self {
261        let mut snapshot = self.clone_without_events();
262        snapshot.events.clone_from(&self.events);
263        snapshot.adjustments.clone_from(&self.adjustments);
264        snapshot.trade_ids.clone_from(&self.trade_ids);
265        snapshot
266    }
267
268    /// Moves durable replay state from `prior` and keeps this position's current replay events.
269    pub fn transfer_replay_state_from(&mut self, prior: &mut Self) {
270        let current_replay = std::mem::take(&mut self.replay_events);
271        self.replay_events = std::mem::take(&mut prior.replay_events);
272        self.fill_voids = std::mem::take(&mut prior.fill_voids);
273        self.replay_events.extend(current_replay);
274    }
275
276    /// Purges all order fill events for the given client order ID and recalculates derived state.
277    ///
278    /// # Warning
279    ///
280    /// This operation recalculates the entire position from scratch after removing the specified
281    /// order's fills. This is an expensive operation and should be used sparingly.
282    ///
283    /// # Panics
284    ///
285    /// Panics if after purging, no fills remain and the position cannot be reconstructed.
286    pub fn purge_events_for_order(&mut self, client_order_id: ClientOrderId) {
287        self.replay_events.retain(|event| {
288            !matches!(event, PositionReplayEvent::Filled(fill) if fill.client_order_id == client_order_id)
289        });
290        self.fill_voids
291            .retain(|record| record.event.client_order_id != client_order_id);
292
293        let filtered_events: Vec<OrderFilled> = self
294            .events
295            .iter()
296            .filter(|e| e.client_order_id != client_order_id)
297            .cloned()
298            .collect();
299
300        // Preserve non-commission adjustments (funding, manual adjustments, etc.)
301        // Commission adjustments will be automatically re-created when fills are replayed
302        let preserved_adjustments: Vec<PositionAdjusted> = self
303            .adjustments
304            .iter()
305            .filter(|adj| {
306                // Keep all non-commission adjustments (funding, manual, etc.)
307                // Commission adjustments will be re-created during fill replay
308                adj.adjustment_type != PositionAdjustmentType::Commission
309            })
310            .copied()
311            .collect();
312
313        // If no events remain, log warning - position should be closed/removed instead
314        if filtered_events.is_empty() {
315            log::warn!(
316                "Position {} has no fills remaining after purging order {}; consider closing the position instead",
317                self.id,
318                client_order_id
319            );
320            self.events.clear();
321            self.trade_ids.clear();
322            self.adjustments.clear();
323            self.buy_qty = Quantity::zero(self.size_precision);
324            self.sell_qty = Quantity::zero(self.size_precision);
325            self.commissions.clear();
326            self.signed_qty = 0.0;
327            self.quantity = Quantity::zero(self.size_precision);
328            self.side = PositionSide::Flat;
329            self.avg_px_close = None;
330            self.realized_pnl = None;
331            self.realized_return = 0.0;
332            self.ts_opened = UnixNanos::default();
333            self.ts_last = UnixNanos::default();
334            self.ts_closed = Some(UnixNanos::default());
335            self.duration_ns = DurationNanos::default();
336            return;
337        }
338
339        // Recalculate position from scratch
340        let position_id = self.id;
341        self.reset_derived_state();
342
343        // Use the first remaining event to set opening state
344        let first_event = &filtered_events[0];
345        self.entry = first_event.order_side;
346        self.opening_order_id = first_event.client_order_id;
347        self.ts_opened = first_event.ts_event;
348        self.ts_init = first_event.ts_init;
349        self.closing_order_id = None;
350        self.ts_closed = None;
351        self.duration_ns = DurationNanos::default();
352
353        let restatement_indexes = Self::price_restatement_indexes(&self.replay_events);
354
355        let start = self.replay_events.iter().position(|event| {
356            matches!(
357                event,
358                PositionReplayEvent::Filled(fill) if fill.event_id == first_event.event_id
359            )
360        });
361
362        let remaining_orders: AHashSet<_> = filtered_events
363            .iter()
364            .map(|event| event.client_order_id)
365            .collect();
366        let effective_by_id: AHashMap<_, _> = filtered_events
367            .iter()
368            .map(|event| (event.event_id, event))
369            .collect();
370
371        if let Some(start) = start {
372            let replay = self.replay_events.clone();
373            for (index, event) in replay.iter().enumerate().skip(start) {
374                let PositionReplayEvent::Filled(fill) = event else {
375                    continue;
376                };
377
378                if !remaining_orders.contains(&fill.client_order_id) {
379                    continue;
380                }
381
382                if restatement_indexes.contains(&index) {
383                    self.apply_price_restatement(fill, false);
384                } else if let Some(effective) = effective_by_id.get(&fill.event_id) {
385                    self.apply_fill(effective, false).expect_display(FAILED);
386                }
387            }
388        } else {
389            for event in &filtered_events {
390                self.apply_fill(event, false).expect_display(FAILED);
391            }
392        }
393
394        // Reapply preserved adjustments to maintain full state
395        for adjustment in preserved_adjustments {
396            self.apply_adjustment_state(adjustment, false);
397        }
398
399        log::info!(
400            "Purged fills for order {} from position {}; recalculated state: qty={}, signed_qty={}, side={:?}",
401            client_order_id,
402            position_id,
403            self.quantity,
404            self.signed_qty,
405            self.side
406        );
407    }
408
409    /// Applies an `OrderFilled` event to this position.
410    ///
411    /// A fill with the same `trade_id`, the same `client_order_id`, and a new price restates
412    /// the open average when that trade is an opening fill in the current cycle. Quantity is
413    /// unchanged. The original fill stays in `events`, so a void still matches its `last_px`.
414    /// Applying the same `event_id` again does nothing. A different order that reuses the trade
415    /// ID is rejected. A closing fill does not change the open average, and restating an
416    /// opening fill does not change realized PnL for quantity already closed.
417    ///
418    /// # Panics
419    ///
420    /// Panics if `fill.trade_id` is already in `trade_ids` and this fill is not a restatement
421    /// of that order.
422    pub fn apply(&mut self, fill: &OrderFilled) {
423        self.apply_fill(fill, true).expect_display(FAILED);
424    }
425
426    /// Applies an `OrderFilled` event to this position with correctness checking.
427    ///
428    /// A fill with the same `trade_id`, the same `client_order_id`, and a new price restates
429    /// the open average when that trade is an opening fill in the current cycle. Quantity is
430    /// unchanged. An error leaves the position unchanged. Applying the same `event_id` again
431    /// does nothing. A closing fill does not change the open average.
432    ///
433    /// # Errors
434    ///
435    /// Returns an error if:
436    /// - The fill instrument or position identity does not match this position.
437    /// - The fill has no position ID.
438    /// - The precision of the fill price, quantity, or a commission converted through `f64`
439    ///   exceeds [`MAX_FLOAT_PRECISION`](crate::types::fixed::MAX_FLOAT_PRECISION).
440    /// - `fill.trade_id` is already in `trade_ids` and this fill is not a restatement of that
441    ///   order.
442    pub fn try_apply(&mut self, fill: &OrderFilled) -> CorrectnessResult<()> {
443        Self::check_fill_instrument(self.instrument_id, "self.instrument_id", fill)?;
444        let position_id = Self::fill_position_id(fill)?;
445        check_equal(&self.id, &position_id, "self.id", "fill.position_id")?;
446        self.check_fill_float_precision(fill)?;
447        self.apply_fill(fill, true)
448    }
449
450    fn replay_has_event(&self, event_id: UUID4) -> bool {
451        self.replay_events.iter().any(|event| {
452            matches!(
453                event,
454                PositionReplayEvent::Filled(existing) if existing.event_id == event_id
455            )
456        })
457    }
458
459    fn check_fill_instrument(
460        instrument_id: InstrumentId,
461        instrument_param: &str,
462        fill: &OrderFilled,
463    ) -> CorrectnessResult<()> {
464        check_equal(
465            &instrument_id,
466            &fill.instrument_id,
467            instrument_param,
468            "fill.instrument_id",
469        )
470    }
471
472    fn fill_position_id(fill: &OrderFilled) -> CorrectnessResult<PositionId> {
473        fill.position_id
474            .ok_or_else(|| CorrectnessError::PredicateViolation {
475                message: "`fill.position_id` was None".to_string(),
476            })
477    }
478
479    fn check_fill_float_precision(&self, fill: &OrderFilled) -> CorrectnessResult<()> {
480        check_float_precision(fill.last_px.precision)?;
481        check_float_precision(fill.last_qty.precision)?;
482
483        // Only settlement and base-currency commissions convert through `f64`
484        if let Some(commission) = fill.commission
485            && (commission.currency == self.settlement_currency
486                || (self.is_currency_pair && self.base_currency == Some(commission.currency)))
487        {
488            check_float_precision(commission.currency.precision)?;
489        }
490
491        Ok(())
492    }
493
494    pub(crate) fn check_state_float_precision(&self) -> CorrectnessResult<()> {
495        check_float_precision(self.size_precision)?;
496        check_float_precision(self.multiplier.precision)?;
497        check_float_precision(self.settlement_currency.precision)?;
498        check_float_precision(self.quantity.precision)?;
499        check_float_precision(self.buy_qty.precision)?;
500        check_float_precision(self.sell_qty.precision)?;
501
502        if let Some(realized_pnl) = self.realized_pnl {
503            check_float_precision(realized_pnl.currency.precision)?;
504        }
505
506        for fill in &self.events {
507            self.check_fill_float_precision(fill)?;
508        }
509
510        for event in &self.replay_events {
511            match event {
512                PositionReplayEvent::Filled(fill) => self.check_fill_float_precision(fill)?,
513                PositionReplayEvent::InstrumentClosed(close) => {
514                    check_float_precision(close.close_price.precision)?;
515                }
516                PositionReplayEvent::Adjusted(_) => {}
517            }
518        }
519
520        Ok(())
521    }
522
523    fn apply_fill(&mut self, fill: &OrderFilled, record_replay: bool) -> CorrectnessResult<()> {
524        if record_replay
525            && self.trade_ids.contains(&fill.trade_id)
526            && self.replay_has_event(fill.event_id)
527        {
528            return Ok(());
529        }
530
531        if self.is_price_restatement(fill) {
532            self.apply_price_restatement(fill, record_replay);
533            return Ok(());
534        }
535
536        if record_replay
537            && (self.side == PositionSide::Flat || !self.trade_ids.contains(&fill.trade_id))
538            && self.is_duplicate_replay_fill(fill)
539        {
540            log::warn!(
541                "Ignoring historical duplicate fill {} for position {}; durable replay already contains this trade",
542                fill.trade_id,
543                self.id,
544            );
545            return Ok(());
546        }
547
548        if fill.ts_event < self.ts_opened {
549            log::warn!(
550                "Fill ts_event {} for {} is before position ts_opened {}",
551                fill.ts_event,
552                self.id,
553                self.ts_opened,
554            );
555        }
556
557        if self.side == PositionSide::Flat {
558            self.reset_cycle(fill);
559        }
560
561        if record_replay {
562            check_predicate_true(
563                !self.trade_ids.contains(&fill.trade_id),
564                "`fill.trade_id` already contained in `trade_ids`",
565            )?;
566            self.replay_events
567                .push(PositionReplayEvent::Filled(fill.clone()));
568        }
569
570        self.events.push(fill.clone());
571        self.trade_ids.insert(fill.trade_id);
572
573        // Calculate cumulative commissions
574        if let Some(commission) = fill.commission {
575            self.commissions
576                .entry(commission.currency)
577                .and_modify(|total| *total = *total + commission)
578                .or_insert(commission);
579        }
580
581        // Calculate avg prices, points, return, PnL
582        match fill.order_side {
583            OrderSide::Buy => {
584                self.handle_buy_order_fill(fill);
585            }
586            OrderSide::Sell => {
587                self.handle_sell_order_fill(fill);
588            }
589        }
590
591        // For CurrencyPair instruments, create adjustment event when commission is in base currency
592        if self.is_currency_pair
593            && let Some(commission) = fill.commission
594            && let Some(base_currency) = self.base_currency
595            && commission.currency == base_currency
596        {
597            self.apply_base_commission_adjustment(fill, commission);
598        }
599
600        // size_precision is valid from instrument
601        self.quantity = Quantity::new(self.signed_qty.abs(), self.size_precision);
602        if self.quantity > self.peak_qty {
603            self.peak_qty = self.quantity;
604        }
605
606        if self.quantity.is_zero() {
607            self.side = PositionSide::Flat;
608            self.signed_qty = 0.0; // Normalize
609            self.closing_order_id = Some(fill.client_order_id);
610            self.ts_closed = Some(fill.ts_event);
611            self.duration_ns = fill.ts_event.saturating_duration_since(self.ts_opened);
612        } else if self.signed_qty > 0.0 {
613            self.entry = OrderSide::Buy;
614            self.side = PositionSide::Long;
615        } else {
616            self.entry = OrderSide::Sell;
617            self.side = PositionSide::Short;
618        }
619
620        self.ts_last = fill.ts_event;
621
622        self.debug_assert_invariants();
623
624        Ok(())
625    }
626
627    fn reset_cycle(&mut self, fill: &OrderFilled) {
628        self.events.clear();
629        self.trade_ids.clear();
630        self.adjustments.clear();
631        self.buy_qty = Quantity::zero(self.size_precision);
632        self.sell_qty = Quantity::zero(self.size_precision);
633        self.commissions.clear();
634        self.opening_order_id = fill.client_order_id;
635        self.closing_order_id = None;
636        self.peak_qty = Quantity::zero(self.size_precision);
637        self.ts_init = fill.ts_init;
638        self.ts_opened = fill.ts_event;
639        self.ts_closed = None;
640        self.duration_ns = DurationNanos::default();
641        self.avg_px_open = fill.last_px.as_f64();
642        self.avg_px_close = None;
643        self.realized_return = 0.0;
644        self.realized_pnl = None;
645    }
646
647    /// Returns whether durable replay history contains `trade_id`.
648    #[must_use]
649    pub fn has_replay_trade_id(&self, trade_id: TradeId) -> bool {
650        self.replay_events.iter().any(
651            |event| matches!(event, PositionReplayEvent::Filled(fill) if fill.trade_id == trade_id),
652        )
653    }
654
655    fn is_duplicate_replay_fill(&self, fill: &OrderFilled) -> bool {
656        let continues_latest_fill = fill.causation_id.is_some_and(|source_id| {
657            self.events.last().is_some_and(|latest| {
658                latest.trade_id == fill.trade_id && latest.event_id == source_id
659            })
660        });
661
662        if self.trade_ids.contains(&fill.trade_id) {
663            return !continues_latest_fill
664                || self.replay_events.iter().any(|event| {
665                    matches!(
666                        event,
667                        PositionReplayEvent::Filled(replayed)
668                            if replayed.trade_id == fill.trade_id
669                                && replayed.causation_id == fill.causation_id
670                    )
671                });
672        }
673
674        let replay_starts_current_cycle = self.replay_events.is_empty()
675            || matches!(
676                (self.replay_events.first(), self.events.first()),
677                (
678                    Some(PositionReplayEvent::Filled(replayed)),
679                    Some(current),
680                ) if replayed.event_id == current.event_id
681            );
682        let corrected_trade = self
683            .fill_voids
684            .iter()
685            .any(|record| record.event.trade_id == fill.trade_id);
686        let current_cycle_only = replay_starts_current_cycle && !corrected_trade;
687        if current_cycle_only {
688            return false;
689        }
690
691        self.has_replay_trade_id(fill.trade_id)
692    }
693
694    fn is_price_restatement(&self, fill: &OrderFilled) -> bool {
695        if self.side == PositionSide::Flat
696            || self.quantity.is_zero()
697            || !self.trade_ids.contains(&fill.trade_id)
698        {
699            return false;
700        }
701
702        let mut latest = None;
703
704        for event in &self.replay_events {
705            let PositionReplayEvent::Filled(existing) = event else {
706                continue;
707            };
708
709            if existing.trade_id != fill.trade_id
710                || existing.client_order_id != fill.client_order_id
711            {
712                continue;
713            }
714
715            if existing.event_id == fill.event_id {
716                return latest.is_some_and(|price| price != fill.last_px);
717            }
718
719            latest = Some(existing.last_px);
720        }
721
722        latest.is_some_and(|price| price != fill.last_px)
723    }
724
725    fn apply_price_restatement(&mut self, fill: &OrderFilled, record_replay: bool) {
726        if self.side != PositionSide::Flat && !self.quantity.is_zero() {
727            self.restate_open_average(fill);
728        }
729
730        self.ts_last = fill.ts_event;
731
732        if record_replay {
733            self.replay_events
734                .push(PositionReplayEvent::Filled(fill.clone()));
735        }
736    }
737
738    fn has_replay_price_restatement(&self) -> bool {
739        let mut seen = AHashMap::new();
740
741        for event in &self.replay_events {
742            let PositionReplayEvent::Filled(fill) = event else {
743                continue;
744            };
745
746            if let Some(price) = seen.get(&fill.trade_id) {
747                if *price != fill.last_px {
748                    return true;
749                }
750            } else {
751                seen.insert(fill.trade_id, fill.last_px);
752            }
753        }
754
755        false
756    }
757
758    fn restate_open_average(&mut self, fill: &OrderFilled) {
759        if !self.trade_ids.contains(&fill.trade_id)
760            || fill.order_side != self.entry
761            || !self.extends_current_cycle(fill)
762        {
763            return;
764        }
765
766        let new_px = fill.last_px.as_f64();
767        if self.trade_ids.len() <= 1 {
768            self.avg_px_open = new_px;
769            return;
770        }
771
772        let Some(previous_px) = self.accounting_px_before(fill) else {
773            return;
774        };
775
776        let open_qty = self.quantity.as_f64();
777        if open_qty <= 0.0 {
778            return;
779        }
780
781        let trade_qty = self.cycle_open_qty_for_trade(fill.trade_id).min(open_qty);
782        if trade_qty <= 0.0 {
783            return;
784        }
785
786        let notional = self.avg_px_open * open_qty - previous_px * trade_qty + new_px * trade_qty;
787        self.avg_px_open = notional / open_qty;
788    }
789
790    fn extends_current_cycle(&self, fill: &OrderFilled) -> bool {
791        let long = self.entry == OrderSide::Buy;
792        let mut signed = 0.0_f64;
793
794        for event in &self.events {
795            let delta = if event.order_side == OrderSide::Buy {
796                event.last_qty.as_f64()
797            } else {
798                -event.last_qty.as_f64()
799            };
800
801            if event.trade_id == fill.trade_id && event.client_order_id == fill.client_order_id {
802                let after = signed + delta;
803                return (signed == 0.0 || (signed > 0.0) == long) && ((after > 0.0) == long);
804            }
805
806            signed += delta;
807        }
808
809        false
810    }
811
812    fn cycle_open_qty_for_trade(&self, trade_id: TradeId) -> f64 {
813        let long = self.entry == OrderSide::Buy;
814        let mut remaining = Vec::new();
815        let mut signed = 0.0_f64;
816
817        for fill in &self.events {
818            let qty = fill.last_qty.as_f64();
819            let delta = if fill.order_side == OrderSide::Buy {
820                qty
821            } else {
822                -qty
823            };
824
825            let after = signed + delta;
826
827            if signed != 0.0 && (after > 0.0) != (signed > 0.0) {
828                // A zero crossing starts a new cycle; only the crossing fill's residual
829                // in the current entry direction extends it.
830                remaining.clear();
831                if after != 0.0 && (after > 0.0) == long {
832                    remaining.push((fill.trade_id, after.abs()));
833                }
834
835                signed = after;
836                continue;
837            }
838
839            if fill.order_side == self.entry {
840                remaining.push((fill.trade_id, qty));
841                signed = after;
842                continue;
843            }
844
845            let open_qty: f64 = remaining.iter().map(|(_, qty)| *qty).sum();
846            if open_qty <= 0.0 {
847                signed = after;
848                continue;
849            }
850
851            let keep = (open_qty - qty.min(open_qty)) / open_qty;
852            for lot in &mut remaining {
853                lot.1 *= keep;
854            }
855
856            signed = after;
857        }
858
859        remaining
860            .into_iter()
861            .filter(|(id, _)| *id == trade_id)
862            .map(|(_, qty)| qty)
863            .sum()
864    }
865
866    fn accounting_px_before(&self, fill: &OrderFilled) -> Option<f64> {
867        let mut price = None;
868
869        for event in &self.replay_events {
870            let PositionReplayEvent::Filled(existing) = event else {
871                continue;
872            };
873
874            if existing.trade_id != fill.trade_id {
875                continue;
876            }
877
878            if existing.event_id == fill.event_id {
879                break;
880            }
881
882            price = Some(existing.last_px.as_f64());
883        }
884
885        price
886    }
887
888    fn voidable_replay_fill<'a>(
889        restatement_indexes: &AHashSet<usize>,
890        index: usize,
891        replay_event: &'a PositionReplayEvent,
892    ) -> Option<&'a OrderFilled> {
893        let PositionReplayEvent::Filled(fill) = replay_event else {
894            return None;
895        };
896
897        if restatement_indexes.contains(&index) {
898            return None;
899        }
900
901        Some(fill)
902    }
903
904    fn price_restatement_indexes(replay_events: &[PositionReplayEvent]) -> AHashSet<usize> {
905        let mut latest_px = AHashMap::new();
906        let mut indexes = AHashSet::new();
907
908        for (index, event) in replay_events.iter().enumerate() {
909            let PositionReplayEvent::Filled(fill) = event else {
910                continue;
911            };
912
913            let key = (fill.trade_id, fill.client_order_id);
914            if let Some(price) = latest_px.get(&key)
915                && *price != fill.last_px
916            {
917                indexes.insert(index);
918            }
919
920            latest_px.insert(key, fill.last_px);
921        }
922
923        indexes
924    }
925
926    fn handle_buy_order_fill(&mut self, fill: &OrderFilled) {
927        // Handle case where commission could be None or not settlement currency
928        let mut realized_pnl = if let Some(commission) = fill.commission {
929            if commission.currency == self.settlement_currency {
930                -commission.as_f64()
931            } else {
932                0.0
933            }
934        } else {
935            0.0
936        };
937
938        let last_px = fill.last_px.as_f64();
939        let last_qty = fill.last_qty.as_f64();
940        let last_qty_object = fill.last_qty;
941        let was_short = self.signed_qty < 0.0;
942        let is_reversal = was_short && last_qty_object > self.quantity;
943        let closing_qty_object = if is_reversal {
944            self.quantity
945        } else {
946            last_qty_object
947        };
948        let opening_qty_object = last_qty_object - closing_qty_object;
949        let closing_qty = closing_qty_object.as_f64();
950
951        if self.signed_qty > 0.0 {
952            self.avg_px_open = self.calculate_avg_px_open_px(last_px, last_qty);
953        } else if was_short {
954            // Closing short position
955            let avg_px_close = self.calculate_avg_px_close_px(last_px, closing_qty);
956            self.avg_px_close = Some(avg_px_close);
957            self.realized_return = self
958                .calculate_return(self.avg_px_open, avg_px_close)
959                .unwrap_or_else(|e| {
960                    log::error!("Error calculating return: {e}");
961                    0.0
962                });
963            realized_pnl += self
964                .calculate_pnl_raw(self.avg_px_open, last_px, closing_qty)
965                .unwrap_or_else(|e| {
966                    log::error!("Error calculating PnL: {e}");
967                    0.0
968                });
969        }
970
971        let current_pnl = self.realized_pnl.map_or(0.0, |p| p.as_f64());
972        self.realized_pnl = Some(Money::new(
973            current_pnl + realized_pnl,
974            self.settlement_currency,
975        ));
976
977        self.signed_qty += last_qty;
978        self.buy_qty = self.buy_qty + last_qty_object;
979
980        // Start a new accounting episode after a short-to-long reversal
981        if is_reversal {
982            self.avg_px_open = last_px;
983            self.avg_px_close = None;
984            self.realized_return = 0.0;
985            self.buy_qty = opening_qty_object;
986            self.sell_qty = Quantity::zero(self.size_precision);
987        }
988    }
989
990    fn handle_sell_order_fill(&mut self, fill: &OrderFilled) {
991        // Handle case where commission could be None or not settlement currency
992        let mut realized_pnl = if let Some(commission) = fill.commission {
993            if commission.currency == self.settlement_currency {
994                -commission.as_f64()
995            } else {
996                0.0
997            }
998        } else {
999            0.0
1000        };
1001
1002        let last_px = fill.last_px.as_f64();
1003        let last_qty = fill.last_qty.as_f64();
1004        let last_qty_object = fill.last_qty;
1005        let was_long = self.signed_qty > 0.0;
1006        let is_reversal = was_long && last_qty_object > self.quantity;
1007        let closing_qty_object = if is_reversal {
1008            self.quantity
1009        } else {
1010            last_qty_object
1011        };
1012        let opening_qty_object = last_qty_object - closing_qty_object;
1013        let closing_qty = closing_qty_object.as_f64();
1014
1015        if self.signed_qty < 0.0 {
1016            self.avg_px_open = self.calculate_avg_px_open_px(last_px, last_qty);
1017        } else if was_long {
1018            // Closing long position
1019            let avg_px_close = self.calculate_avg_px_close_px(last_px, closing_qty);
1020            self.avg_px_close = Some(avg_px_close);
1021            self.realized_return = self
1022                .calculate_return(self.avg_px_open, avg_px_close)
1023                .unwrap_or_else(|e| {
1024                    log::error!("Error calculating return: {e}");
1025                    0.0
1026                });
1027            realized_pnl += self
1028                .calculate_pnl_raw(self.avg_px_open, last_px, closing_qty)
1029                .unwrap_or_else(|e| {
1030                    log::error!("Error calculating PnL: {e}");
1031                    0.0
1032                });
1033        }
1034
1035        let current_pnl = self.realized_pnl.map_or(0.0, |p| p.as_f64());
1036        self.realized_pnl = Some(Money::new(
1037            current_pnl + realized_pnl,
1038            self.settlement_currency,
1039        ));
1040
1041        self.signed_qty -= last_qty;
1042        self.sell_qty = self.sell_qty + last_qty_object;
1043
1044        // Start a new accounting episode after a long-to-short reversal
1045        if is_reversal {
1046            self.avg_px_open = last_px;
1047            self.avg_px_close = None;
1048            self.realized_return = 0.0;
1049            self.buy_qty = Quantity::zero(self.size_precision);
1050            self.sell_qty = opening_qty_object;
1051        }
1052    }
1053
1054    /// Applies a position adjustment event.
1055    ///
1056    /// This method handles adjustments to position quantity or realized PnL that occur
1057    /// outside of normal order fills, such as:
1058    /// - Commission adjustments in base currency (crypto spot markets).
1059    /// - Funding payments (perpetual futures).
1060    ///
1061    /// The adjustment event is stored in the position's adjustment history for full audit trail.
1062    ///
1063    /// # Panics
1064    ///
1065    /// Panics if the adjustment's `quantity_change` cannot be converted to f64.
1066    pub fn apply_adjustment(&mut self, adjustment: PositionAdjusted) {
1067        self.apply_adjustment_state(adjustment, true);
1068    }
1069
1070    /// Reverts the most recent adjustment applied with [`Self::apply_adjustment`].
1071    ///
1072    /// `prior` is a snapshot of this position taken before that adjustment was applied, for
1073    /// example with [`Self::clone_without_events`]; the state an adjustment changes is restored
1074    /// from it, and the adjustment is removed from the position history.
1075    ///
1076    /// An adjustment can leave every field the snapshot is checked on unchanged, for example a
1077    /// zero value funding payment, so the snapshot must also not be older than the latest
1078    /// adjustment that stays in the history. This assumes adjustments are applied in timestamp
1079    /// order, as the backtest does; a stale snapshot behind out of order adjustments can pass.
1080    ///
1081    /// # Errors
1082    ///
1083    /// Returns an error if `prior` is a snapshot of a different position, size precision or
1084    /// settlement currency, the last replay event is not the latest adjustment (for example a
1085    /// fill was applied after it), `prior` predates the adjustment before the latest one, its
1086    /// realized PnL cannot take the adjustment's PnL change (another currency, or out of
1087    /// `Money` bounds), or applying that adjustment to `prior` does not reproduce the current
1088    /// state. An error leaves the position unchanged.
1089    pub fn revert_last_adjustment(&mut self, prior: &Self) -> CorrectnessResult<()> {
1090        check_equal(&self.id, &prior.id, "self.id", "prior.id")?;
1091        check_equal(
1092            &self.size_precision,
1093            &prior.size_precision,
1094            "self.size_precision",
1095            "prior.size_precision",
1096        )?;
1097        check_equal(
1098            &self.settlement_currency,
1099            &prior.settlement_currency,
1100            "self.settlement_currency",
1101            "prior.settlement_currency",
1102        )?;
1103
1104        let adjustment = match (self.replay_events.last(), self.adjustments.last()) {
1105            (Some(PositionReplayEvent::Adjusted(replayed)), Some(latest)) if replayed == latest => {
1106                *latest
1107            }
1108            _ => {
1109                return Err(CorrectnessError::PredicateViolation {
1110                    message: format!(
1111                        "cannot revert adjustment for position {}: the last replay event is not its latest adjustment",
1112                        self.id
1113                    ),
1114                });
1115            }
1116        };
1117
1118        if let Some(earlier) = self.adjustments.iter().rev().nth(1)
1119            && prior.ts_last < earlier.ts_event
1120        {
1121            return Err(CorrectnessError::PredicateViolation {
1122                message: format!(
1123                    "cannot revert adjustment {} for position {}: `prior` at {} predates adjustment {} at {}",
1124                    adjustment.event_id, self.id, prior.ts_last, earlier.event_id, earlier.ts_event
1125                ),
1126            });
1127        }
1128
1129        // Checked here because adding to a realized PnL in another currency panics
1130        if let (Some(realized_pnl), Some(pnl_change)) = (prior.realized_pnl, adjustment.pnl_change)
1131            && (realized_pnl.currency != pnl_change.currency
1132                || realized_pnl.checked_add(pnl_change).is_none())
1133        {
1134            return Err(CorrectnessError::PredicateViolation {
1135                message: format!(
1136                    "cannot revert adjustment {} for position {}: `prior` realized PnL {realized_pnl} cannot take the PnL change {pnl_change}",
1137                    adjustment.event_id, self.id
1138                ),
1139            });
1140        }
1141
1142        let mut expected = prior.clone_without_events();
1143        expected.apply_adjustment_state(adjustment, false);
1144
1145        if expected.signed_qty != self.signed_qty
1146            || expected.quantity != self.quantity
1147            || expected.peak_qty != self.peak_qty
1148            || expected.side != self.side
1149            || expected.realized_pnl != self.realized_pnl
1150            || expected.ts_last != self.ts_last
1151        {
1152            return Err(CorrectnessError::PredicateViolation {
1153                message: format!(
1154                    "cannot revert adjustment {} for position {}: `prior` is not the state before it",
1155                    adjustment.event_id, self.id
1156                ),
1157            });
1158        }
1159
1160        self.adjustments.pop();
1161        self.replay_events.pop();
1162        self.signed_qty = prior.signed_qty;
1163        self.quantity = prior.quantity;
1164        self.peak_qty = prior.peak_qty;
1165        self.side = prior.side;
1166        self.realized_pnl = prior.realized_pnl;
1167        self.ts_last = prior.ts_last;
1168
1169        self.debug_assert_invariants();
1170        Ok(())
1171    }
1172
1173    fn apply_adjustment_state(&mut self, adjustment: PositionAdjusted, record_replay: bool) {
1174        if record_replay {
1175            self.replay_events
1176                .push(PositionReplayEvent::Adjusted(adjustment));
1177        }
1178
1179        // Apply quantity change if present
1180        if let Some(quantity_change) = adjustment.quantity_change {
1181            self.signed_qty += quantity_change
1182                .to_f64()
1183                .expect("Failed to convert Decimal to f64");
1184
1185            self.quantity = Quantity::new(self.signed_qty.abs(), self.size_precision);
1186
1187            if self.quantity > self.peak_qty {
1188                self.peak_qty = self.quantity;
1189            }
1190        }
1191
1192        // Apply PnL change if present
1193        if let Some(pnl_change) = adjustment.pnl_change {
1194            self.realized_pnl = Some(match self.realized_pnl {
1195                Some(current) => current + pnl_change,
1196                None => pnl_change,
1197            });
1198        }
1199
1200        // Update position state based on quantity (source of truth for zero check)
1201        // This handles floating-point precision edge cases
1202        if self.quantity.is_zero() {
1203            self.side = PositionSide::Flat;
1204            self.signed_qty = 0.0; // Normalize
1205        } else if self.signed_qty > 0.0 {
1206            self.side = PositionSide::Long;
1207        } else {
1208            self.side = PositionSide::Short;
1209        }
1210
1211        self.adjustments.push(adjustment);
1212        self.ts_last = adjustment.ts_event;
1213
1214        self.debug_assert_invariants();
1215    }
1216
1217    /// Settles the open position at the payout price of an authoritative contract expiration.
1218    ///
1219    /// The settlement closes the current cycle at `close.close_price` and books its realized PnL
1220    /// without an order fill. Fill quantities and commissions stay as filled. The close is
1221    /// recorded in the replay history, so [`Self::is_settled`] holds until a later fill reopens
1222    /// the position.
1223    ///
1224    /// # Errors
1225    ///
1226    /// Returns an error if the close targets another instrument, is not a contract expiration,
1227    /// the position is not open, or the settlement PnL cannot be represented. An error leaves the
1228    /// position unchanged.
1229    pub fn apply_instrument_close(&mut self, close: InstrumentClose) -> anyhow::Result<()> {
1230        anyhow::ensure!(
1231            close.instrument_id == self.instrument_id,
1232            "instrument close {} does not match position instrument {}",
1233            close.instrument_id,
1234            self.instrument_id,
1235        );
1236        anyhow::ensure!(
1237            close.close_type == InstrumentCloseType::ContractExpired,
1238            "instrument close for {} is not a contract expiration",
1239            close.instrument_id,
1240        );
1241        anyhow::ensure!(
1242            self.is_open(),
1243            "cannot settle position {} which is not open",
1244            self.id,
1245        );
1246
1247        self.apply_instrument_close_state(close, true)
1248    }
1249
1250    fn apply_instrument_close_state(
1251        &mut self,
1252        close: InstrumentClose,
1253        record_replay: bool,
1254    ) -> anyhow::Result<()> {
1255        // A replayed close that finds the corrected history already flat has nothing to settle
1256        if self.side == PositionSide::Flat {
1257            return Ok(());
1258        }
1259
1260        let last_qty = self.quantity;
1261        let last_px = close.close_price.as_f64();
1262        let avg_px_close = self.calculate_avg_px_close_px(last_px, last_qty.as_f64());
1263
1264        let realized_return = self
1265            .calculate_return(self.avg_px_open, avg_px_close)
1266            .unwrap_or_else(|e| {
1267                log::error!("Error calculating return: {e}");
1268                0.0
1269            });
1270
1271        let settlement_pnl = self.try_calculate_pnl(self.avg_px_open, last_px, last_qty)?;
1272
1273        let realized_pnl = match self.realized_pnl {
1274            Some(current) => current.checked_add(settlement_pnl).ok_or_else(|| {
1275                anyhow::anyhow!("settlement PnL overflow for position {}", self.id)
1276            })?,
1277            None => settlement_pnl,
1278        };
1279
1280        if record_replay {
1281            self.replay_events
1282                .push(PositionReplayEvent::InstrumentClosed(close));
1283        }
1284
1285        self.avg_px_close = Some(avg_px_close);
1286        self.realized_return = realized_return;
1287        self.realized_pnl = Some(realized_pnl);
1288        self.side = PositionSide::Flat;
1289        self.signed_qty = 0.0;
1290        self.quantity = Quantity::zero(self.size_precision);
1291        self.closing_order_id = None;
1292        self.ts_last = close.ts_event;
1293        self.ts_closed = Some(close.ts_event);
1294        self.duration_ns = close.ts_event.saturating_duration_since(self.ts_opened);
1295
1296        self.debug_assert_invariants();
1297
1298        Ok(())
1299    }
1300
1301    fn debug_assert_invariants(&self) {
1302        debug_assert!(
1303            match self.side {
1304                PositionSide::Long => self.signed_qty > 0.0,
1305                PositionSide::Short => self.signed_qty < 0.0,
1306                PositionSide::Flat => self.signed_qty == 0.0,
1307            },
1308            "Invariant: position side must match signed_qty sign (side={:?}, signed_qty={})",
1309            self.side,
1310            self.signed_qty,
1311        );
1312        debug_assert!(
1313            self.peak_qty >= self.quantity,
1314            "Invariant: peak_qty must not be less than current quantity (peak={}, quantity={})",
1315            self.peak_qty,
1316            self.quantity,
1317        );
1318    }
1319
1320    /// Applies a cumulative fill correction allocated to this position and rebuilds derived state.
1321    ///
1322    /// Returns the realized PnL of the cycles the rebuild closed before the current one, which
1323    /// [`Self::realized_pnl`] no longer holds because reopening from flat resets it. A caller
1324    /// archiving closed cycles needs this to keep their PnL once the correction has moved the
1325    /// cycle boundaries its existing archive describes. `None` when the corrected history never
1326    /// goes flat, so the current cycle covers all of it.
1327    ///
1328    /// # Errors
1329    ///
1330    /// Returns an error if the allocation is stale, duplicated, exceeds known fragments, or has a
1331    /// commission currency that differs from those fragments.
1332    pub fn apply_fill_void(
1333        &mut self,
1334        event: OrderFillVoided,
1335        voided_qty: Quantity,
1336        commission_voided: Option<Money>,
1337    ) -> anyhow::Result<Option<Money>> {
1338        let fragments = self.fill_fragments(event.client_order_id, event.trade_id);
1339
1340        let fragment_qty = fragments
1341            .iter()
1342            .fold(Quantity::zero(self.size_precision), |total, fill| {
1343                total + fill.last_qty
1344            });
1345        anyhow::ensure!(
1346            !voided_qty.is_zero() && voided_qty <= fragment_qty,
1347            "position fill void exceeds known fragments for {}",
1348            event.trade_id,
1349        );
1350
1351        if let Some(commission_voided) = commission_voided {
1352            for commission in fragments.iter().filter_map(|fill| fill.commission) {
1353                anyhow::ensure!(
1354                    commission.currency == commission_voided.currency,
1355                    "position commission currency differs for fill {}",
1356                    event.trade_id,
1357                );
1358            }
1359        }
1360
1361        if let Some(previous) = self.fill_voids.iter().rev().find(|record| {
1362            record.event.client_order_id == event.client_order_id
1363                && record.event.trade_id == event.trade_id
1364        }) {
1365            anyhow::ensure!(
1366                voided_qty >= previous.voided_qty,
1367                "stale position fill void for {}",
1368                event.trade_id,
1369            );
1370            anyhow::ensure!(
1371                voided_qty != previous.voided_qty
1372                    || commission_voided != previous.commission_voided,
1373                "duplicate position fill void for {}",
1374                event.trade_id,
1375            );
1376        }
1377
1378        self.fill_voids.push(PositionFillVoid {
1379            event,
1380            voided_qty,
1381            commission_voided,
1382        });
1383
1384        Ok(self.rebuild_from_replay())
1385    }
1386
1387    /// Returns durable fill fragments matching an order trade in local application order.
1388    #[must_use]
1389    pub fn fill_fragments(
1390        &self,
1391        client_order_id: ClientOrderId,
1392        trade_id: TradeId,
1393    ) -> Vec<&OrderFilled> {
1394        let restatement_indexes = Self::price_restatement_indexes(&self.replay_events);
1395        self.replay_events
1396            .iter()
1397            .enumerate()
1398            .filter_map(|(index, event)| match event {
1399                PositionReplayEvent::Filled(fill)
1400                    if fill.client_order_id == client_order_id
1401                        && fill.trade_id == trade_id
1402                        && !restatement_indexes.contains(&index) =>
1403                {
1404                    Some(fill)
1405                }
1406                _ => None,
1407            })
1408            .collect()
1409    }
1410
1411    // The banked total assumes `replay_events` spans every cycle this position archived, since
1412    // settling replaces all of its frames with one worth that total. Bounding the log has to
1413    // preserve it at trim time; `Cache::settle_position_snapshots` documents the two ways.
1414    fn rebuild_from_replay(&mut self) -> Option<Money> {
1415        let replay_events = self.replay_events.clone();
1416        let restatement_indexes = Self::price_restatement_indexes(&replay_events);
1417        let mut quantity_removed = AHashMap::<usize, Quantity>::new();
1418        let mut commission_removed = AHashMap::<usize, Money>::new();
1419
1420        for correction in self.latest_fill_voids() {
1421            let mut remaining_qty = correction.voided_qty;
1422            let mut remaining_commission = correction.commission_voided;
1423
1424            for (index, replay_event) in replay_events.iter().enumerate().rev() {
1425                let Some(fill) =
1426                    Self::voidable_replay_fill(&restatement_indexes, index, replay_event)
1427                else {
1428                    continue;
1429                };
1430
1431                if fill.client_order_id != correction.event.client_order_id
1432                    || fill.trade_id != correction.event.trade_id
1433                {
1434                    continue;
1435                }
1436
1437                if !remaining_qty.is_zero() {
1438                    let removed = remaining_qty.min(fill.last_qty);
1439                    quantity_removed.insert(index, removed);
1440                    remaining_qty = remaining_qty - removed;
1441                }
1442
1443                if let (Some(remaining), Some(commission)) = (remaining_commission, fill.commission)
1444                {
1445                    let magnitude = remaining.abs().min(commission.abs());
1446
1447                    let removed = if remaining.is_negative() {
1448                        -magnitude
1449                    } else {
1450                        magnitude
1451                    };
1452
1453                    commission_removed.insert(index, removed);
1454                    let next = remaining - removed;
1455                    remaining_commission = (!next.is_zero()).then_some(next);
1456                }
1457            }
1458        }
1459
1460        self.reset_derived_state();
1461
1462        let mut closed_cycles_pnl: Option<Money> = None;
1463
1464        for (index, replay_event) in replay_events.iter().enumerate() {
1465            match replay_event {
1466                PositionReplayEvent::Filled(fill) => {
1467                    if restatement_indexes.contains(&index) {
1468                        self.apply_price_restatement(fill, false);
1469                        continue;
1470                    }
1471
1472                    let removed = quantity_removed
1473                        .get(&index)
1474                        .copied()
1475                        .unwrap_or_else(|| Quantity::zero(fill.last_qty.precision));
1476                    let effective_qty = fill.last_qty - removed;
1477                    let effective_commission =
1478                        match (fill.commission, commission_removed.get(&index).copied()) {
1479                            (Some(commission), Some(removed)) => Some(commission - removed),
1480                            (commission, None) => commission,
1481                            (None, Some(_)) => None,
1482                        };
1483
1484                    if effective_qty.is_zero() {
1485                        if let Some(commission) =
1486                            effective_commission.filter(|commission| !commission.is_zero())
1487                            && let Some(realized_pnl) =
1488                                self.apply_surviving_fill_commission(fill, commission)
1489                        {
1490                            closed_cycles_pnl = Some(
1491                                closed_cycles_pnl
1492                                    .map_or(realized_pnl, |total| total + realized_pnl),
1493                            );
1494                        }
1495                        continue;
1496                    }
1497
1498                    // `apply_fill` clears realized PnL when it reopens from flat, so bank the
1499                    // closing cycle's total before it goes
1500                    if self.side == PositionSide::Flat
1501                        && let Some(realized_pnl) = self.realized_pnl
1502                    {
1503                        closed_cycles_pnl = Some(
1504                            closed_cycles_pnl.map_or(realized_pnl, |total| total + realized_pnl),
1505                        );
1506                    }
1507
1508                    let mut effective = fill.clone();
1509                    effective.last_qty = effective_qty;
1510                    effective.commission = effective_commission;
1511                    self.apply_fill(&effective, false).expect_display(FAILED);
1512                }
1513                PositionReplayEvent::Adjusted(adjustment) => {
1514                    self.apply_adjustment_state(*adjustment, false);
1515                }
1516                PositionReplayEvent::InstrumentClosed(close) => {
1517                    self.apply_instrument_close_state(*close, false)
1518                        .expect("replaying a validated instrument close must succeed");
1519                }
1520            }
1521        }
1522
1523        closed_cycles_pnl
1524    }
1525
1526    fn apply_surviving_fill_commission(
1527        &mut self,
1528        fill: &OrderFilled,
1529        commission: Money,
1530    ) -> Option<Money> {
1531        let is_base_commission =
1532            self.is_currency_pair && self.base_currency == Some(commission.currency);
1533        let reopens = self.side == PositionSide::Flat
1534            && is_base_commission
1535            && !Quantity::new(commission.as_f64().abs(), self.size_precision).is_zero();
1536        let closed_cycles_pnl_previous = reopens.then_some(self.realized_pnl).flatten();
1537
1538        if reopens {
1539            self.reset_cycle(fill);
1540        }
1541
1542        self.commissions
1543            .entry(commission.currency)
1544            .and_modify(|total| *total = *total + commission)
1545            .or_insert(commission);
1546
1547        if commission.currency == self.settlement_currency {
1548            let pnl_change = Money::zero(self.settlement_currency) - commission;
1549            self.realized_pnl = Some(match self.realized_pnl {
1550                Some(current) => current + pnl_change,
1551                None => pnl_change,
1552            });
1553        }
1554
1555        if is_base_commission {
1556            let previous_side = self.side;
1557            self.apply_base_commission_adjustment(fill, commission);
1558            self.finalize_surviving_base_commission(fill, previous_side);
1559        } else {
1560            self.ts_last = fill.ts_event;
1561        }
1562
1563        closed_cycles_pnl_previous
1564    }
1565
1566    fn finalize_surviving_base_commission(
1567        &mut self,
1568        fill: &OrderFilled,
1569        previous_side: PositionSide,
1570    ) {
1571        if self.side == PositionSide::Flat {
1572            if previous_side != PositionSide::Flat {
1573                self.closing_order_id = Some(fill.client_order_id);
1574                self.ts_closed = Some(fill.ts_event);
1575                self.duration_ns = fill.ts_event.saturating_duration_since(self.ts_opened);
1576            }
1577        } else {
1578            self.entry = match self.side {
1579                PositionSide::Long => OrderSide::Buy,
1580                PositionSide::Short => OrderSide::Sell,
1581                PositionSide::Flat => unreachable!(),
1582            };
1583
1584            if previous_side != PositionSide::Flat && previous_side != self.side {
1585                self.avg_px_open = fill.last_px.as_f64();
1586            }
1587        }
1588    }
1589
1590    fn apply_base_commission_adjustment(&mut self, fill: &OrderFilled, commission: Money) {
1591        let mut adjustment_id = fill.event_id.as_bytes();
1592        adjustment_id[15] ^= 0x01;
1593        self.apply_adjustment_state(
1594            PositionAdjusted::new(
1595                self.trader_id,
1596                self.strategy_id,
1597                self.instrument_id,
1598                self.id,
1599                self.account_id,
1600                PositionAdjustmentType::Commission,
1601                Some(-commission.as_decimal()),
1602                None,
1603                Some(fill.client_order_id.inner()),
1604                UUID4::from_bytes(adjustment_id),
1605                fill.ts_event,
1606                fill.ts_init,
1607            ),
1608            false,
1609        );
1610    }
1611
1612    fn latest_fill_voids(&self) -> Vec<&PositionFillVoid> {
1613        let mut latest = IndexMap::<(ClientOrderId, TradeId), &PositionFillVoid>::new();
1614        for correction in &self.fill_voids {
1615            latest.insert(
1616                (correction.event.client_order_id, correction.event.trade_id),
1617                correction,
1618            );
1619        }
1620        latest.into_values().collect()
1621    }
1622
1623    fn reset_derived_state(&mut self) {
1624        self.events.clear();
1625        self.adjustments.clear();
1626        self.trade_ids.clear();
1627        self.buy_qty = Quantity::zero(self.size_precision);
1628        self.sell_qty = Quantity::zero(self.size_precision);
1629        self.commissions.clear();
1630        self.signed_qty = 0.0;
1631        self.quantity = Quantity::zero(self.size_precision);
1632        self.peak_qty = Quantity::zero(self.size_precision);
1633        self.side = PositionSide::Flat;
1634        self.closing_order_id = None;
1635        self.ts_opened = UnixNanos::default();
1636        self.ts_last = UnixNanos::default();
1637        self.ts_closed = Some(UnixNanos::default());
1638        self.duration_ns = DurationNanos::default();
1639        self.avg_px_open = 0.0;
1640        self.avg_px_close = None;
1641        self.realized_pnl = None;
1642        self.realized_return = 0.0;
1643    }
1644
1645    /// Calculates the average price using f64 arithmetic.
1646    ///
1647    /// # Design Decision: f64 vs Fixed-Point Arithmetic
1648    ///
1649    /// This function uses f64 arithmetic which provides sufficient precision for financial
1650    /// calculations in this context. While f64 can introduce precision errors, the risk
1651    /// is minimal here because:
1652    ///
1653    /// 1. **No cumulative error**: Each calculation starts fresh from precise Price and
1654    ///    Quantity objects (derived from fixed-point raw values via `as_f64()`), rather
1655    ///    than carrying f64 intermediate results between calculations.
1656    ///
1657    /// 2. **Single operation**: This is a single weighted average calculation, not a
1658    ///    chain of operations where errors would compound.
1659    ///
1660    /// 3. **Overflow safety**: Raw integer arithmetic (`price_raw` * `qty_raw`) would risk
1661    ///    overflow even with i128 intermediates, since max values can exceed integer limits.
1662    ///
1663    /// 4. **f64 precision**: ~15 decimal digits is sufficient for typical financial
1664    ///    calculations at this level.
1665    ///
1666    /// For scenarios requiring higher precision (regulatory compliance, high-frequency
1667    /// micro-calculations), consider using Decimal arithmetic libraries.
1668    ///
1669    /// # Empirical Precision Validation
1670    ///
1671    /// Testing confirms f64 arithmetic maintains accuracy for typical trading scenarios:
1672    /// - **Typical amounts**: No precision loss for amounts ≥ 0.01 in standard currencies.
1673    /// - **High-precision instruments**: 9-decimal crypto prices preserved within 1e-6 tolerance.
1674    /// - **Many fills**: 100 sequential fills show no drift (commission accuracy to 1e-10).
1675    /// - **Extreme prices**: Handles range from 0.00001 to 99999.99999 without overflow/underflow.
1676    /// - **Round-trip**: Open/close at same price produces exact PnL (commissions only).
1677    ///
1678    /// See precision validation tests: `test_position_pnl_precision_*`
1679    ///
1680    /// # Errors
1681    ///
1682    /// Returns an error if:
1683    /// - Both `qty` and `last_qty` are zero.
1684    /// - `last_qty` is zero (prevents division by zero).
1685    /// - `total_qty` is zero or negative (arithmetic error).
1686    fn calculate_avg_px(
1687        &self,
1688        qty: f64,
1689        avg_pg: f64,
1690        last_px: f64,
1691        last_qty: f64,
1692    ) -> anyhow::Result<f64> {
1693        // Prices can be negative for options and spreads, so only quantities
1694        // are checked for non-negativity here.
1695        debug_assert!(
1696            qty >= 0.0 && last_qty >= 0.0,
1697            "Invariant: average price calc requires non-negative quantities \
1698             (qty={qty}, last_qty={last_qty})"
1699        );
1700
1701        if qty == 0.0 && last_qty == 0.0 {
1702            anyhow::bail!("Cannot calculate average price: both quantities are zero");
1703        }
1704
1705        if last_qty == 0.0 {
1706            anyhow::bail!("Cannot calculate average price: fill quantity is zero");
1707        }
1708
1709        if qty == 0.0 {
1710            return Ok(last_px);
1711        }
1712
1713        let start_cost = avg_pg * qty;
1714        let event_cost = last_px * last_qty;
1715        let total_qty = qty + last_qty;
1716
1717        // Runtime check to prevent division by zero even in release builds
1718        if total_qty <= 0.0 {
1719            anyhow::bail!(
1720                "Total quantity unexpectedly zero or negative in average price calculation: qty={qty}, last_qty={last_qty}, total_qty={total_qty}"
1721            );
1722        }
1723
1724        Ok((start_cost + event_cost) / total_qty)
1725    }
1726
1727    fn calculate_avg_px_open_px(&self, last_px: f64, last_qty: f64) -> f64 {
1728        self.calculate_avg_px(self.quantity.as_f64(), self.avg_px_open, last_px, last_qty)
1729            .unwrap_or_else(|e| {
1730                log::error!("Error calculating average open price: {e}");
1731                last_px
1732            })
1733    }
1734
1735    fn calculate_avg_px_close_px(&self, last_px: f64, last_qty: f64) -> f64 {
1736        let Some(avg_px_close) = self.avg_px_close else {
1737            return last_px;
1738        };
1739        let closing_qty = if self.side == PositionSide::Long {
1740            self.sell_qty
1741        } else {
1742            self.buy_qty
1743        };
1744        self.calculate_avg_px(closing_qty.as_f64(), avg_px_close, last_px, last_qty)
1745            .unwrap_or_else(|e| {
1746                log::error!("Error calculating average close price: {e}");
1747                last_px
1748            })
1749    }
1750
1751    fn calculate_points(&self, avg_px_open: f64, avg_px_close: f64) -> f64 {
1752        match self.side {
1753            PositionSide::Long => avg_px_close - avg_px_open,
1754            PositionSide::Short => avg_px_open - avg_px_close,
1755            PositionSide::Flat => 0.0,
1756        }
1757    }
1758
1759    fn calculate_points_inverse(&self, avg_px_open: f64, avg_px_close: f64) -> anyhow::Result<f64> {
1760        // Epsilon at the limit of IEEE f64 precision before rounding errors (f64::EPSILON ≈ 2.22e-16)
1761        const EPSILON: f64 = 1e-15;
1762
1763        if avg_px_open <= 0.0 || avg_px_open.abs() < EPSILON {
1764            anyhow::bail!(
1765                "Cannot calculate inverse points: open price is not positive or is too small ({avg_px_open})"
1766            );
1767        }
1768
1769        if avg_px_close <= 0.0 || avg_px_close.abs() < EPSILON {
1770            anyhow::bail!(
1771                "Cannot calculate inverse points: close price is not positive or is too small ({avg_px_close})"
1772            );
1773        }
1774
1775        let inverse_open = 1.0 / avg_px_open;
1776        let inverse_close = 1.0 / avg_px_close;
1777        let result = match self.side {
1778            PositionSide::Long => inverse_open - inverse_close,
1779            PositionSide::Short => inverse_close - inverse_open,
1780            PositionSide::Flat => 0.0,
1781        };
1782        Ok(result)
1783    }
1784
1785    fn calculate_return(&self, avg_px_open: f64, avg_px_close: f64) -> anyhow::Result<f64> {
1786        // Prevent division by zero in return calculation
1787        if avg_px_open == 0.0 {
1788            anyhow::bail!(
1789                "Cannot calculate return: open price is zero (close price: {avg_px_close})"
1790            );
1791        }
1792        Ok(self.calculate_points(avg_px_open, avg_px_close) / avg_px_open)
1793    }
1794
1795    fn calculate_pnl_raw(
1796        &self,
1797        avg_px_open: f64,
1798        avg_px_close: f64,
1799        quantity: f64,
1800    ) -> anyhow::Result<f64> {
1801        let quantity = quantity.min(self.signed_qty.abs());
1802
1803        let result = if self
1804            .instrument_class
1805            .divides_notional_by_price(self.is_inverse)
1806        {
1807            anyhow::ensure!(
1808                self.base_currency.is_some(),
1809                "inverse position {} has no base currency",
1810                self.instrument_id
1811            );
1812            let points = self.calculate_points_inverse(avg_px_open, avg_px_close)?;
1813            quantity * self.multiplier.as_f64() * points
1814        } else {
1815            quantity * self.multiplier.as_f64() * self.calculate_points(avg_px_open, avg_px_close)
1816        };
1817        Ok(result)
1818    }
1819
1820    /// Calculates profit and loss from the given prices and quantity.
1821    ///
1822    /// # Errors
1823    ///
1824    /// Returns an error if the `quantity` precision exceeds
1825    /// [`MAX_FLOAT_PRECISION`](crate::types::fixed::MAX_FLOAT_PRECISION), inverse P&L cannot be
1826    /// calculated, or the result cannot be represented as [`Money`].
1827    pub fn try_calculate_pnl(
1828        &self,
1829        avg_px_open: f64,
1830        avg_px_close: f64,
1831        quantity: Quantity,
1832    ) -> anyhow::Result<Money> {
1833        check_float_precision(quantity.precision)?;
1834        let pnl_raw = self.calculate_pnl_raw(avg_px_open, avg_px_close, quantity.as_f64())?;
1835        Money::new_checked(pnl_raw, self.settlement_currency).map_err(Into::into)
1836    }
1837
1838    /// Calculates profit and loss from the given prices and quantity.
1839    #[must_use]
1840    pub fn calculate_pnl(&self, avg_px_open: f64, avg_px_close: f64, quantity: Quantity) -> Money {
1841        self.try_calculate_pnl(avg_px_open, avg_px_close, quantity)
1842            .unwrap_or_else(|e| {
1843                log::error!("Error calculating PnL: {e}");
1844                Money::zero(self.settlement_currency)
1845            })
1846    }
1847
1848    /// Returns total P&L (realized + unrealized) based on the last price.
1849    ///
1850    /// # Errors
1851    ///
1852    /// Returns an error if unrealized P&L cannot be calculated, the realized and unrealized
1853    /// currencies differ, or the total cannot be represented as [`Money`].
1854    pub fn try_total_pnl(&self, last: Price) -> anyhow::Result<Money> {
1855        let unrealized = self.try_unrealized_pnl(last)?;
1856
1857        match self.realized_pnl {
1858            Some(realized) => {
1859                anyhow::ensure!(
1860                    realized.currency == unrealized.currency,
1861                    "realized and unrealized PnL currencies differ"
1862                );
1863                realized
1864                    .checked_add(unrealized)
1865                    .ok_or_else(|| anyhow::anyhow!("total PnL overflow"))
1866            }
1867            None => Ok(unrealized),
1868        }
1869    }
1870
1871    /// Returns total P&L (realized + unrealized) based on the last price.
1872    #[must_use]
1873    pub fn total_pnl(&self, last: Price) -> Money {
1874        self.try_total_pnl(last).unwrap_or_else(|e| {
1875            log::error!("Error calculating total PnL: {e}");
1876            Money::zero(self.settlement_currency)
1877        })
1878    }
1879
1880    /// Returns unrealized P&L based on the last price.
1881    ///
1882    /// # Errors
1883    ///
1884    /// Returns an error if the position is not flat and the `last` precision exceeds
1885    /// [`MAX_FLOAT_PRECISION`](crate::types::fixed::MAX_FLOAT_PRECISION), inverse P&L cannot be
1886    /// calculated, or the result cannot be represented as [`Money`].
1887    pub fn try_unrealized_pnl(&self, last: Price) -> anyhow::Result<Money> {
1888        if self.side == PositionSide::Flat {
1889            Ok(Money::zero(self.settlement_currency))
1890        } else {
1891            check_float_precision(last.precision)?;
1892            let pnl =
1893                self.calculate_pnl_raw(self.avg_px_open, last.as_f64(), self.quantity.as_f64())?;
1894            Money::new_checked(pnl, self.settlement_currency).map_err(Into::into)
1895        }
1896    }
1897
1898    /// Returns unrealized P&L based on the last price.
1899    #[must_use]
1900    pub fn unrealized_pnl(&self, last: Price) -> Money {
1901        self.try_unrealized_pnl(last).unwrap_or_else(|e| {
1902            log::error!("Error calculating unrealized PnL: {e}");
1903            Money::zero(self.settlement_currency)
1904        })
1905    }
1906
1907    /// Returns the order side required to close this position.
1908    #[must_use]
1909    pub fn closing_order_side(&self) -> Option<OrderSide> {
1910        match self.side {
1911            PositionSide::Long => Some(OrderSide::Sell),
1912            PositionSide::Short => Some(OrderSide::Buy),
1913            PositionSide::Flat => None,
1914        }
1915    }
1916
1917    /// Returns whether the given order side is opposite to the position entry side.
1918    #[must_use]
1919    pub fn is_opposite_side(&self, side: OrderSide) -> bool {
1920        self.entry != side
1921    }
1922
1923    /// Returns the instrument symbol.
1924    #[must_use]
1925    pub fn symbol(&self) -> Symbol {
1926        self.instrument_id.symbol
1927    }
1928
1929    /// Returns the trading venue.
1930    #[must_use]
1931    pub fn venue(&self) -> Venue {
1932        self.instrument_id.venue
1933    }
1934
1935    /// Returns the count of order fill events applied to this position.
1936    #[must_use]
1937    pub fn event_count(&self) -> usize {
1938        self.events.len()
1939    }
1940
1941    /// Returns unique client order IDs from all fill events, sorted.
1942    #[must_use]
1943    pub fn client_order_ids(&self) -> Vec<ClientOrderId> {
1944        // First to hash set to remove duplicate, then again iter to vector
1945        let mut result = self
1946            .events
1947            .iter()
1948            .map(|event| event.client_order_id)
1949            .collect::<AHashSet<ClientOrderId>>()
1950            .into_iter()
1951            .collect::<Vec<ClientOrderId>>();
1952        result.sort_unstable();
1953        result
1954    }
1955
1956    /// Returns unique venue order IDs from all fill events, sorted.
1957    #[must_use]
1958    pub fn venue_order_ids(&self) -> Vec<VenueOrderId> {
1959        // First to hash set to remove duplicate, then again iter to vector
1960        let mut result = self
1961            .events
1962            .iter()
1963            .map(|event| event.venue_order_id)
1964            .collect::<AHashSet<VenueOrderId>>()
1965            .into_iter()
1966            .collect::<Vec<VenueOrderId>>();
1967        result.sort_unstable();
1968        result
1969    }
1970
1971    /// Returns unique trade IDs from all fill events, sorted.
1972    #[must_use]
1973    pub fn trade_ids(&self) -> Vec<TradeId> {
1974        let mut result = self
1975            .events
1976            .iter()
1977            .map(|event| event.trade_id)
1978            .collect::<AHashSet<TradeId>>()
1979            .into_iter()
1980            .collect::<Vec<TradeId>>();
1981        result.sort_unstable();
1982        result
1983    }
1984
1985    /// Calculates the notional value based on the last price.
1986    ///
1987    /// # Errors
1988    ///
1989    /// Returns an error if this is an inverse position without a base currency, the price is not
1990    /// positive for a non-premium inverse position, or the result cannot be represented as
1991    /// [`Money`].
1992    pub fn try_notional_value(&self, last: Price) -> anyhow::Result<Money> {
1993        let currency = if self.is_inverse {
1994            self.base_currency.ok_or_else(|| {
1995                anyhow::anyhow!(
1996                    "inverse position {} has no base currency",
1997                    self.instrument_id
1998                )
1999            })?
2000        } else {
2001            self.settlement_currency
2002        };
2003
2004        crate::instruments::try_notional_value(
2005            self.quantity,
2006            last,
2007            self.multiplier,
2008            self.instrument_class,
2009            self.is_inverse,
2010            false,
2011            currency,
2012        )
2013    }
2014
2015    /// Calculates the notional value based on the last price.
2016    ///
2017    /// # Panics
2018    ///
2019    /// Panics if [`Position::try_notional_value`] returns an error.
2020    #[must_use]
2021    pub fn notional_value(&self, last: Price) -> Money {
2022        self.try_notional_value(last)
2023            .expect("invalid notional value")
2024    }
2025
2026    /// Returns the last `OrderFilled` event for the position (if any after purging).
2027    #[must_use]
2028    pub fn last_event(&self) -> Option<OrderFilled> {
2029        self.events.last().cloned()
2030    }
2031
2032    /// Returns the last `TradeId` for the position (if any after purging).
2033    #[must_use]
2034    pub fn last_trade_id(&self) -> Option<TradeId> {
2035        self.events.last().map(|e| e.trade_id)
2036    }
2037
2038    /// Returns whether the position is long (positive quantity).
2039    #[must_use]
2040    pub fn is_long(&self) -> bool {
2041        self.side == PositionSide::Long
2042    }
2043
2044    /// Returns whether the position is short (negative quantity).
2045    #[must_use]
2046    pub fn is_short(&self) -> bool {
2047        self.side == PositionSide::Short
2048    }
2049
2050    /// Returns whether the position is currently open (has quantity and no close timestamp).
2051    #[must_use]
2052    pub fn is_open(&self) -> bool {
2053        self.side != PositionSide::Flat && self.ts_closed.is_none()
2054    }
2055
2056    /// Returns whether the position is closed (flat with a close timestamp).
2057    #[must_use]
2058    pub fn is_closed(&self) -> bool {
2059        self.side == PositionSide::Flat && self.ts_closed.is_some()
2060    }
2061
2062    /// Returns whether an authoritative instrument close settled the current cycle.
2063    ///
2064    /// Reads the replay history, so it is false for a copy made by
2065    /// [`Self::clone_without_events`] or [`Self::clone_for_snapshot`].
2066    #[must_use]
2067    pub fn is_settled(&self) -> bool {
2068        matches!(
2069            self.replay_events.last(),
2070            Some(PositionReplayEvent::InstrumentClosed(_))
2071        )
2072    }
2073
2074    /// Returns whether replaying the position's fills alone cannot rebuild its state.
2075    ///
2076    /// Fill voids, settlements, and price restatements change state without a new
2077    /// quantity-bearing fill, so persistence must store the complete replay state.
2078    /// Retained virtual position cycles also require the complete replay state.
2079    #[must_use]
2080    pub fn requires_replay_state(&self) -> bool {
2081        // A price restatement is stored only in replay, so equal lengths cannot contain one.
2082        !self.fill_voids.is_empty()
2083            || self.is_settled()
2084            || (self.replay_events.len() > self.events.len() && self.has_replay_price_restatement())
2085            || (self.id.is_virtual() && self.replay_events.len() > self.events.len())
2086    }
2087
2088    /// Returns the signed quantity as a `Decimal`.
2089    ///
2090    /// Uses the raw `signed_qty` field to preserve full precision, as the `quantity`
2091    /// field may have reduced precision based on the instrument's `size_precision`.
2092    #[must_use]
2093    pub fn signed_decimal_qty(&self) -> Decimal {
2094        Decimal::try_from(self.signed_qty).unwrap_or(Decimal::ZERO)
2095    }
2096
2097    /// Returns the cumulative commissions for the position as a vector.
2098    #[must_use]
2099    pub fn commissions(&self) -> Vec<Money> {
2100        self.commissions.values().copied().collect()
2101    }
2102}
2103
2104impl PartialEq<Self> for Position {
2105    fn eq(&self, other: &Self) -> bool {
2106        self.id == other.id
2107    }
2108}
2109
2110impl Eq for Position {}
2111
2112impl Hash for Position {
2113    fn hash<H: Hasher>(&self, state: &mut H) {
2114        self.id.hash(state);
2115    }
2116}
2117
2118impl Display for Position {
2119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2120        let quantity_str = if self.quantity == Quantity::zero(self.size_precision) {
2121            String::new()
2122        } else {
2123            self.quantity.to_formatted_string() + " "
2124        };
2125        write!(
2126            f,
2127            "Position({} {}{}, id={})",
2128            self.side, quantity_str, self.instrument_id, self.id
2129        )
2130    }
2131}
2132
2133/// Replays position legs onto a hypothetical NETTING position in `ts_opened`
2134/// order, returning `(net_signed_qty, net_avg_px_open)`.
2135///
2136/// Each leg is `(signed_qty, avg_px_open, ts_opened_ns)`. Rules follow
2137/// [`Position::apply`]:
2138/// - Same-side legs produce a quantity-weighted average open price.
2139/// - Opposite-side legs partial-close at the existing average.
2140/// - A leg that crosses zero makes the residual take that leg's price.
2141///
2142/// Zero-quantity legs are skipped. Sort is stable on `ts_opened`; the caller
2143/// orders ties (e.g. by `position_id`).
2144#[must_use]
2145pub fn fold_net_position(legs: &[(Decimal, Decimal, u64)]) -> (Decimal, Decimal) {
2146    let mut sorted: Vec<&(Decimal, Decimal, u64)> =
2147        legs.iter().filter(|(qty, _, _)| !qty.is_zero()).collect();
2148    sorted.sort_by_key(|(_, _, ts_opened)| *ts_opened);
2149
2150    let mut net_signed_qty = Decimal::ZERO;
2151    let mut net_avg_px = Decimal::ZERO;
2152
2153    for &(p_qty, p_px, _) in sorted {
2154        if net_signed_qty.is_zero() {
2155            net_signed_qty = p_qty;
2156            net_avg_px = p_px;
2157            continue;
2158        }
2159
2160        let same_side = net_signed_qty.is_sign_negative() == p_qty.is_sign_negative();
2161        let new_net = net_signed_qty + p_qty;
2162
2163        if same_side {
2164            let total_abs = net_signed_qty.abs() + p_qty.abs();
2165            net_avg_px = (net_signed_qty.abs() * net_avg_px + p_qty.abs() * p_px) / total_abs;
2166            net_signed_qty = new_net;
2167        } else if new_net.is_zero()
2168            || new_net.is_sign_negative() == net_signed_qty.is_sign_negative()
2169        {
2170            net_signed_qty = new_net;
2171            if new_net.is_zero() {
2172                net_avg_px = Decimal::ZERO;
2173            }
2174        } else {
2175            net_signed_qty = new_net;
2176            net_avg_px = p_px;
2177        }
2178    }
2179
2180    (net_signed_qty, net_avg_px)
2181}
2182
2183#[cfg(test)]
2184mod tests {
2185    use std::str::FromStr;
2186
2187    use ahash::AHashSet;
2188    use nautilus_core::{DurationNanos, UUID4, UnixNanos, correctness::CorrectnessError};
2189    use proptest::prelude::*;
2190    use rstest::rstest;
2191    use rust_decimal::{Decimal, prelude::ToPrimitive};
2192    use rust_decimal_macros::dec;
2193
2194    #[cfg(feature = "defi")]
2195    use crate::enums::CurrencyType;
2196    use crate::{
2197        data::InstrumentClose,
2198        enums::{InstrumentCloseType, OrderSide, OrderType, PositionAdjustmentType, PositionSide},
2199        events::{
2200            OrderEventAny, OrderFillVoided, OrderFilled, PositionAdjusted, PositionSnapshot,
2201            order::spec::{OrderFillVoidedSpec, OrderFilledSpec},
2202        },
2203        identifiers::{
2204            AccountId, ClientOrderId, InstrumentId, PositionId, StrategyId, TradeId, VenueOrderId,
2205            stubs::uuid4,
2206        },
2207        instruments::{
2208            BinaryOption, CryptoFuture, CryptoOption, CryptoPerpetual, CurrencyPair, Instrument,
2209            InstrumentAny, stubs::*,
2210        },
2211        orders::{Order, builder::OrderTestBuilder, stubs::TestOrderEventStubs},
2212        position::{Position, PositionFillVoid, PositionReplayEvent, fold_net_position},
2213        stubs::*,
2214        types::{Currency, Money, Price, Quantity, money::MONEY_RAW_MAX},
2215    };
2216
2217    #[rstest]
2218    fn test_same_trade_id_new_price_restates_position_average(audusd_sim: CurrencyPair) {
2219        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2220        let position_id = PositionId::from("P-RESTATE");
2221        let trade_id = TradeId::from("T-1");
2222        let opening = OrderFilledSpec::builder()
2223            .instrument_id(instrument.id())
2224            .trade_id(trade_id)
2225            .order_side(OrderSide::Buy)
2226            .last_qty(Quantity::from(2))
2227            .last_px(Price::from("12"))
2228            .position_id(position_id)
2229            .build();
2230        let restatement = OrderFilledSpec::builder()
2231            .instrument_id(instrument.id())
2232            .trade_id(trade_id)
2233            .order_side(OrderSide::Buy)
2234            .last_qty(Quantity::from(2))
2235            .last_px(Price::from("9.47"))
2236            .position_id(position_id)
2237            .event_id(UUID4::new())
2238            .build();
2239        let mut position = Position::new(&instrument, opening);
2240        position.apply(&restatement);
2241
2242        assert_eq!(position.quantity.as_decimal(), Decimal::from(2));
2243        assert_eq!(position.side, PositionSide::Long);
2244        assert_eq!(position.avg_px_open, Price::from("9.47").as_f64());
2245        assert_eq!(position.events[0].last_px, Price::from("12"));
2246
2247        let voided = OrderFillVoidedSpec::builder()
2248            .instrument_id(instrument.id())
2249            .trade_id(trade_id)
2250            .voided_qty(Quantity::from(1))
2251            .last_px(Price::from("12"))
2252            .order_side(OrderSide::Buy)
2253            .position_id(position_id)
2254            .build();
2255        position
2256            .apply_fill_void(voided, Quantity::from(1), None)
2257            .unwrap();
2258
2259        assert_eq!(position.quantity.as_decimal(), Decimal::from(1));
2260        assert_eq!(position.avg_px_open, Price::from("9.47").as_f64());
2261        assert_eq!(position.events[0].last_px, Price::from("12"));
2262        assert!(position.requires_replay_state());
2263    }
2264
2265    #[rstest]
2266    fn test_price_restatement_of_closing_fill_keeps_open_average(audusd_sim: CurrencyPair) {
2267        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2268        let position_id = PositionId::from("P-CLOSE-RESTATE");
2269        let opening = OrderFilledSpec::builder()
2270            .instrument_id(instrument.id())
2271            .trade_id(TradeId::from("T-OPEN"))
2272            .order_side(OrderSide::Buy)
2273            .last_qty(Quantity::from(2))
2274            .last_px(Price::from("12"))
2275            .position_id(position_id)
2276            .build();
2277        let closing = OrderFilledSpec::builder()
2278            .instrument_id(instrument.id())
2279            .trade_id(TradeId::from("T-CLOSE"))
2280            .order_side(OrderSide::Sell)
2281            .last_qty(Quantity::from(1))
2282            .last_px(Price::from("15"))
2283            .position_id(position_id)
2284            .event_id(UUID4::new())
2285            .build();
2286        let restatement = OrderFilledSpec::builder()
2287            .instrument_id(instrument.id())
2288            .trade_id(TradeId::from("T-CLOSE"))
2289            .order_side(OrderSide::Sell)
2290            .last_qty(Quantity::from(1))
2291            .last_px(Price::from("12.75"))
2292            .position_id(position_id)
2293            .event_id(UUID4::new())
2294            .build();
2295        let mut position = Position::new(&instrument, opening);
2296        position.apply(&closing);
2297        position.apply(&restatement);
2298
2299        assert_eq!(position.quantity.as_decimal(), Decimal::from(1));
2300        assert_eq!(position.side, PositionSide::Long);
2301        assert_eq!(position.avg_px_open, Price::from("12").as_f64());
2302        assert_eq!(position.avg_px_close, Some(Price::from("15").as_f64()));
2303        assert_eq!(
2304            position.realized_pnl.map(|pnl| pnl.as_decimal()),
2305            Some(dec!(3))
2306        );
2307        assert_eq!(position.events[1].last_px, Price::from("15"));
2308    }
2309
2310    #[rstest]
2311    fn test_restatement_of_reversal_fill_keeps_flipped_average(audusd_sim: CurrencyPair) {
2312        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2313        let position_id = PositionId::from("P-REVERSAL-RESTATE");
2314        let opening = OrderFilledSpec::builder()
2315            .instrument_id(instrument.id())
2316            .trade_id(TradeId::from("T-OPEN"))
2317            .order_side(OrderSide::Buy)
2318            .last_qty(Quantity::from(2))
2319            .last_px(Price::from("12"))
2320            .position_id(position_id)
2321            .build();
2322        let reversal = OrderFilledSpec::builder()
2323            .instrument_id(instrument.id())
2324            .trade_id(TradeId::from("T-REVERSE"))
2325            .order_side(OrderSide::Sell)
2326            .last_qty(Quantity::from(3))
2327            .last_px(Price::from("10"))
2328            .position_id(position_id)
2329            .event_id(UUID4::new())
2330            .build();
2331        let restatement = OrderFilledSpec::builder()
2332            .instrument_id(instrument.id())
2333            .trade_id(TradeId::from("T-REVERSE"))
2334            .order_side(OrderSide::Sell)
2335            .last_qty(Quantity::from(3))
2336            .last_px(Price::from("9"))
2337            .position_id(position_id)
2338            .event_id(UUID4::new())
2339            .build();
2340        let mut position = Position::new(&instrument, opening);
2341        position.apply(&reversal);
2342        position.apply(&restatement);
2343
2344        assert_eq!(position.side, PositionSide::Short);
2345        assert_eq!(position.quantity.as_decimal(), Decimal::from(1));
2346        assert_eq!(position.avg_px_open, Price::from("10").as_f64());
2347    }
2348
2349    #[rstest]
2350    fn test_restatement_share_excludes_reversal_closing_quantity(audusd_sim: CurrencyPair) {
2351        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2352        let position_id = PositionId::from("P-REVERSAL-SHARE");
2353        let trade_id = TradeId::from("T-RESTATE");
2354
2355        let mut position = Position::new(
2356            &instrument,
2357            OrderFilledSpec::builder()
2358                .instrument_id(instrument.id())
2359                .trade_id(TradeId::from("T-OPEN"))
2360                .order_side(OrderSide::Buy)
2361                .last_qty(Quantity::from(2))
2362                .last_px(Price::from("12"))
2363                .position_id(position_id)
2364                .build(),
2365        );
2366
2367        for (trade, side, qty, px) in [
2368            ("T-REVERSE", OrderSide::Sell, 3, "10"),
2369            (trade_id.as_str(), OrderSide::Sell, 1, "8"),
2370            ("T-CLOSE", OrderSide::Buy, 1, "9"),
2371        ] {
2372            position.apply(
2373                &OrderFilledSpec::builder()
2374                    .instrument_id(instrument.id())
2375                    .trade_id(TradeId::from(trade))
2376                    .order_side(side)
2377                    .last_qty(Quantity::from(qty))
2378                    .last_px(Price::from(px))
2379                    .position_id(position_id)
2380                    .event_id(UUID4::new())
2381                    .build(),
2382            );
2383        }
2384
2385        assert_eq!(position.side, PositionSide::Short);
2386        assert_eq!(position.quantity.as_decimal(), Decimal::from(1));
2387        assert_eq!(position.avg_px_open, Price::from("9").as_f64());
2388
2389        position.apply(
2390            &OrderFilledSpec::builder()
2391                .instrument_id(instrument.id())
2392                .trade_id(trade_id)
2393                .order_side(OrderSide::Sell)
2394                .last_qty(Quantity::from(1))
2395                .last_px(Price::from("6"))
2396                .position_id(position_id)
2397                .event_id(UUID4::new())
2398                .build(),
2399        );
2400
2401        assert_eq!(position.avg_px_open, Price::from("8").as_f64());
2402    }
2403
2404    #[rstest]
2405    fn test_price_restatement_uses_surviving_quantity(audusd_sim: CurrencyPair) {
2406        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2407        let position_id = PositionId::from("P-SURVIVING");
2408        let opening = OrderFilledSpec::builder()
2409            .instrument_id(instrument.id())
2410            .trade_id(TradeId::from("T-1"))
2411            .order_side(OrderSide::Buy)
2412            .last_qty(Quantity::from(2))
2413            .last_px(Price::from("12"))
2414            .position_id(position_id)
2415            .build();
2416        let later = OrderFilledSpec::builder()
2417            .instrument_id(instrument.id())
2418            .trade_id(TradeId::from("T-2"))
2419            .order_side(OrderSide::Buy)
2420            .last_qty(Quantity::from(1))
2421            .last_px(Price::from("10"))
2422            .position_id(position_id)
2423            .event_id(UUID4::new())
2424            .build();
2425        let restatement = OrderFilledSpec::builder()
2426            .instrument_id(instrument.id())
2427            .trade_id(TradeId::from("T-1"))
2428            .order_side(OrderSide::Buy)
2429            .last_qty(Quantity::from(1))
2430            .last_px(Price::from("9.47"))
2431            .position_id(position_id)
2432            .event_id(UUID4::new())
2433            .build();
2434        let mut position = Position::new(&instrument, opening);
2435        let voided = OrderFillVoidedSpec::builder()
2436            .instrument_id(instrument.id())
2437            .trade_id(TradeId::from("T-1"))
2438            .voided_qty(Quantity::from(1))
2439            .last_px(Price::from("12"))
2440            .order_side(OrderSide::Buy)
2441            .position_id(position_id)
2442            .build();
2443        position
2444            .apply_fill_void(voided, Quantity::from(1), None)
2445            .unwrap();
2446        position.apply(&later);
2447        position.apply(&restatement);
2448
2449        let expected = f64::midpoint(11.0 * 2.0 - 12.0, Price::from("9.47").as_f64());
2450        assert_eq!(position.quantity.as_decimal(), Decimal::from(2));
2451        assert_eq!(position.avg_px_open, expected);
2452    }
2453
2454    #[rstest]
2455    fn test_reapplying_same_price_restatement_is_idempotent(audusd_sim: CurrencyPair) {
2456        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2457        let position_id = PositionId::from("P-IDEMPOTENT");
2458        let trade_id = TradeId::from("T-1");
2459        let opening = OrderFilledSpec::builder()
2460            .instrument_id(instrument.id())
2461            .trade_id(trade_id)
2462            .order_side(OrderSide::Buy)
2463            .last_qty(Quantity::from(2))
2464            .last_px(Price::from("12"))
2465            .position_id(position_id)
2466            .build();
2467        let restatement = OrderFilledSpec::builder()
2468            .instrument_id(instrument.id())
2469            .trade_id(trade_id)
2470            .order_side(OrderSide::Buy)
2471            .last_qty(Quantity::from(2))
2472            .last_px(Price::from("9.47"))
2473            .position_id(position_id)
2474            .event_id(UUID4::new())
2475            .build();
2476        let mut position = Position::new(&instrument, opening);
2477        position.apply(&restatement);
2478        let replay_len = position.replay_events.len();
2479        position.apply(&restatement);
2480
2481        assert_eq!(position.avg_px_open, Price::from("9.47").as_f64());
2482        assert_eq!(position.replay_events.len(), replay_len);
2483    }
2484
2485    #[rstest]
2486    fn test_purge_keeps_price_restatement_on_surviving_order(audusd_sim: CurrencyPair) {
2487        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2488        let position_id = PositionId::from("P-PURGE-RESTATE");
2489        let opening = OrderFilledSpec::builder()
2490            .client_order_id(ClientOrderId::from("O-KEEP"))
2491            .instrument_id(instrument.id())
2492            .trade_id(TradeId::from("T-KEEP"))
2493            .order_side(OrderSide::Buy)
2494            .last_qty(Quantity::from(2))
2495            .last_px(Price::from("12"))
2496            .position_id(position_id)
2497            .build();
2498        let other = OrderFilledSpec::builder()
2499            .client_order_id(ClientOrderId::from("O-DROP"))
2500            .instrument_id(instrument.id())
2501            .trade_id(TradeId::from("T-DROP"))
2502            .order_side(OrderSide::Buy)
2503            .last_qty(Quantity::from(1))
2504            .last_px(Price::from("10"))
2505            .position_id(position_id)
2506            .event_id(UUID4::new())
2507            .build();
2508        let restatement = OrderFilledSpec::builder()
2509            .client_order_id(ClientOrderId::from("O-KEEP"))
2510            .instrument_id(instrument.id())
2511            .trade_id(TradeId::from("T-KEEP"))
2512            .order_side(OrderSide::Buy)
2513            .last_qty(Quantity::from(2))
2514            .last_px(Price::from("9.47"))
2515            .position_id(position_id)
2516            .event_id(UUID4::new())
2517            .build();
2518        let mut position = Position::new(&instrument, opening);
2519        position.apply(&other);
2520        position.apply(&restatement);
2521        position.purge_events_for_order(ClientOrderId::from("O-DROP"));
2522
2523        assert_eq!(position.quantity.as_decimal(), Decimal::from(2));
2524        assert_eq!(position.avg_px_open, Price::from("9.47").as_f64());
2525    }
2526
2527    #[rstest]
2528    fn test_price_restatement_excludes_closed_quantity(audusd_sim: CurrencyPair) {
2529        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2530        let position_id = PositionId::from("P-CLOSED-QTY");
2531        let opening = OrderFilledSpec::builder()
2532            .instrument_id(instrument.id())
2533            .trade_id(TradeId::from("T-1"))
2534            .order_side(OrderSide::Buy)
2535            .last_qty(Quantity::from(2))
2536            .last_px(Price::from("12"))
2537            .position_id(position_id)
2538            .build();
2539        let closing = OrderFilledSpec::builder()
2540            .instrument_id(instrument.id())
2541            .trade_id(TradeId::from("T-CLOSE"))
2542            .order_side(OrderSide::Sell)
2543            .last_qty(Quantity::from(1))
2544            .last_px(Price::from("15"))
2545            .position_id(position_id)
2546            .event_id(UUID4::new())
2547            .build();
2548        let later = OrderFilledSpec::builder()
2549            .instrument_id(instrument.id())
2550            .trade_id(TradeId::from("T-2"))
2551            .order_side(OrderSide::Buy)
2552            .last_qty(Quantity::from(1))
2553            .last_px(Price::from("10"))
2554            .position_id(position_id)
2555            .event_id(UUID4::new())
2556            .build();
2557        let restatement = OrderFilledSpec::builder()
2558            .instrument_id(instrument.id())
2559            .trade_id(TradeId::from("T-1"))
2560            .order_side(OrderSide::Buy)
2561            .last_qty(Quantity::from(2))
2562            .last_px(Price::from("9.47"))
2563            .position_id(position_id)
2564            .event_id(UUID4::new())
2565            .build();
2566        let mut position = Position::new(&instrument, opening);
2567        position.apply(&closing);
2568        position.apply(&later);
2569        position.apply(&restatement);
2570
2571        let expected = f64::midpoint(11.0 * 2.0 - 12.0, Price::from("9.47").as_f64());
2572        assert_eq!(position.quantity.as_decimal(), Decimal::from(2));
2573        assert_eq!(position.avg_px_open, expected);
2574    }
2575
2576    #[rstest]
2577    fn test_price_restatement_after_close_across_two_lots_uses_average_cost(
2578        audusd_sim: CurrencyPair,
2579    ) {
2580        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2581        let position_id = PositionId::from("P-AVG-COST");
2582        let first = OrderFilledSpec::builder()
2583            .instrument_id(instrument.id())
2584            .trade_id(TradeId::from("T-1"))
2585            .order_side(OrderSide::Buy)
2586            .last_qty(Quantity::from(2))
2587            .last_px(Price::from("12"))
2588            .position_id(position_id)
2589            .build();
2590        let second = OrderFilledSpec::builder()
2591            .instrument_id(instrument.id())
2592            .trade_id(TradeId::from("T-2"))
2593            .order_side(OrderSide::Buy)
2594            .last_qty(Quantity::from(2))
2595            .last_px(Price::from("10"))
2596            .position_id(position_id)
2597            .event_id(UUID4::new())
2598            .build();
2599        let closing = OrderFilledSpec::builder()
2600            .instrument_id(instrument.id())
2601            .trade_id(TradeId::from("T-CLOSE"))
2602            .order_side(OrderSide::Sell)
2603            .last_qty(Quantity::from(2))
2604            .last_px(Price::from("15"))
2605            .position_id(position_id)
2606            .event_id(UUID4::new())
2607            .build();
2608        let restatement = OrderFilledSpec::builder()
2609            .instrument_id(instrument.id())
2610            .trade_id(TradeId::from("T-1"))
2611            .order_side(OrderSide::Buy)
2612            .last_qty(Quantity::from(2))
2613            .last_px(Price::from("9.47"))
2614            .position_id(position_id)
2615            .event_id(UUID4::new())
2616            .build();
2617        let mut position = Position::new(&instrument, first);
2618        position.apply(&second);
2619        position.apply(&closing);
2620        position.apply(&restatement);
2621
2622        let expected = f64::midpoint(11.0 * 2.0 - 12.0, Price::from("9.47").as_f64());
2623        assert_eq!(position.quantity.as_decimal(), Decimal::from(2));
2624        assert_eq!(position.avg_px_open, expected);
2625    }
2626
2627    #[rstest]
2628    fn test_purge_applies_restatement_before_later_close(audusd_sim: CurrencyPair) {
2629        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2630        let position_id = PositionId::from("P-PURGE-ORDER");
2631        let opening = OrderFilledSpec::builder()
2632            .client_order_id(ClientOrderId::from("O-KEEP"))
2633            .instrument_id(instrument.id())
2634            .trade_id(TradeId::from("T-KEEP"))
2635            .order_side(OrderSide::Buy)
2636            .last_qty(Quantity::from(2))
2637            .last_px(Price::from("12"))
2638            .position_id(position_id)
2639            .build();
2640        let restatement = OrderFilledSpec::builder()
2641            .client_order_id(ClientOrderId::from("O-KEEP"))
2642            .instrument_id(instrument.id())
2643            .trade_id(TradeId::from("T-KEEP"))
2644            .order_side(OrderSide::Buy)
2645            .last_qty(Quantity::from(2))
2646            .last_px(Price::from("9.47"))
2647            .position_id(position_id)
2648            .event_id(UUID4::new())
2649            .build();
2650        let closing = OrderFilledSpec::builder()
2651            .client_order_id(ClientOrderId::from("O-CLOSE"))
2652            .instrument_id(instrument.id())
2653            .trade_id(TradeId::from("T-CLOSE"))
2654            .order_side(OrderSide::Sell)
2655            .last_qty(Quantity::from(1))
2656            .last_px(Price::from("15"))
2657            .position_id(position_id)
2658            .event_id(UUID4::new())
2659            .build();
2660        let other = OrderFilledSpec::builder()
2661            .client_order_id(ClientOrderId::from("O-DROP"))
2662            .instrument_id(instrument.id())
2663            .trade_id(TradeId::from("T-DROP"))
2664            .order_side(OrderSide::Buy)
2665            .last_qty(Quantity::from(1))
2666            .last_px(Price::from("10"))
2667            .position_id(position_id)
2668            .event_id(UUID4::new())
2669            .build();
2670        let mut position = Position::new(&instrument, opening);
2671        position.apply(&restatement);
2672        position.apply(&closing);
2673        let pnl_before = position.realized_pnl;
2674        let avg_before = position.avg_px_open;
2675        position.apply(&other);
2676        position.purge_events_for_order(ClientOrderId::from("O-DROP"));
2677
2678        assert_eq!(position.realized_pnl, pnl_before);
2679        assert_eq!(position.avg_px_open, avg_before);
2680        assert_eq!(position.quantity.as_decimal(), Decimal::from(1));
2681    }
2682
2683    #[rstest]
2684    fn test_full_void_after_restatement_does_not_reopen(audusd_sim: CurrencyPair) {
2685        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2686        let position_id = PositionId::from("P-VOID-RESTATE");
2687        let trade_id = TradeId::from("T-1");
2688        let opening = OrderFilledSpec::builder()
2689            .instrument_id(instrument.id())
2690            .trade_id(trade_id)
2691            .order_side(OrderSide::Buy)
2692            .last_qty(Quantity::from(2))
2693            .last_px(Price::from("12"))
2694            .position_id(position_id)
2695            .build();
2696        let restatement = OrderFilledSpec::builder()
2697            .instrument_id(instrument.id())
2698            .trade_id(trade_id)
2699            .order_side(OrderSide::Buy)
2700            .last_qty(Quantity::from(2))
2701            .last_px(Price::from("9.47"))
2702            .position_id(position_id)
2703            .event_id(UUID4::new())
2704            .build();
2705        let mut position = Position::new(&instrument, opening);
2706        position.apply(&restatement);
2707        let voided = OrderFillVoidedSpec::builder()
2708            .instrument_id(instrument.id())
2709            .trade_id(trade_id)
2710            .voided_qty(Quantity::from(2))
2711            .last_px(Price::from("12"))
2712            .order_side(OrderSide::Buy)
2713            .position_id(position_id)
2714            .build();
2715        position
2716            .apply_fill_void(voided, Quantity::from(2), None)
2717            .unwrap();
2718
2719        assert_eq!(position.side, PositionSide::Flat);
2720        assert!(position.quantity.is_zero());
2721    }
2722
2723    #[rstest]
2724    fn test_return_to_original_price_then_full_void_stays_flat(audusd_sim: CurrencyPair) {
2725        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2726        let position_id = PositionId::from("P-PRICE-ROUNDTRIP");
2727        let trade_id = TradeId::from("T-1");
2728        let opening = OrderFilledSpec::builder()
2729            .instrument_id(instrument.id())
2730            .trade_id(trade_id)
2731            .order_side(OrderSide::Buy)
2732            .last_qty(Quantity::from(2))
2733            .last_px(Price::from("12"))
2734            .position_id(position_id)
2735            .build();
2736        let restated = OrderFilledSpec::builder()
2737            .instrument_id(instrument.id())
2738            .trade_id(trade_id)
2739            .order_side(OrderSide::Buy)
2740            .last_qty(Quantity::from(2))
2741            .last_px(Price::from("9.47"))
2742            .position_id(position_id)
2743            .event_id(UUID4::new())
2744            .build();
2745        let restored = OrderFilledSpec::builder()
2746            .instrument_id(instrument.id())
2747            .trade_id(trade_id)
2748            .order_side(OrderSide::Buy)
2749            .last_qty(Quantity::from(2))
2750            .last_px(Price::from("12"))
2751            .position_id(position_id)
2752            .event_id(UUID4::new())
2753            .build();
2754        let mut position = Position::new(&instrument, opening);
2755        position.apply(&restated);
2756        position.apply(&restored);
2757        let voided = OrderFillVoidedSpec::builder()
2758            .instrument_id(instrument.id())
2759            .trade_id(trade_id)
2760            .voided_qty(Quantity::from(2))
2761            .last_px(Price::from("12"))
2762            .order_side(OrderSide::Buy)
2763            .position_id(position_id)
2764            .build();
2765        position
2766            .apply_fill_void(voided, Quantity::from(2), None)
2767            .unwrap();
2768
2769        assert_eq!(position.side, PositionSide::Flat);
2770        assert!(position.quantity.is_zero());
2771    }
2772
2773    #[rstest]
2774    fn test_purge_keeps_voided_quantity_on_surviving_order(audusd_sim: CurrencyPair) {
2775        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2776        let position_id = PositionId::from("P-PURGE-VOID");
2777        let opening = OrderFilledSpec::builder()
2778            .client_order_id(ClientOrderId::from("O-KEEP"))
2779            .instrument_id(instrument.id())
2780            .trade_id(TradeId::from("T-KEEP"))
2781            .order_side(OrderSide::Buy)
2782            .last_qty(Quantity::from(2))
2783            .last_px(Price::from("12"))
2784            .position_id(position_id)
2785            .build();
2786        let other = OrderFilledSpec::builder()
2787            .client_order_id(ClientOrderId::from("O-DROP"))
2788            .instrument_id(instrument.id())
2789            .trade_id(TradeId::from("T-DROP"))
2790            .order_side(OrderSide::Buy)
2791            .last_qty(Quantity::from(1))
2792            .last_px(Price::from("10"))
2793            .position_id(position_id)
2794            .event_id(UUID4::new())
2795            .build();
2796        let mut position = Position::new(&instrument, opening);
2797        let voided = OrderFillVoidedSpec::builder()
2798            .client_order_id(ClientOrderId::from("O-KEEP"))
2799            .instrument_id(instrument.id())
2800            .trade_id(TradeId::from("T-KEEP"))
2801            .voided_qty(Quantity::from(1))
2802            .last_px(Price::from("12"))
2803            .order_side(OrderSide::Buy)
2804            .position_id(position_id)
2805            .build();
2806        position
2807            .apply_fill_void(voided, Quantity::from(1), None)
2808            .unwrap();
2809        position.apply(&other);
2810        position.purge_events_for_order(ClientOrderId::from("O-DROP"));
2811
2812        assert_eq!(position.quantity.as_decimal(), Decimal::from(1));
2813    }
2814
2815    #[rstest]
2816    fn test_position_long_display(stub_position_long: Position) {
2817        let display = format!("{stub_position_long}");
2818        assert_eq!(display, "Position(LONG 1 AUD/USD.SIM, id=1)");
2819    }
2820
2821    #[rstest]
2822    fn test_position_short_display(stub_position_short: Position) {
2823        let display = format!("{stub_position_short}");
2824        assert_eq!(display, "Position(SHORT 1 AUD/USD.SIM, id=1)");
2825    }
2826
2827    #[rstest]
2828    #[case::open(false)]
2829    #[case::closed(true)]
2830    fn test_clone_without_events_preserves_current_state(
2831        mut stub_position_long: Position,
2832        #[case] close: bool,
2833    ) {
2834        let adjustment = PositionAdjusted::new(
2835            stub_position_long.trader_id,
2836            stub_position_long.strategy_id,
2837            stub_position_long.instrument_id,
2838            stub_position_long.id,
2839            stub_position_long.account_id,
2840            PositionAdjustmentType::Funding,
2841            None,
2842            Some(Money::from_decimal(dec!(1.25), stub_position_long.settlement_currency).unwrap()),
2843            Some("clone-test".into()),
2844            uuid4(),
2845            UnixNanos::from(2),
2846            UnixNanos::from(2),
2847        );
2848        stub_position_long.apply_adjustment(adjustment);
2849
2850        if close {
2851            let closing_fill = OrderFilledSpec::builder()
2852                .trader_id(stub_position_long.trader_id)
2853                .strategy_id(stub_position_long.strategy_id)
2854                .instrument_id(stub_position_long.instrument_id)
2855                .client_order_id(ClientOrderId::from("CLONE-CLOSE"))
2856                .venue_order_id(VenueOrderId::from("CLONE-CLOSE"))
2857                .account_id(stub_position_long.account_id)
2858                .trade_id(TradeId::from("CLONE-CLOSE"))
2859                .order_side(OrderSide::Sell)
2860                .order_type(OrderType::Market)
2861                .last_qty(stub_position_long.quantity)
2862                .last_px(Price::from("1.0012"))
2863                .currency(stub_position_long.settlement_currency)
2864                .position_id(stub_position_long.id)
2865                .ts_event(UnixNanos::from(3))
2866                .ts_init(UnixNanos::from(3))
2867                .build();
2868            stub_position_long.apply(&closing_fill);
2869        }
2870
2871        let source_fill = stub_position_long.events[0].clone();
2872        let fill_voided = matching_fill_void(&source_fill, source_fill.last_qty, None);
2873        stub_position_long.fill_voids.push(PositionFillVoid {
2874            event: fill_voided,
2875            voided_qty: source_fill.last_qty,
2876            commission_voided: source_fill.commission,
2877        });
2878
2879        let cloned = stub_position_long.clone_without_events();
2880        let mut expected = stub_position_long.clone();
2881        expected.events.clear();
2882        expected.adjustments.clear();
2883        expected.replay_events.clear();
2884        expected.fill_voids.clear();
2885        expected.trade_ids.clear();
2886
2887        assert!(!stub_position_long.events.is_empty());
2888        assert!(!stub_position_long.adjustments.is_empty());
2889        assert!(!stub_position_long.replay_events.is_empty());
2890        assert!(!stub_position_long.fill_voids.is_empty());
2891        assert!(!stub_position_long.trade_ids.is_empty());
2892        assert!(cloned.events.is_empty());
2893        assert!(cloned.adjustments.is_empty());
2894        assert!(cloned.replay_events.is_empty());
2895        assert!(cloned.fill_voids.is_empty());
2896        assert!(cloned.trade_ids.is_empty());
2897        assert_eq!(
2898            serde_json::to_value(cloned).unwrap(),
2899            serde_json::to_value(expected).unwrap()
2900        );
2901    }
2902
2903    #[rstest]
2904    fn test_clone_for_snapshot_matches_clone_then_clear(mut stub_position_long: Position) {
2905        let source_fill = stub_position_long.events[0].clone();
2906        stub_position_long.fill_voids.push(PositionFillVoid {
2907            event: matching_fill_void(&source_fill, source_fill.last_qty, None),
2908            voided_qty: source_fill.last_qty,
2909            commission_voided: source_fill.commission,
2910        });
2911        let mut expected = stub_position_long.clone();
2912        expected.replay_events.clear();
2913        expected.fill_voids.clear();
2914
2915        let snapshot = stub_position_long.clone_for_snapshot();
2916
2917        assert!(snapshot.replay_events.is_empty());
2918        assert!(snapshot.fill_voids.is_empty());
2919        assert_eq!(
2920            serde_json::to_vec(&snapshot).unwrap(),
2921            serde_json::to_vec(&expected).unwrap(),
2922        );
2923    }
2924
2925    #[rstest]
2926    fn test_new_checked_rejects_missing_position_id(audusd_sim: CurrencyPair) {
2927        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2928        let fill = OrderFilledSpec::builder()
2929            .instrument_id(instrument.id())
2930            .build();
2931
2932        let error = Position::new_checked(&instrument, fill).unwrap_err();
2933
2934        assert_eq!(
2935            error,
2936            CorrectnessError::PredicateViolation {
2937                message: "`fill.position_id` was None".to_string(),
2938            }
2939        );
2940    }
2941
2942    #[rstest]
2943    fn test_new_checked_rejects_instrument_mismatch(audusd_sim: CurrencyPair) {
2944        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2945        let fill = OrderFilledSpec::builder()
2946            .instrument_id(InstrumentId::from("GBP/USD.SIM"))
2947            .position_id(PositionId::from("P-1"))
2948            .build();
2949
2950        let error = Position::new_checked(&instrument, fill).unwrap_err();
2951
2952        assert_eq!(
2953            error,
2954            CorrectnessError::EqualityMismatch {
2955                lhs_param: "instrument.id()".to_string(),
2956                rhs_param: "fill.instrument_id".to_string(),
2957                lhs: "AUD/USD.SIM".to_string(),
2958                rhs: "GBP/USD.SIM".to_string(),
2959                type_name: "value",
2960            }
2961        );
2962    }
2963
2964    #[rstest]
2965    #[case::instrument_mismatch(
2966        "GBP/USD.SIM",
2967        Some("P-1"),
2968        "'self.instrument_id' value of AUD/USD.SIM was not equal to 'fill.instrument_id' value of GBP/USD.SIM"
2969    )]
2970    #[case::missing_position_id("AUD/USD.SIM", None, "`fill.position_id` was None")]
2971    #[case::position_mismatch(
2972        "AUD/USD.SIM",
2973        Some("P-2"),
2974        "'self.id' value of P-1 was not equal to 'fill.position_id' value of P-2"
2975    )]
2976    fn test_try_apply_rejects_invalid_fill_identity_without_mutation(
2977        #[case] fill_instrument_id: &str,
2978        #[case] fill_position_id: Option<&str>,
2979        #[case] expected_error: &str,
2980        audusd_sim: CurrencyPair,
2981    ) {
2982        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
2983        let position_id = PositionId::from("P-1");
2984        let fill_open = OrderFilledSpec::builder()
2985            .instrument_id(instrument.id())
2986            .trade_id(TradeId::from("T-1"))
2987            .position_id(position_id)
2988            .build();
2989        let mut fill_invalid = OrderFilledSpec::builder()
2990            .instrument_id(InstrumentId::from(fill_instrument_id))
2991            .trade_id(TradeId::from("T-2"))
2992            .build();
2993        fill_invalid.position_id = fill_position_id.map(PositionId::from);
2994        let mut position = Position::new(&instrument, fill_open);
2995        let state_before = serde_json::to_value(&position).unwrap();
2996
2997        let error = position.try_apply(&fill_invalid).unwrap_err();
2998
2999        assert_eq!(error.to_string(), expected_error);
3000        assert_eq!(serde_json::to_value(&position).unwrap(), state_before);
3001    }
3002
3003    #[rstest]
3004    fn test_try_apply_rejects_duplicate_trade_without_mutation(audusd_sim: CurrencyPair) {
3005        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3006        let position_id = PositionId::from("P-1");
3007        let fill_open = OrderFilledSpec::builder()
3008            .instrument_id(instrument.id())
3009            .trade_id(TradeId::from("T-1"))
3010            .position_id(position_id)
3011            .build();
3012        let fill_duplicate = OrderFilledSpec::builder()
3013            .instrument_id(instrument.id())
3014            .client_order_id(ClientOrderId::from("O-2"))
3015            .trade_id(TradeId::from("T-1"))
3016            .position_id(position_id)
3017            .build();
3018        let mut position = Position::new(&instrument, fill_open);
3019        let state_before = serde_json::to_value(&position).unwrap();
3020
3021        let error = position.try_apply(&fill_duplicate).unwrap_err();
3022
3023        assert_eq!(
3024            error,
3025            CorrectnessError::PredicateViolation {
3026                message: "`fill.trade_id` already contained in `trade_ids`".to_string(),
3027            }
3028        );
3029        assert_eq!(serde_json::to_value(&position).unwrap(), state_before);
3030    }
3031
3032    #[cfg(feature = "defi")]
3033    #[rstest]
3034    #[case::price_17("1.00000000000000001", "100000", None, 17)]
3035    #[case::price_18("1.000000000000000001", "100000", None, 18)]
3036    #[case::quantity_18("1.00000", "1.000000000000000001", None, 18)]
3037    #[case::settlement_commission_18("1.00000", "100000", Some(("USD", 18)), 18)]
3038    #[case::base_commission_18("1.00000", "100000", Some(("AUD", 18)), 18)]
3039    fn test_new_checked_rejects_fill_values_above_float_precision(
3040        #[case] last_px: &str,
3041        #[case] last_qty: &str,
3042        #[case] commission: Option<(&str, u8)>,
3043        #[case] expected_precision: u8,
3044        audusd_sim: CurrencyPair,
3045    ) {
3046        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3047        let mut fill = OrderFilledSpec::builder()
3048            .instrument_id(instrument.id())
3049            .last_px(Price::from(last_px))
3050            .last_qty(Quantity::from(last_qty))
3051            .position_id(PositionId::from("P-1"))
3052            .build();
3053        fill.commission = commission.map(|(code, precision)| {
3054            let currency = Currency::new(code, precision, 0, code, CurrencyType::Fiat);
3055            Money::from_decimal(dec!(1), currency).unwrap()
3056        });
3057
3058        let error = Position::new_checked(&instrument, fill).unwrap_err();
3059
3060        assert_eq!(
3061            error.to_string(),
3062            format!(
3063                "Fixed-point precision {expected_precision} exceeds maximum float precision 16"
3064            )
3065        );
3066    }
3067
3068    #[cfg(feature = "defi")]
3069    #[rstest]
3070    #[case::size_precision(18, 0, 2)]
3071    #[case::multiplier(0, 18, 2)]
3072    #[case::settlement_currency(0, 0, 18)]
3073    fn test_new_checked_rejects_instrument_above_float_precision(
3074        #[case] size_precision: u8,
3075        #[case] multiplier_precision: u8,
3076        #[case] quote_precision: u8,
3077        mut audusd_sim: CurrencyPair,
3078    ) {
3079        audusd_sim.size_precision = size_precision;
3080        audusd_sim.multiplier = Quantity::from_decimal_dp(dec!(1), multiplier_precision).unwrap();
3081        audusd_sim.quote_currency =
3082            Currency::new("USD", quote_precision, 840, "US dollar", CurrencyType::Fiat);
3083        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3084        let fill = OrderFilledSpec::builder()
3085            .instrument_id(instrument.id())
3086            .position_id(PositionId::from("P-1"))
3087            .build();
3088
3089        let error = Position::new_checked(&instrument, fill).unwrap_err();
3090
3091        assert_eq!(
3092            error.to_string(),
3093            "Fixed-point precision 18 exceeds maximum float precision 16"
3094        );
3095    }
3096
3097    #[cfg(feature = "defi")]
3098    #[rstest]
3099    fn test_new_checked_accepts_fill_values_at_float_precision(audusd_sim: CurrencyPair) {
3100        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3101        let usd = Currency::new("USD", 16, 840, "US dollar", CurrencyType::Fiat);
3102        let mut fill = OrderFilledSpec::builder()
3103            .instrument_id(instrument.id())
3104            .last_px(Price::from("1.5000000000000000"))
3105            .last_qty(Quantity::from("2.0000000000000000"))
3106            .position_id(PositionId::from("P-1"))
3107            .build();
3108        fill.commission = Some(Money::from_decimal(dec!(0.25), usd).unwrap());
3109
3110        let position = Position::new_checked(&instrument, fill).unwrap();
3111
3112        assert_eq!(position.avg_px_open, 1.5);
3113        assert_eq!(position.signed_qty, 2.0);
3114        assert_eq!(position.quantity, Quantity::from("2.0000000000000000"));
3115        assert_eq!(position.realized_pnl, Some(Money::from("-0.25 USD")));
3116    }
3117
3118    #[cfg(feature = "defi")]
3119    #[rstest]
3120    fn test_new_checked_accepts_unconverted_commission_above_float_precision(
3121        audusd_sim: CurrencyPair,
3122    ) {
3123        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3124        let wei = Currency::new("WEI", 18, 0, "Wei", CurrencyType::Crypto);
3125        let commission = Money::from_decimal(dec!(0.000000000000000001), wei).unwrap();
3126        let mut fill = OrderFilledSpec::builder()
3127            .instrument_id(instrument.id())
3128            .position_id(PositionId::from("P-1"))
3129            .build();
3130        fill.commission = Some(commission);
3131
3132        let position = Position::new_checked(&instrument, fill).unwrap();
3133
3134        assert_eq!(position.commissions(), vec![commission]);
3135        assert_eq!(position.realized_pnl, Some(Money::from("0 USD")));
3136    }
3137
3138    #[cfg(feature = "defi")]
3139    #[rstest]
3140    fn test_try_apply_rejects_fill_above_float_precision_without_mutation(
3141        audusd_sim: CurrencyPair,
3142    ) {
3143        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3144        let position_id = PositionId::from("P-1");
3145        let fill_open = OrderFilledSpec::builder()
3146            .instrument_id(instrument.id())
3147            .trade_id(TradeId::from("T-1"))
3148            .position_id(position_id)
3149            .build();
3150        let fill_invalid = OrderFilledSpec::builder()
3151            .instrument_id(instrument.id())
3152            .trade_id(TradeId::from("T-2"))
3153            .last_px(Price::from("1.000000000000000001"))
3154            .position_id(position_id)
3155            .build();
3156        let mut position = Position::new(&instrument, fill_open);
3157        let state_before = serde_json::to_value(&position).unwrap();
3158
3159        let error = position.try_apply(&fill_invalid).unwrap_err();
3160
3161        assert_eq!(
3162            error.to_string(),
3163            "Fixed-point precision 18 exceeds maximum float precision 16"
3164        );
3165        assert_eq!(serde_json::to_value(&position).unwrap(), state_before);
3166    }
3167
3168    #[cfg(feature = "defi")]
3169    #[rstest]
3170    #[case::unchanged(|_: &mut Position| {}, None)]
3171    #[case::buy_qty(
3172        |position: &mut Position| position.buy_qty = Quantity::from("1.000000000000000001"),
3173        Some(18)
3174    )]
3175    #[case::sell_qty(
3176        |position: &mut Position| position.sell_qty = Quantity::from("1.000000000000000001"),
3177        Some(18)
3178    )]
3179    #[case::event_fill(
3180        |position: &mut Position| position.events[0].last_px = Price::from("1.000000000000000001"),
3181        Some(18)
3182    )]
3183    #[case::replay_fill(
3184        |position: &mut Position| {
3185            let mut fill = position.events[0].clone();
3186            fill.last_qty = Quantity::from("1.00000000000000001");
3187            position.replay_events.push(PositionReplayEvent::Filled(fill));
3188        },
3189        Some(17)
3190    )]
3191    #[case::replay_close(
3192        |position: &mut Position| {
3193            let close = InstrumentClose::new(
3194                position.instrument_id,
3195                Price::from("1.000000000000000001"),
3196                InstrumentCloseType::ContractExpired,
3197                UnixNanos::default(),
3198                UnixNanos::default(),
3199            );
3200            position.replay_events.push(PositionReplayEvent::InstrumentClosed(close));
3201        },
3202        Some(18)
3203    )]
3204    #[case::realized_pnl(
3205        |position: &mut Position| {
3206            let usd = Currency::new("USD", 18, 840, "US dollar", CurrencyType::Fiat);
3207            position.realized_pnl = Some(Money::from_decimal(dec!(1), usd).unwrap());
3208        },
3209        Some(18)
3210    )]
3211    fn test_check_state_float_precision_covers_stored_values(
3212        #[case] mutate: fn(&mut Position),
3213        #[case] expected_error_precision: Option<u8>,
3214        mut stub_position_long: Position,
3215    ) {
3216        mutate(&mut stub_position_long);
3217
3218        let result = stub_position_long.check_state_float_precision();
3219
3220        let expected = expected_error_precision.map_or(Ok(()), |precision| {
3221            Err(CorrectnessError::PredicateViolation {
3222                message: format!(
3223                    "Fixed-point precision {precision} exceeds maximum float precision 16"
3224                ),
3225            })
3226        });
3227
3228        assert_eq!(result, expected);
3229    }
3230
3231    #[cfg(feature = "defi")]
3232    #[rstest]
3233    fn test_try_pnl_rejects_values_above_float_precision(stub_position_long: Position) {
3234        let last = Price::from("1.000000000000000001");
3235        let quantity = Quantity::from("1.000000000000000001");
3236        let expected = "Fixed-point precision 18 exceeds maximum float precision 16";
3237
3238        let unrealized = stub_position_long.try_unrealized_pnl(last).unwrap_err();
3239        let total = stub_position_long.try_total_pnl(last).unwrap_err();
3240        let calculated = stub_position_long
3241            .try_calculate_pnl(1.0, 1.0, quantity)
3242            .unwrap_err();
3243
3244        assert_eq!(unrealized.to_string(), expected);
3245        assert_eq!(total.to_string(), expected);
3246        assert_eq!(calculated.to_string(), expected);
3247    }
3248
3249    #[rstest]
3250    #[should_panic(expected = "`fill.trade_id` already contained in `trade_ids`")]
3251    fn test_two_trades_with_same_trade_id_error(audusd_sim: CurrencyPair) {
3252        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3253        let order1 = OrderTestBuilder::new(OrderType::Market)
3254            .client_order_id(ClientOrderId::from("O-1"))
3255            .instrument_id(audusd_sim.id())
3256            .side(OrderSide::Buy)
3257            .quantity(Quantity::from(100_000))
3258            .build();
3259        let order2 = OrderTestBuilder::new(OrderType::Market)
3260            .client_order_id(ClientOrderId::from("O-2"))
3261            .instrument_id(audusd_sim.id())
3262            .side(OrderSide::Buy)
3263            .quantity(Quantity::from(100_000))
3264            .build();
3265        let fill1 = TestOrderEventStubs::filled(
3266            &order1,
3267            &audusd_sim,
3268            Some(TradeId::new("1")),
3269            None,
3270            Some(Price::from("1.00001")),
3271            None,
3272            None,
3273            None,
3274            None,
3275            None,
3276        );
3277        let fill2 = TestOrderEventStubs::filled(
3278            &order2,
3279            &audusd_sim,
3280            Some(TradeId::new("1")),
3281            None,
3282            Some(Price::from("1.00002")),
3283            None,
3284            None,
3285            None,
3286            None,
3287            None,
3288        );
3289        let mut position = Position::new(&audusd_sim, fill1.into());
3290        position.apply(&fill2.into());
3291    }
3292
3293    #[rstest]
3294    #[case(false)]
3295    #[case(true)]
3296    fn test_historical_duplicate_trade_id_does_not_poison_fill_void_replay(
3297        #[case] causal_duplicate: bool,
3298        audusd_sim: CurrencyPair,
3299    ) {
3300        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3301        let position_id = PositionId::from("P-DUP");
3302        let fill_open = OrderFilledSpec::builder()
3303            .instrument_id(instrument.id())
3304            .client_order_id(ClientOrderId::from("O-1"))
3305            .trade_id(TradeId::from("T-1"))
3306            .order_side(OrderSide::Buy)
3307            .last_qty(Quantity::from(10))
3308            .last_px(Price::from("1.00000"))
3309            .currency(Currency::USD())
3310            .position_id(position_id)
3311            .ts_event(UnixNanos::from(1))
3312            .build();
3313        let fill_close = OrderFilledSpec::builder()
3314            .instrument_id(instrument.id())
3315            .client_order_id(ClientOrderId::from("O-2"))
3316            .trade_id(TradeId::from("T-2"))
3317            .order_side(OrderSide::Sell)
3318            .last_qty(Quantity::from(10))
3319            .last_px(Price::from("1.00010"))
3320            .currency(Currency::USD())
3321            .position_id(position_id)
3322            .ts_event(UnixNanos::from(2))
3323            .build();
3324        let mut fill_duplicate = OrderFilledSpec::builder()
3325            .instrument_id(instrument.id())
3326            .client_order_id(ClientOrderId::from("O-1"))
3327            .trade_id(TradeId::from("T-1"))
3328            .order_side(OrderSide::Buy)
3329            .last_qty(Quantity::from(10))
3330            .last_px(Price::from("1.00020"))
3331            .currency(Currency::USD())
3332            .position_id(position_id)
3333            .ts_event(UnixNanos::from(3))
3334            .build();
3335
3336        if causal_duplicate {
3337            fill_duplicate.causation_id = Some(fill_open.event_id);
3338        }
3339        let fill_reopen = OrderFilledSpec::builder()
3340            .instrument_id(instrument.id())
3341            .client_order_id(ClientOrderId::from("O-3"))
3342            .trade_id(TradeId::from("T-3"))
3343            .order_side(OrderSide::Buy)
3344            .last_qty(Quantity::from(5))
3345            .last_px(Price::from("1.00000"))
3346            .currency(Currency::USD())
3347            .position_id(position_id)
3348            .ts_event(UnixNanos::from(4))
3349            .build();
3350        let mut fill_duplicate_open = fill_duplicate.clone();
3351        fill_duplicate_open.event_id = uuid4();
3352        fill_duplicate_open.client_order_id = ClientOrderId::from("O-4");
3353        fill_duplicate_open.ts_event = UnixNanos::from(5);
3354        let fill_voided = matching_fill_void(&fill_close, Quantity::from(10), None);
3355        let mut position = Position::new(&instrument, fill_open.clone());
3356        position.try_apply(&fill_close).unwrap();
3357
3358        position.try_apply(&fill_duplicate).unwrap();
3359
3360        assert_eq!(position.side, PositionSide::Flat);
3361        assert_eq!(position.quantity, Quantity::from(0));
3362        assert_eq!(position.events, vec![fill_open.clone(), fill_close.clone()]);
3363        assert_eq!(position.replay_events.len(), 2);
3364        assert_eq!(position.trade_ids.len(), 2);
3365        assert!(position.trade_ids.contains(&TradeId::from("T-1")));
3366        assert!(position.trade_ids.contains(&TradeId::from("T-2")));
3367
3368        position.try_apply(&fill_reopen).unwrap();
3369        position.try_apply(&fill_duplicate_open).unwrap();
3370
3371        assert_eq!(position.side, PositionSide::Long);
3372        assert_eq!(position.quantity, Quantity::from(5));
3373        assert_eq!(position.opening_order_id, ClientOrderId::from("O-3"));
3374        assert_eq!(position.events, vec![fill_reopen.clone()]);
3375        assert_eq!(position.replay_events.len(), 3);
3376        assert_eq!(position.trade_ids.len(), 1);
3377        assert!(position.trade_ids.contains(&TradeId::from("T-3")));
3378
3379        position
3380            .apply_fill_void(fill_voided, Quantity::from(10), None)
3381            .unwrap();
3382
3383        assert_eq!(position.side, PositionSide::Long);
3384        assert_eq!(position.quantity, Quantity::from(15));
3385        assert_eq!(position.opening_order_id, ClientOrderId::from("O-1"));
3386        assert_eq!(position.closing_order_id, None);
3387        assert_eq!(position.avg_px_open, 1.0);
3388        assert_eq!(position.buy_qty, Quantity::from(15));
3389        assert_eq!(position.sell_qty, Quantity::from(0));
3390        assert_eq!(
3391            position.events,
3392            vec![fill_open.clone(), fill_reopen.clone()]
3393        );
3394        assert_eq!(position.replay_events.len(), 3);
3395        assert_eq!(position.fill_voids.len(), 1);
3396        assert_eq!(position.trade_ids.len(), 2);
3397        assert!(position.trade_ids.contains(&TradeId::from("T-1")));
3398        assert!(position.trade_ids.contains(&TradeId::from("T-3")));
3399
3400        let mut fill_close_duplicate = fill_close;
3401        fill_close_duplicate.event_id = uuid4();
3402        fill_close_duplicate.ts_event = UnixNanos::from(6);
3403        position.try_apply(&fill_close_duplicate).unwrap();
3404
3405        assert_eq!(position.side, PositionSide::Long);
3406        assert_eq!(position.quantity, Quantity::from(15));
3407        assert_eq!(position.events, vec![fill_open, fill_reopen]);
3408        assert_eq!(position.replay_events.len(), 3);
3409    }
3410
3411    #[rstest]
3412    fn test_replay_fill_with_foreign_causation_stays_duplicate(audusd_sim: CurrencyPair) {
3413        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
3414        let position_id = PositionId::from("P-CAUSATION");
3415        let fill_open = OrderFilledSpec::builder()
3416            .instrument_id(instrument.id())
3417            .client_order_id(ClientOrderId::from("O-1"))
3418            .trade_id(TradeId::from("T-1"))
3419            .order_side(OrderSide::Buy)
3420            .last_qty(Quantity::from(10))
3421            .last_px(Price::from("1.00000"))
3422            .currency(Currency::USD())
3423            .position_id(position_id)
3424            .ts_event(UnixNanos::from(1))
3425            .build();
3426        let fill_close = OrderFilledSpec::builder()
3427            .instrument_id(instrument.id())
3428            .client_order_id(ClientOrderId::from("O-2"))
3429            .trade_id(TradeId::from("T-2"))
3430            .order_side(OrderSide::Sell)
3431            .last_qty(Quantity::from(10))
3432            .last_px(Price::from("1.00000"))
3433            .currency(Currency::USD())
3434            .position_id(position_id)
3435            .ts_event(UnixNanos::from(2))
3436            .build();
3437        let mut position = Position::new(&instrument, fill_open.clone());
3438        position.apply(&fill_close);
3439
3440        let mut replayed = fill_open.clone();
3441        replayed.event_id = uuid4();
3442        replayed.ts_event = UnixNanos::from(3);
3443        replayed.causation_id = Some(fill_close.event_id);
3444
3445        position.apply(&replayed);
3446
3447        assert_eq!(position.side, PositionSide::Flat);
3448        assert_eq!(position.quantity, Quantity::from(0));
3449        assert_eq!(position.events, vec![fill_open, fill_close.clone()]);
3450    }
3451
3452    #[rstest]
3453    fn test_position_applies_fills_with_negative_prices(audusd_sim: CurrencyPair) {
3454        // Options and spreads can trade at negative prices; position average
3455        // price updates must not panic when the stored average or incoming
3456        // fill price is below zero.
3457        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3458        let order = OrderTestBuilder::new(OrderType::Market)
3459            .instrument_id(audusd_sim.id())
3460            .side(OrderSide::Buy)
3461            .quantity(Quantity::from(100_000))
3462            .build();
3463        let fill1 = TestOrderEventStubs::filled(
3464            &order,
3465            &audusd_sim,
3466            Some(TradeId::new("1")),
3467            None,
3468            Some(Price::from("-5.00000")),
3469            Some(Quantity::from(50_000)),
3470            None,
3471            None,
3472            None,
3473            None,
3474        );
3475        let fill2 = TestOrderEventStubs::filled(
3476            &order,
3477            &audusd_sim,
3478            Some(TradeId::new("2")),
3479            None,
3480            Some(Price::from("-7.00000")),
3481            Some(Quantity::from(50_000)),
3482            None,
3483            None,
3484            None,
3485            None,
3486        );
3487        let mut position = Position::new(&audusd_sim, fill1.into());
3488        position.apply(&fill2.into());
3489
3490        assert_eq!(position.quantity, Quantity::from(100_000));
3491        assert_eq!(position.signed_qty, 100_000.0);
3492        assert_eq!(position.side, PositionSide::Long);
3493        // Weighted avg_px_open: (50_000 * -5 + 50_000 * -7) / 100_000 = -6.0
3494        assert_eq!(position.avg_px_open, -6.0);
3495    }
3496
3497    #[rstest]
3498    fn test_position_filled_with_buy_order(audusd_sim: CurrencyPair) {
3499        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3500        let order = OrderTestBuilder::new(OrderType::Market)
3501            .instrument_id(audusd_sim.id())
3502            .side(OrderSide::Buy)
3503            .quantity(Quantity::from(100_000))
3504            .build();
3505        let fill = TestOrderEventStubs::filled(
3506            &order,
3507            &audusd_sim,
3508            None,
3509            None,
3510            Some(Price::from("1.00001")),
3511            None,
3512            None,
3513            None,
3514            None,
3515            None,
3516        );
3517        let last_price = Price::from_str("1.0005").unwrap();
3518        let position = Position::new(&audusd_sim, fill.into());
3519        assert_eq!(position.symbol(), audusd_sim.id().symbol);
3520        assert_eq!(position.venue(), audusd_sim.id().venue);
3521        assert_eq!(position.closing_order_side(), Some(OrderSide::Sell));
3522        assert!(!position.is_opposite_side(OrderSide::Buy));
3523        assert_eq!(position, position); // equality operator test
3524        assert!(position.closing_order_id.is_none());
3525        assert_eq!(position.quantity, Quantity::from(100_000));
3526        assert_eq!(position.peak_qty, Quantity::from(100_000));
3527        assert_eq!(position.size_precision, 0);
3528        assert_eq!(position.signed_qty, 100_000.0);
3529        assert_eq!(position.entry, OrderSide::Buy);
3530        assert_eq!(position.side, PositionSide::Long);
3531        assert_eq!(position.ts_opened.as_u64(), 0);
3532        assert_eq!(position.duration_ns, DurationNanos::default());
3533        assert_eq!(position.avg_px_open, 1.00001);
3534        assert_eq!(position.event_count(), 1);
3535        assert_eq!(position.id, PositionId::new("1"));
3536        assert_eq!(position.events.len(), 1);
3537        assert!(position.is_long());
3538        assert!(!position.is_short());
3539        assert!(position.is_open());
3540        assert!(!position.is_closed());
3541        assert_eq!(position.realized_return, 0.0);
3542        assert_eq!(position.realized_pnl, Some(Money::from("-2.0 USD")));
3543        assert_eq!(position.unrealized_pnl(last_price), Money::from("49.0 USD"));
3544        assert_eq!(position.total_pnl(last_price), Money::from("47.0 USD"));
3545        assert_eq!(position.commissions(), vec![Money::from("2.0 USD")]);
3546        assert_eq!(
3547            format!("{position}"),
3548            "Position(LONG 100_000 AUD/USD.SIM, id=1)"
3549        );
3550    }
3551
3552    #[rstest]
3553    fn test_position_filled_with_sell_order(audusd_sim: CurrencyPair) {
3554        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3555        let order = OrderTestBuilder::new(OrderType::Market)
3556            .instrument_id(audusd_sim.id())
3557            .side(OrderSide::Sell)
3558            .quantity(Quantity::from(100_000))
3559            .build();
3560        let fill = TestOrderEventStubs::filled(
3561            &order,
3562            &audusd_sim,
3563            None,
3564            None,
3565            Some(Price::from("1.00001")),
3566            None,
3567            None,
3568            None,
3569            None,
3570            None,
3571        );
3572        let last_price = Price::from_str("1.00050").unwrap();
3573        let position = Position::new(&audusd_sim, fill.into());
3574        assert_eq!(position.symbol(), audusd_sim.id().symbol);
3575        assert_eq!(position.venue(), audusd_sim.id().venue);
3576        assert_eq!(position.closing_order_side(), Some(OrderSide::Buy));
3577        assert!(!position.is_opposite_side(OrderSide::Sell));
3578        assert_eq!(position, position); // Equality operator test
3579        assert!(position.closing_order_id.is_none());
3580        assert_eq!(position.quantity, Quantity::from(100_000));
3581        assert_eq!(position.peak_qty, Quantity::from(100_000));
3582        assert_eq!(position.signed_qty, -100_000.0);
3583        assert_eq!(position.entry, OrderSide::Sell);
3584        assert_eq!(position.side, PositionSide::Short);
3585        assert_eq!(position.ts_opened.as_u64(), 0);
3586        assert_eq!(position.avg_px_open, 1.00001);
3587        assert_eq!(position.event_count(), 1);
3588        assert_eq!(position.id, PositionId::new("1"));
3589        assert_eq!(position.events.len(), 1);
3590        assert!(!position.is_long());
3591        assert!(position.is_short());
3592        assert!(position.is_open());
3593        assert!(!position.is_closed());
3594        assert_eq!(position.realized_return, 0.0);
3595        assert_eq!(position.realized_pnl, Some(Money::from("-2.0 USD")));
3596        assert_eq!(
3597            position.unrealized_pnl(last_price),
3598            Money::from("-49.0 USD")
3599        );
3600        assert_eq!(position.total_pnl(last_price), Money::from("-51.0 USD"));
3601        assert_eq!(position.commissions(), vec![Money::from("2.0 USD")]);
3602        assert_eq!(
3603            format!("{position}"),
3604            "Position(SHORT 100_000 AUD/USD.SIM, id=1)"
3605        );
3606    }
3607
3608    #[rstest]
3609    fn test_position_partial_fills_with_buy_order(audusd_sim: CurrencyPair) {
3610        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3611        let order = OrderTestBuilder::new(OrderType::Market)
3612            .instrument_id(audusd_sim.id())
3613            .side(OrderSide::Buy)
3614            .quantity(Quantity::from(100_000))
3615            .build();
3616        let fill = TestOrderEventStubs::filled(
3617            &order,
3618            &audusd_sim,
3619            None,
3620            None,
3621            Some(Price::from("1.00001")),
3622            Some(Quantity::from(50_000)),
3623            None,
3624            None,
3625            None,
3626            None,
3627        );
3628        let last_price = Price::from_str("1.00048").unwrap();
3629        let position = Position::new(&audusd_sim, fill.into());
3630        assert_eq!(position.quantity, Quantity::from(50_000));
3631        assert_eq!(position.peak_qty, Quantity::from(50_000));
3632        assert_eq!(position.side, PositionSide::Long);
3633        assert_eq!(position.signed_qty, 50000.0);
3634        assert_eq!(position.avg_px_open, 1.00001);
3635        assert_eq!(position.event_count(), 1);
3636        assert_eq!(position.ts_opened.as_u64(), 0);
3637        assert!(position.is_long());
3638        assert!(!position.is_short());
3639        assert!(position.is_open());
3640        assert!(!position.is_closed());
3641        assert_eq!(position.realized_return, 0.0);
3642        assert_eq!(position.realized_pnl, Some(Money::from("-2.0 USD")));
3643        assert_eq!(position.unrealized_pnl(last_price), Money::from("23.5 USD"));
3644        assert_eq!(position.total_pnl(last_price), Money::from("21.5 USD"));
3645        assert_eq!(position.commissions(), vec![Money::from("2.0 USD")]);
3646        assert_eq!(
3647            format!("{position}"),
3648            "Position(LONG 50_000 AUD/USD.SIM, id=1)"
3649        );
3650    }
3651
3652    #[rstest]
3653    fn test_position_partial_fills_with_two_sell_orders(audusd_sim: CurrencyPair) {
3654        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3655        let order = OrderTestBuilder::new(OrderType::Market)
3656            .instrument_id(audusd_sim.id())
3657            .side(OrderSide::Sell)
3658            .quantity(Quantity::from(100_000))
3659            .build();
3660        let fill1 = TestOrderEventStubs::filled(
3661            &order,
3662            &audusd_sim,
3663            Some(TradeId::new("1")),
3664            None,
3665            Some(Price::from("1.00001")),
3666            Some(Quantity::from(50_000)),
3667            None,
3668            None,
3669            None,
3670            None,
3671        );
3672        let fill2 = TestOrderEventStubs::filled(
3673            &order,
3674            &audusd_sim,
3675            Some(TradeId::new("2")),
3676            None,
3677            Some(Price::from("1.00002")),
3678            Some(Quantity::from(50_000)),
3679            None,
3680            None,
3681            None,
3682            None,
3683        );
3684        let last_price = Price::from_str("1.0005").unwrap();
3685        let mut position = Position::new(&audusd_sim, fill1.into());
3686        position.apply(&fill2.into());
3687
3688        assert_eq!(position.quantity, Quantity::from(100_000));
3689        assert_eq!(position.peak_qty, Quantity::from(100_000));
3690        assert_eq!(position.side, PositionSide::Short);
3691        assert_eq!(position.signed_qty, -100_000.0);
3692        assert_eq!(position.avg_px_open, 1.000_015);
3693        assert_eq!(position.event_count(), 2);
3694        assert_eq!(position.ts_opened, 0);
3695        assert!(position.is_short());
3696        assert!(!position.is_long());
3697        assert!(position.is_open());
3698        assert!(!position.is_closed());
3699        assert_eq!(position.realized_return, 0.0);
3700        assert_eq!(position.realized_pnl, Some(Money::from("-4.0 USD")));
3701        assert_eq!(
3702            position.unrealized_pnl(last_price),
3703            Money::from("-48.5 USD")
3704        );
3705        assert_eq!(position.total_pnl(last_price), Money::from("-52.5 USD"));
3706        assert_eq!(position.commissions(), vec![Money::from("4.0 USD")]);
3707    }
3708
3709    #[rstest]
3710    pub fn test_position_filled_with_buy_order_then_sell_order(audusd_sim: CurrencyPair) {
3711        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3712        let order = OrderTestBuilder::new(OrderType::Market)
3713            .instrument_id(audusd_sim.id())
3714            .side(OrderSide::Buy)
3715            .quantity(Quantity::from(150_000))
3716            .build();
3717        let fill = TestOrderEventStubs::filled(
3718            &order,
3719            &audusd_sim,
3720            Some(TradeId::new("1")),
3721            Some(PositionId::new("P-1")),
3722            Some(Price::from("1.00001")),
3723            None,
3724            None,
3725            None,
3726            Some(UnixNanos::from(1_000_000_000)),
3727            None,
3728        );
3729        let mut position = Position::new(&audusd_sim, fill.into());
3730
3731        let fill2 = OrderFilledSpec::builder()
3732            .trader_id(order.trader_id())
3733            .strategy_id(StrategyId::new("S-001"))
3734            .instrument_id(order.instrument_id())
3735            .client_order_id(order.client_order_id())
3736            .venue_order_id(VenueOrderId::from("2"))
3737            .account_id(order.account_id().unwrap_or(AccountId::new("SIM-001")))
3738            .trade_id(TradeId::new("2"))
3739            .order_side(OrderSide::Sell)
3740            .last_qty(order.quantity())
3741            .last_px(Price::from("1.00011"))
3742            .currency(audusd_sim.quote_currency())
3743            .ts_event(2_000_000_000.into())
3744            .position_id(PositionId::new("T1"))
3745            .commission(Money::from("0.0 USD"))
3746            .build();
3747        position.apply(&fill2);
3748        let last = Price::from_str("1.0005").unwrap();
3749
3750        assert!(position.is_opposite_side(fill2.order_side));
3751        assert_eq!(
3752            position.quantity,
3753            Quantity::zero(audusd_sim.price_precision())
3754        );
3755        assert_eq!(position.size_precision, 0);
3756        assert_eq!(position.signed_qty, 0.0);
3757        assert_eq!(position.side, PositionSide::Flat);
3758        assert_eq!(position.ts_opened, 1_000_000_000);
3759        assert_eq!(position.ts_closed, Some(UnixNanos::from(2_000_000_000)));
3760        assert_eq!(position.duration_ns, DurationNanos::from_secs(1));
3761        assert_eq!(position.avg_px_open, 1.00001);
3762        assert_eq!(position.avg_px_close, Some(1.00011));
3763        assert!(!position.is_long());
3764        assert!(!position.is_short());
3765        assert!(!position.is_open());
3766        assert!(position.is_closed());
3767        assert_eq!(position.realized_return, 9.999_900_000_998_888e-5);
3768        assert_eq!(position.realized_pnl, Some(Money::from("13.0 USD")));
3769        assert_eq!(position.unrealized_pnl(last), Money::from("0 USD"));
3770        assert_eq!(position.commissions(), vec![Money::from("2 USD")]);
3771        assert_eq!(position.total_pnl(last), Money::from("13 USD"));
3772        assert_eq!(format!("{position}"), "Position(FLAT AUD/USD.SIM, id=P-1)");
3773    }
3774
3775    #[rstest]
3776    pub fn test_position_filled_with_sell_order_then_buy_order(audusd_sim: CurrencyPair) {
3777        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3778        let order1 = OrderTestBuilder::new(OrderType::Market)
3779            .instrument_id(audusd_sim.id())
3780            .side(OrderSide::Sell)
3781            .quantity(Quantity::from(100_000))
3782            .build();
3783        let order2 = OrderTestBuilder::new(OrderType::Market)
3784            .instrument_id(audusd_sim.id())
3785            .side(OrderSide::Buy)
3786            .quantity(Quantity::from(100_000))
3787            .build();
3788        let fill1 = TestOrderEventStubs::filled(
3789            &order1,
3790            &audusd_sim,
3791            None,
3792            Some(PositionId::new("P-19700101-000000-001-001-1")),
3793            Some(Price::from("1.0")),
3794            None,
3795            None,
3796            None,
3797            None,
3798            None,
3799        );
3800        let mut position = Position::new(&audusd_sim, fill1.into());
3801        // create closing from order from different venue but same strategy
3802        let fill2 = TestOrderEventStubs::filled(
3803            &order2,
3804            &audusd_sim,
3805            Some(TradeId::new("1")),
3806            Some(PositionId::new("P-19700101-000000-001-001-1")),
3807            Some(Price::from("1.00001")),
3808            Some(Quantity::from(50_000)),
3809            None,
3810            None,
3811            None,
3812            None,
3813        );
3814        let fill3 = TestOrderEventStubs::filled(
3815            &order2,
3816            &audusd_sim,
3817            Some(TradeId::new("2")),
3818            Some(PositionId::new("P-19700101-000000-001-001-1")),
3819            Some(Price::from("1.00003")),
3820            Some(Quantity::from(50_000)),
3821            None,
3822            None,
3823            None,
3824            None,
3825        );
3826        let last = Price::from("1.0005");
3827        position.apply(&fill2.into());
3828        position.apply(&fill3.into());
3829
3830        assert_eq!(
3831            position.quantity,
3832            Quantity::zero(audusd_sim.price_precision())
3833        );
3834        assert_eq!(position.side, PositionSide::Flat);
3835        assert_eq!(position.ts_opened, 0);
3836        assert_eq!(position.avg_px_open, 1.0);
3837        assert_eq!(position.events.len(), 3);
3838        assert_eq!(position.ts_closed, Some(UnixNanos::default()));
3839        assert_eq!(position.avg_px_close, Some(1.00002));
3840        assert!(!position.is_long());
3841        assert!(!position.is_short());
3842        assert!(!position.is_open());
3843        assert!(position.is_closed());
3844        assert_eq!(position.commissions(), vec![Money::from("6.0 USD")]);
3845        assert_eq!(position.unrealized_pnl(last), Money::from("0 USD"));
3846        assert_eq!(position.realized_pnl, Some(Money::from("-8.0 USD")));
3847        assert_eq!(position.total_pnl(last), Money::from("-8.0 USD"));
3848        assert_eq!(
3849            format!("{position}"),
3850            "Position(FLAT AUD/USD.SIM, id=P-19700101-000000-001-001-1)"
3851        );
3852    }
3853
3854    #[rstest]
3855    fn test_position_filled_with_no_change(audusd_sim: CurrencyPair) {
3856        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3857        let order1 = OrderTestBuilder::new(OrderType::Market)
3858            .instrument_id(audusd_sim.id())
3859            .side(OrderSide::Buy)
3860            .quantity(Quantity::from(100_000))
3861            .build();
3862        let order2 = OrderTestBuilder::new(OrderType::Market)
3863            .instrument_id(audusd_sim.id())
3864            .side(OrderSide::Sell)
3865            .quantity(Quantity::from(100_000))
3866            .build();
3867        let fill1 = TestOrderEventStubs::filled(
3868            &order1,
3869            &audusd_sim,
3870            Some(TradeId::new("1")),
3871            Some(PositionId::new("P-19700101-000000-001-001-1")),
3872            Some(Price::from("1.0")),
3873            None,
3874            None,
3875            None,
3876            None,
3877            None,
3878        );
3879        let mut position = Position::new(&audusd_sim, fill1.into());
3880        let fill2 = TestOrderEventStubs::filled(
3881            &order2,
3882            &audusd_sim,
3883            Some(TradeId::new("2")),
3884            Some(PositionId::new("P-19700101-000000-001-001-1")),
3885            Some(Price::from("1.0")),
3886            None,
3887            None,
3888            None,
3889            None,
3890            None,
3891        );
3892        let last = Price::from("1.0005");
3893        position.apply(&fill2.into());
3894
3895        assert_eq!(
3896            position.quantity,
3897            Quantity::zero(audusd_sim.price_precision())
3898        );
3899        assert_eq!(position.closing_order_side(), None);
3900        assert_eq!(position.side, PositionSide::Flat);
3901        assert_eq!(position.ts_opened, 0);
3902        assert_eq!(position.avg_px_open, 1.0);
3903        assert_eq!(position.events.len(), 2);
3904        // assert_eq!(position.trade_ids, vec![fill1.trade_id, fill2.trade_id]);  // TODO
3905        assert_eq!(position.ts_closed, Some(UnixNanos::default()));
3906        assert_eq!(position.avg_px_close, Some(1.0));
3907        assert!(!position.is_long());
3908        assert!(!position.is_short());
3909        assert!(!position.is_open());
3910        assert!(position.is_closed());
3911        assert_eq!(position.commissions(), vec![Money::from("4.0 USD")]);
3912        assert_eq!(position.unrealized_pnl(last), Money::from("0 USD"));
3913        assert_eq!(position.realized_pnl, Some(Money::from("-4.0 USD")));
3914        assert_eq!(position.total_pnl(last), Money::from("-4.0 USD"));
3915        assert_eq!(
3916            format!("{position}"),
3917            "Position(FLAT AUD/USD.SIM, id=P-19700101-000000-001-001-1)"
3918        );
3919    }
3920
3921    #[rstest]
3922    fn test_position_long_with_multiple_filled_orders(audusd_sim: CurrencyPair) {
3923        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
3924        let order1 = OrderTestBuilder::new(OrderType::Market)
3925            .instrument_id(audusd_sim.id())
3926            .side(OrderSide::Buy)
3927            .quantity(Quantity::from(100_000))
3928            .build();
3929        let order2 = OrderTestBuilder::new(OrderType::Market)
3930            .instrument_id(audusd_sim.id())
3931            .side(OrderSide::Buy)
3932            .quantity(Quantity::from(100_000))
3933            .build();
3934        let order3 = OrderTestBuilder::new(OrderType::Market)
3935            .instrument_id(audusd_sim.id())
3936            .side(OrderSide::Sell)
3937            .quantity(Quantity::from(200_000))
3938            .build();
3939        let fill1 = TestOrderEventStubs::filled(
3940            &order1,
3941            &audusd_sim,
3942            Some(TradeId::new("1")),
3943            Some(PositionId::new("P-123456")),
3944            Some(Price::from("1.0")),
3945            None,
3946            None,
3947            None,
3948            None,
3949            None,
3950        );
3951        let fill2 = TestOrderEventStubs::filled(
3952            &order2,
3953            &audusd_sim,
3954            Some(TradeId::new("2")),
3955            Some(PositionId::new("P-123456")),
3956            Some(Price::from("1.00001")),
3957            None,
3958            None,
3959            None,
3960            None,
3961            None,
3962        );
3963        let fill3 = TestOrderEventStubs::filled(
3964            &order3,
3965            &audusd_sim,
3966            Some(TradeId::new("3")),
3967            Some(PositionId::new("P-123456")),
3968            Some(Price::from("1.0001")),
3969            None,
3970            None,
3971            None,
3972            None,
3973            None,
3974        );
3975        let mut position = Position::new(&audusd_sim, fill1.into());
3976        let last = Price::from("1.0005");
3977        position.apply(&fill2.into());
3978        position.apply(&fill3.into());
3979
3980        assert_eq!(
3981            position.quantity,
3982            Quantity::zero(audusd_sim.price_precision())
3983        );
3984        assert_eq!(position.side, PositionSide::Flat);
3985        assert_eq!(position.ts_opened, 0);
3986        assert_eq!(position.avg_px_open, 1.000_005);
3987        assert_eq!(position.events.len(), 3);
3988        // assert_eq!(
3989        //     position.trade_ids,
3990        //     vec![fill1.trade_id, fill2.trade_id, fill3.trade_id]
3991        // );
3992        assert_eq!(position.ts_closed, Some(UnixNanos::default()));
3993        assert_eq!(position.avg_px_close, Some(1.0001));
3994        assert!(position.is_closed());
3995        assert!(!position.is_open());
3996        assert!(!position.is_long());
3997        assert!(!position.is_short());
3998        assert_eq!(position.commissions(), vec![Money::from("6.0 USD")]);
3999        assert_eq!(position.realized_pnl, Some(Money::from("13.0 USD")));
4000        assert_eq!(position.unrealized_pnl(last), Money::from("0 USD"));
4001        assert_eq!(position.total_pnl(last), Money::from("13 USD"));
4002        assert_eq!(
4003            format!("{position}"),
4004            "Position(FLAT AUD/USD.SIM, id=P-123456)"
4005        );
4006    }
4007
4008    #[rstest]
4009    fn test_pnl_calculation_from_trading_technologies_example(currency_pair_ethusdt: CurrencyPair) {
4010        let ethusdt = InstrumentAny::CurrencyPair(currency_pair_ethusdt);
4011        let quantity1 = Quantity::from(12);
4012        let price1 = Price::from("100.0");
4013        let order1 = OrderTestBuilder::new(OrderType::Market)
4014            .instrument_id(ethusdt.id())
4015            .side(OrderSide::Buy)
4016            .quantity(quantity1)
4017            .build();
4018        let commission1 =
4019            calculate_commission(&ethusdt, order1.quantity(), price1, None, dec!(0.0001));
4020        let fill1 = TestOrderEventStubs::filled(
4021            &order1,
4022            &ethusdt,
4023            Some(TradeId::new("1")),
4024            Some(PositionId::new("P-123456")),
4025            Some(price1),
4026            None,
4027            None,
4028            Some(commission1),
4029            None,
4030            None,
4031        );
4032        let mut position = Position::new(&ethusdt, fill1.into());
4033        let quantity2 = Quantity::from(17);
4034        let order2 = OrderTestBuilder::new(OrderType::Market)
4035            .instrument_id(ethusdt.id())
4036            .side(OrderSide::Buy)
4037            .quantity(quantity2)
4038            .build();
4039        let price2 = Price::from("99.0");
4040        let commission2 =
4041            calculate_commission(&ethusdt, order2.quantity(), price2, None, dec!(0.0001));
4042        let fill2 = TestOrderEventStubs::filled(
4043            &order2,
4044            &ethusdt,
4045            Some(TradeId::new("2")),
4046            Some(PositionId::new("P-123456")),
4047            Some(price2),
4048            None,
4049            None,
4050            Some(commission2),
4051            None,
4052            None,
4053        );
4054        position.apply(&fill2.into());
4055        assert_eq!(position.quantity, Quantity::from(29));
4056        assert_eq!(position.realized_pnl, Some(Money::from("-0.28830000 USDT")));
4057        assert_eq!(position.avg_px_open, 99.413_793_103_448_27);
4058        let quantity3 = Quantity::from(9);
4059        let order3 = OrderTestBuilder::new(OrderType::Market)
4060            .instrument_id(ethusdt.id())
4061            .side(OrderSide::Sell)
4062            .quantity(quantity3)
4063            .build();
4064        let price3 = Price::from("101.0");
4065        let commission3 =
4066            calculate_commission(&ethusdt, order3.quantity(), price3, None, dec!(0.0001));
4067        let fill3 = TestOrderEventStubs::filled(
4068            &order3,
4069            &ethusdt,
4070            Some(TradeId::new("3")),
4071            Some(PositionId::new("P-123456")),
4072            Some(price3),
4073            None,
4074            None,
4075            Some(commission3),
4076            None,
4077            None,
4078        );
4079        position.apply(&fill3.into());
4080        assert_eq!(position.quantity, Quantity::from(20));
4081        assert_eq!(position.realized_pnl, Some(Money::from("13.89666207 USDT")));
4082        assert_eq!(position.avg_px_open, 99.413_793_103_448_27);
4083        let quantity4 = Quantity::from("4");
4084        let price4 = Price::from("105.0");
4085        let order4 = OrderTestBuilder::new(OrderType::Market)
4086            .instrument_id(ethusdt.id())
4087            .side(OrderSide::Sell)
4088            .quantity(quantity4)
4089            .build();
4090        let commission4 =
4091            calculate_commission(&ethusdt, order4.quantity(), price4, None, dec!(0.0001));
4092        let fill4 = TestOrderEventStubs::filled(
4093            &order4,
4094            &ethusdt,
4095            Some(TradeId::new("4")),
4096            Some(PositionId::new("P-123456")),
4097            Some(price4),
4098            None,
4099            None,
4100            Some(commission4),
4101            None,
4102            None,
4103        );
4104        position.apply(&fill4.into());
4105        assert_eq!(position.quantity, Quantity::from("16"));
4106        assert_eq!(position.realized_pnl, Some(Money::from("36.19948966 USDT")));
4107        assert_eq!(position.avg_px_open, 99.413_793_103_448_27);
4108        let quantity5 = Quantity::from("3");
4109        let price5 = Price::from("103.0");
4110        let order5 = OrderTestBuilder::new(OrderType::Market)
4111            .instrument_id(ethusdt.id())
4112            .side(OrderSide::Buy)
4113            .quantity(quantity5)
4114            .build();
4115        let commission5 =
4116            calculate_commission(&ethusdt, order5.quantity(), price5, None, dec!(0.0001));
4117        let fill5 = TestOrderEventStubs::filled(
4118            &order5,
4119            &ethusdt,
4120            Some(TradeId::new("5")),
4121            Some(PositionId::new("P-123456")),
4122            Some(price5),
4123            None,
4124            None,
4125            Some(commission5),
4126            None,
4127            None,
4128        );
4129        position.apply(&fill5.into());
4130        assert_eq!(position.quantity, Quantity::from("19"));
4131        assert_eq!(position.realized_pnl, Some(Money::from("36.16858966 USDT")));
4132        assert_eq!(position.avg_px_open, 99.980_036_297_640_65);
4133        assert_eq!(
4134            format!("{position}"),
4135            "Position(LONG 19.00000 ETHUSDT.BINANCE, id=P-123456)"
4136        );
4137    }
4138
4139    #[rstest]
4140    fn test_position_closed_and_reopened(audusd_sim: CurrencyPair) {
4141        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
4142        let quantity1 = Quantity::from(150_000);
4143        let price1 = Price::from("1.00001");
4144        let order = OrderTestBuilder::new(OrderType::Market)
4145            .instrument_id(audusd_sim.id())
4146            .side(OrderSide::Buy)
4147            .quantity(quantity1)
4148            .build();
4149        let commission1 = calculate_commission(&audusd_sim, quantity1, price1, None, dec!(0.00002));
4150        let fill1 = TestOrderEventStubs::filled(
4151            &order,
4152            &audusd_sim,
4153            Some(TradeId::new("5")),
4154            Some(PositionId::new("P-123456")),
4155            Some(Price::from("1.00001")),
4156            None,
4157            None,
4158            Some(commission1),
4159            Some(UnixNanos::from(1_000_000_000)),
4160            None,
4161        );
4162        let mut position = Position::new(&audusd_sim, fill1.into());
4163
4164        let fill2 = OrderFilledSpec::builder()
4165            .trader_id(order.trader_id())
4166            .strategy_id(order.strategy_id())
4167            .instrument_id(order.instrument_id())
4168            .client_order_id(order.client_order_id())
4169            .venue_order_id(VenueOrderId::from("2"))
4170            .account_id(order.account_id().unwrap_or(AccountId::new("SIM-001")))
4171            .trade_id(TradeId::from("2"))
4172            .order_side(OrderSide::Sell)
4173            .last_qty(order.quantity())
4174            .last_px(Price::from("1.00011"))
4175            .currency(audusd_sim.quote_currency())
4176            .ts_event(UnixNanos::from(2_000_000_000))
4177            .position_id(PositionId::from("P-123456"))
4178            .commission(Money::from("0 USD"))
4179            .build();
4180
4181        position.apply(&fill2);
4182
4183        let fill3 = OrderFilledSpec::builder()
4184            .trader_id(order.trader_id())
4185            .strategy_id(order.strategy_id())
4186            .instrument_id(order.instrument_id())
4187            .client_order_id(order.client_order_id())
4188            .venue_order_id(VenueOrderId::from("2"))
4189            .account_id(order.account_id().unwrap_or(AccountId::new("SIM-001")))
4190            .trade_id(TradeId::from("3"))
4191            .last_qty(order.quantity())
4192            .last_px(Price::from("1.00012"))
4193            .currency(audusd_sim.quote_currency())
4194            .ts_event(UnixNanos::from(3_000_000_000))
4195            .position_id(PositionId::from("P-123456"))
4196            .commission(Money::from("0 USD"))
4197            .build();
4198
4199        position.apply(&fill3);
4200
4201        let last = Price::from("1.0003");
4202        assert!(position.is_opposite_side(fill2.order_side));
4203        assert_eq!(position.quantity, Quantity::from(150_000));
4204        assert_eq!(position.peak_qty, Quantity::from(150_000));
4205        assert_eq!(position.side, PositionSide::Long);
4206        assert_eq!(position.opening_order_id, fill3.client_order_id);
4207        assert_eq!(position.closing_order_id, None);
4208        assert_eq!(position.ts_opened, 3_000_000_000);
4209        assert_eq!(position.duration_ns, DurationNanos::default());
4210        assert_eq!(position.avg_px_open, 1.00012);
4211        assert_eq!(position.event_count(), 1);
4212        assert_eq!(position.ts_closed, None);
4213        assert_eq!(position.avg_px_close, None);
4214        assert!(position.is_long());
4215        assert!(!position.is_short());
4216        assert!(position.is_open());
4217        assert!(!position.is_closed());
4218        assert_eq!(position.realized_return, 0.0);
4219        assert_eq!(position.realized_pnl, Some(Money::from("0 USD")));
4220        assert_eq!(position.unrealized_pnl(last), Money::from("27 USD"));
4221        assert_eq!(position.total_pnl(last), Money::from("27 USD"));
4222        assert_eq!(position.commissions(), vec![Money::from("0 USD")]);
4223        assert_eq!(
4224            format!("{position}"),
4225            "Position(LONG 150_000 AUD/USD.SIM, id=P-123456)"
4226        );
4227    }
4228
4229    #[rstest]
4230    #[case::zero(Quantity::from(0))]
4231    #[case::exceeds_fragments(Quantity::from(11))]
4232    fn test_apply_fill_void_rejects_invalid_allocation_without_mutation(
4233        #[case] voided_qty: Quantity,
4234        audusd_sim: CurrencyPair,
4235    ) {
4236        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4237        let position_id = PositionId::from("P-VOID-INVALID");
4238        let fill = OrderFilledSpec::builder()
4239            .instrument_id(instrument.id())
4240            .client_order_id(ClientOrderId::from("O-VOID-INVALID"))
4241            .trade_id(TradeId::from("T-VOID-INVALID"))
4242            .order_side(OrderSide::Buy)
4243            .last_qty(Quantity::from(10))
4244            .last_px(Price::from("1.00000"))
4245            .currency(Currency::USD())
4246            .position_id(position_id)
4247            .build();
4248        let fill_voided = matching_fill_void(&fill, voided_qty, None);
4249        let mut position = Position::new(&instrument, fill);
4250        let state_before = serde_json::to_value(&position).unwrap();
4251
4252        let error = position
4253            .apply_fill_void(fill_voided, voided_qty, None)
4254            .unwrap_err();
4255
4256        assert_eq!(
4257            error.to_string(),
4258            "position fill void exceeds known fragments for T-VOID-INVALID"
4259        );
4260        assert_eq!(serde_json::to_value(&position).unwrap(), state_before);
4261    }
4262
4263    #[rstest]
4264    #[case::stale(
4265        Quantity::from(4),
4266        Money::from("0.40 USD"),
4267        "stale position fill void for T-VOID-CUMULATIVE"
4268    )]
4269    #[case::duplicate(
4270        Quantity::from(5),
4271        Money::from("0.50 USD"),
4272        "duplicate position fill void for T-VOID-CUMULATIVE"
4273    )]
4274    fn test_apply_fill_void_rejects_invalid_cumulative_update_without_mutation(
4275        #[case] voided_qty: Quantity,
4276        #[case] commission_voided: Money,
4277        #[case] expected_error: &str,
4278        audusd_sim: CurrencyPair,
4279    ) {
4280        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4281        let position_id = PositionId::from("P-VOID-CUMULATIVE");
4282        let fill = OrderFilledSpec::builder()
4283            .instrument_id(instrument.id())
4284            .client_order_id(ClientOrderId::from("O-VOID-CUMULATIVE"))
4285            .trade_id(TradeId::from("T-VOID-CUMULATIVE"))
4286            .order_side(OrderSide::Buy)
4287            .last_qty(Quantity::from(10))
4288            .last_px(Price::from("1.00000"))
4289            .currency(Currency::USD())
4290            .position_id(position_id)
4291            .commission(Money::from("1.00 USD"))
4292            .build();
4293        let fill_voided =
4294            matching_fill_void(&fill, Quantity::from(5), Some(Money::from("0.50 USD")));
4295        let mut position = Position::new(&instrument, fill);
4296        position
4297            .apply_fill_void(
4298                fill_voided.clone(),
4299                Quantity::from(5),
4300                Some(Money::from("0.50 USD")),
4301            )
4302            .unwrap();
4303        let state_before = serde_json::to_value(&position).unwrap();
4304
4305        let error = position
4306            .apply_fill_void(fill_voided, voided_qty, Some(commission_voided))
4307            .unwrap_err();
4308
4309        assert_eq!(error.to_string(), expected_error);
4310        assert_eq!(serde_json::to_value(&position).unwrap(), state_before);
4311    }
4312
4313    #[rstest]
4314    fn test_apply_fill_void_rejects_currency_mismatch_without_mutation(audusd_sim: CurrencyPair) {
4315        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4316        let fill = OrderFilledSpec::builder()
4317            .instrument_id(instrument.id())
4318            .client_order_id(ClientOrderId::from("O-VOID-CURRENCY"))
4319            .trade_id(TradeId::from("T-VOID-CURRENCY"))
4320            .order_side(OrderSide::Buy)
4321            .last_qty(Quantity::from(10))
4322            .last_px(Price::from("1.00000"))
4323            .currency(Currency::USD())
4324            .position_id(PositionId::from("P-VOID-CURRENCY"))
4325            .commission(Money::from("1.00 USD"))
4326            .build();
4327        let commission_voided = Some(Money::from("0.50 EUR"));
4328        let voided_qty = Quantity::from(5);
4329        let fill_voided = matching_fill_void(&fill, voided_qty, commission_voided);
4330
4331        let mut position = Position::new(&instrument, fill);
4332        let before = serde_json::to_value(&position).unwrap();
4333        let error = position
4334            .apply_fill_void(fill_voided, voided_qty, commission_voided)
4335            .unwrap_err();
4336        assert_eq!(
4337            error.to_string(),
4338            "position commission currency differs for fill T-VOID-CURRENCY"
4339        );
4340        assert_eq!(serde_json::to_value(&position).unwrap(), before);
4341    }
4342
4343    #[rstest]
4344    fn test_apply_fill_void_uses_latest_cumulative_commission(audusd_sim: CurrencyPair) {
4345        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4346        let position_id = PositionId::from("P-VOID-LATEST");
4347        let fill = OrderFilledSpec::builder()
4348            .instrument_id(instrument.id())
4349            .client_order_id(ClientOrderId::from("O-VOID-LATEST"))
4350            .trade_id(TradeId::from("T-VOID-LATEST"))
4351            .order_side(OrderSide::Buy)
4352            .last_qty(Quantity::from(10))
4353            .last_px(Price::from("1.00000"))
4354            .currency(Currency::USD())
4355            .position_id(position_id)
4356            .commission(Money::from("1.00 USD"))
4357            .build();
4358        let fill_voided =
4359            matching_fill_void(&fill, Quantity::from(4), Some(Money::from("0.40 USD")));
4360        let mut position = Position::new(&instrument, fill);
4361        position
4362            .apply_fill_void(
4363                fill_voided.clone(),
4364                Quantity::from(4),
4365                Some(Money::from("0.40 USD")),
4366            )
4367            .unwrap();
4368
4369        position
4370            .apply_fill_void(fill_voided, Quantity::from(7), None)
4371            .unwrap();
4372
4373        assert_eq!(position.side, PositionSide::Long);
4374        assert_eq!(position.quantity, Quantity::from(3));
4375        assert_eq!(position.commissions(), vec![Money::from("1.00 USD")]);
4376        assert_eq!(position.realized_pnl, Some(Money::from("-1.00 USD")));
4377        assert_eq!(position.fill_voids.len(), 2);
4378    }
4379
4380    #[rstest]
4381    fn test_fill_void_replays_across_position_close_and_reopen(audusd_sim: CurrencyPair) {
4382        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4383        let position_id = PositionId::from("P-VOID-REPLAY");
4384        let fill1 = OrderFilledSpec::builder()
4385            .instrument_id(instrument.id())
4386            .client_order_id(ClientOrderId::from("O-OPEN"))
4387            .trade_id(TradeId::from("T-OPEN"))
4388            .order_side(OrderSide::Buy)
4389            .last_qty(Quantity::from(10))
4390            .last_px(Price::from("1.00000"))
4391            .currency(Currency::USD())
4392            .position_id(position_id)
4393            .commission(Money::from("1.00 USD"))
4394            .ts_event(UnixNanos::from(1))
4395            .build();
4396        let fill2 = OrderFilledSpec::builder()
4397            .instrument_id(instrument.id())
4398            .client_order_id(ClientOrderId::from("O-CLOSE"))
4399            .trade_id(TradeId::from("T-CLOSE"))
4400            .order_side(OrderSide::Sell)
4401            .last_qty(Quantity::from(10))
4402            .last_px(Price::from("1.10000"))
4403            .currency(Currency::USD())
4404            .position_id(position_id)
4405            .commission(Money::from("1.00 USD"))
4406            .ts_event(UnixNanos::from(2))
4407            .build();
4408        let fill3 = OrderFilledSpec::builder()
4409            .instrument_id(instrument.id())
4410            .client_order_id(ClientOrderId::from("O-REOPEN"))
4411            .trade_id(TradeId::from("T-REOPEN"))
4412            .order_side(OrderSide::Buy)
4413            .last_qty(Quantity::from(5))
4414            .last_px(Price::from("1.20000"))
4415            .currency(Currency::USD())
4416            .position_id(position_id)
4417            .commission(Money::from("1.00 USD"))
4418            .ts_event(UnixNanos::from(3))
4419            .build();
4420        let fill_voided =
4421            matching_fill_void(&fill2, Quantity::from(5), Some(Money::from("0.50 USD")));
4422        let mut position = Position::new(&instrument, fill1);
4423        position.apply(&fill2);
4424        position.apply(&fill3);
4425
4426        position
4427            .apply_fill_void(
4428                fill_voided,
4429                Quantity::from(5),
4430                Some(Money::from("0.50 USD")),
4431            )
4432            .unwrap();
4433        let encoded = serde_json::to_string(&position).unwrap();
4434        let restored: Position = serde_json::from_str(&encoded).unwrap();
4435
4436        assert_eq!(position.side, PositionSide::Long);
4437        assert_eq!(position.quantity, Quantity::from(10));
4438        assert_eq!(position.opening_order_id, ClientOrderId::from("O-OPEN"));
4439        assert_eq!(position.buy_qty, Quantity::from(15));
4440        assert_eq!(position.sell_qty, Quantity::from(5));
4441        assert_eq!(position.commissions(), vec![Money::from("2.50 USD")]);
4442        assert_eq!(position.replay_events.len(), 3);
4443        assert_eq!(position.fill_voids.len(), 1);
4444        assert_eq!(restored.quantity, position.quantity);
4445        assert_eq!(restored.opening_order_id, position.opening_order_id);
4446        assert_eq!(restored.commissions(), position.commissions());
4447        assert_eq!(restored.replay_events.len(), position.replay_events.len());
4448        assert_eq!(restored.fill_voids.len(), position.fill_voids.len());
4449    }
4450
4451    #[rstest]
4452    fn test_fill_void_replays_manual_adjustment(audusd_sim: CurrencyPair) {
4453        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4454        let position_id = PositionId::from("P-VOID-ADJUSTMENT");
4455        let fill = OrderFilledSpec::builder()
4456            .instrument_id(instrument.id())
4457            .client_order_id(ClientOrderId::from("O-VOID-ADJUSTMENT"))
4458            .trade_id(TradeId::from("T-VOID-ADJUSTMENT"))
4459            .order_side(OrderSide::Buy)
4460            .last_qty(Quantity::from(10))
4461            .last_px(Price::from("1.00000"))
4462            .currency(Currency::USD())
4463            .position_id(position_id)
4464            .ts_event(UnixNanos::from(1))
4465            .build();
4466        let fill_voided = matching_fill_void(&fill, Quantity::from(2), None);
4467        let adjustment = PositionAdjusted::new(
4468            fill.trader_id,
4469            fill.strategy_id,
4470            fill.instrument_id,
4471            position_id,
4472            fill.account_id,
4473            PositionAdjustmentType::Funding,
4474            None,
4475            Some(Money::from("5.00 USD")),
4476            Some("funding".into()),
4477            uuid4(),
4478            UnixNanos::from(2),
4479            UnixNanos::from(2),
4480        );
4481        let mut position = Position::new(&instrument, fill);
4482        position.apply_adjustment(adjustment);
4483
4484        position
4485            .apply_fill_void(fill_voided, Quantity::from(2), None)
4486            .unwrap();
4487
4488        assert_eq!(position.side, PositionSide::Long);
4489        assert_eq!(position.quantity, Quantity::from(8));
4490        assert_eq!(position.realized_pnl, Some(Money::from("5.00 USD")));
4491        assert_eq!(position.adjustments, vec![adjustment]);
4492        assert_eq!(position.replay_events.len(), 2);
4493        assert_eq!(position.fill_voids.len(), 1);
4494        assert_eq!(position.ts_last, UnixNanos::from(2));
4495    }
4496
4497    fn binary_fill(
4498        instrument: &InstrumentAny,
4499        trade_id: &str,
4500        last_qty: &str,
4501        last_px: &str,
4502        ts_event: u64,
4503    ) -> OrderFilled {
4504        OrderFilledSpec::builder()
4505            .instrument_id(instrument.id())
4506            .client_order_id(ClientOrderId::from(format!("O-{trade_id}").as_str()))
4507            .venue_order_id(VenueOrderId::from(format!("V-{trade_id}").as_str()))
4508            .account_id(AccountId::from("POLYMARKET-001"))
4509            .trade_id(TradeId::from(trade_id))
4510            .order_side(OrderSide::Buy)
4511            .last_qty(Quantity::from(last_qty))
4512            .last_px(Price::from(last_px))
4513            .currency(Currency::USDC())
4514            .commission(Money::from("0.10 USDC"))
4515            .position_id(PositionId::from("P-BINARY"))
4516            .ts_event(UnixNanos::from(ts_event))
4517            .ts_init(UnixNanos::from(ts_event))
4518            .build()
4519    }
4520
4521    fn contract_expired(instrument: &InstrumentAny, close_price: &str) -> InstrumentClose {
4522        InstrumentClose::new(
4523            instrument.id(),
4524            Price::from(close_price),
4525            InstrumentCloseType::ContractExpired,
4526            UnixNanos::from(300),
4527            UnixNanos::from(301),
4528        )
4529    }
4530
4531    #[rstest]
4532    #[case::winner("1.000", dec!(5.90), 1.5)]
4533    #[case::loser("0.000", dec!(-4.10), -1.0)]
4534    fn test_apply_instrument_close_settles_without_fill(
4535        binary_option: BinaryOption,
4536        #[case] close_price: &str,
4537        #[case] expected_realized_pnl: Decimal,
4538        #[case] expected_return: f64,
4539    ) {
4540        let instrument = InstrumentAny::BinaryOption(binary_option);
4541        let fill = binary_fill(&instrument, "T-OPEN", "10.00", "0.400", 100);
4542        let mut position = Position::new(&instrument, fill.clone());
4543        let close = contract_expired(&instrument, close_price);
4544        let requires_replay_state_before = position.requires_replay_state();
4545
4546        position.apply_instrument_close(close).unwrap();
4547        let snapshot = PositionSnapshot::from_replay_state(&position, None);
4548        let restored: Position = serde_json::from_value(snapshot.replay_state.unwrap()).unwrap();
4549
4550        assert!(!requires_replay_state_before);
4551        assert!(position.requires_replay_state());
4552        assert!(position.is_closed());
4553        assert!(position.is_settled());
4554        assert_eq!(position.side, PositionSide::Flat);
4555        assert_eq!(position.signed_qty, 0.0);
4556        assert_eq!(position.quantity, Quantity::from("0.00"));
4557        assert_eq!(position.peak_qty, Quantity::from("10.00"));
4558        assert_eq!(position.buy_qty, Quantity::from("10.00"));
4559        assert_eq!(position.sell_qty, Quantity::from("0.00"));
4560        assert_eq!(position.opening_order_id, fill.client_order_id);
4561        assert_eq!(position.closing_order_id, None);
4562        assert_eq!(position.events, vec![fill]);
4563        assert_eq!(position.commissions(), vec![Money::from("0.10 USDC")]);
4564        assert_eq!(position.avg_px_open, 0.4);
4565        assert_eq!(
4566            position.avg_px_close,
4567            Some(Price::from(close_price).as_f64())
4568        );
4569        assert!((position.realized_return - expected_return).abs() < 1e-12);
4570        assert_eq!(
4571            position.realized_pnl.unwrap().as_decimal(),
4572            expected_realized_pnl
4573        );
4574        assert_eq!(position.ts_last, UnixNanos::from(300));
4575        assert_eq!(position.ts_closed, Some(UnixNanos::from(300)));
4576        assert_eq!(position.duration_ns, DurationNanos::new(200));
4577        assert!(matches!(
4578            position.replay_events.last(),
4579            Some(PositionReplayEvent::InstrumentClosed(event)) if *event == close
4580        ));
4581        assert!(restored.is_settled());
4582        assert!(restored.is_closed());
4583        assert_eq!(restored.realized_pnl, position.realized_pnl);
4584        assert_eq!(restored.avg_px_close, position.avg_px_close);
4585        assert_eq!(restored.ts_closed, position.ts_closed);
4586    }
4587
4588    #[rstest]
4589    fn test_apply_instrument_close_after_partial_close(binary_option: BinaryOption) {
4590        let instrument = InstrumentAny::BinaryOption(binary_option);
4591        let opening_fill = binary_fill(&instrument, "T-OPEN", "10.00", "0.400", 100);
4592        let mut reducing_fill = binary_fill(&instrument, "T-REDUCE", "4.00", "0.600", 200);
4593        reducing_fill.order_side = OrderSide::Sell;
4594        let mut position = Position::new(&instrument, opening_fill);
4595        position.apply(&reducing_fill);
4596
4597        position
4598            .apply_instrument_close(contract_expired(&instrument, "1.000"))
4599            .unwrap();
4600
4601        assert!(position.is_closed());
4602        assert_eq!(position.quantity, Quantity::from("0.00"));
4603        assert_eq!(position.buy_qty, Quantity::from("10.00"));
4604        assert_eq!(position.sell_qty, Quantity::from("4.00"));
4605        assert!((position.avg_px_close.unwrap() - 0.84).abs() < 1e-12);
4606        assert!((position.realized_return - 1.1).abs() < 1e-12);
4607        assert_eq!(position.realized_pnl.unwrap().as_decimal(), dec!(4.20));
4608        assert_eq!(position.closing_order_id, None);
4609    }
4610
4611    #[rstest]
4612    #[case::fills_only(false, false, false)]
4613    #[case::fill_voided(true, false, true)]
4614    #[case::settled(false, true, true)]
4615    fn test_requires_replay_state(
4616        binary_option: BinaryOption,
4617        #[case] void_fill: bool,
4618        #[case] settle: bool,
4619        #[case] expected: bool,
4620    ) {
4621        let instrument = InstrumentAny::BinaryOption(binary_option);
4622        let fill = binary_fill(&instrument, "T-OPEN", "10.00", "0.400", 100);
4623        let mut position = Position::new(&instrument, fill.clone());
4624
4625        if void_fill {
4626            position
4627                .apply_fill_void(
4628                    matching_fill_void(&fill, Quantity::from("2.00"), None),
4629                    Quantity::from("2.00"),
4630                    None,
4631                )
4632                .unwrap();
4633        }
4634
4635        if settle {
4636            position
4637                .apply_instrument_close(contract_expired(&instrument, "1.000"))
4638                .unwrap();
4639        }
4640
4641        assert_eq!(position.requires_replay_state(), expected);
4642    }
4643
4644    #[rstest]
4645    #[case::other_instrument(Some("OTHER.POLYMARKET"), InstrumentCloseType::ContractExpired, false)]
4646    #[case::end_of_session(None, InstrumentCloseType::EndOfSession, false)]
4647    #[case::not_open(None, InstrumentCloseType::ContractExpired, true)]
4648    fn test_apply_instrument_close_rejects_without_mutation(
4649        binary_option: BinaryOption,
4650        #[case] instrument_id: Option<&str>,
4651        #[case] close_type: InstrumentCloseType,
4652        #[case] settle_first: bool,
4653    ) {
4654        let instrument = InstrumentAny::BinaryOption(binary_option);
4655        let fill = binary_fill(&instrument, "T-OPEN", "10.00", "0.400", 100);
4656        let mut position = Position::new(&instrument, fill);
4657
4658        if settle_first {
4659            position
4660                .apply_instrument_close(contract_expired(&instrument, "1.000"))
4661                .unwrap();
4662        }
4663
4664        let before = serde_json::to_value(&position).unwrap();
4665
4666        let close = InstrumentClose::new(
4667            instrument_id.map_or_else(|| instrument.id(), InstrumentId::from),
4668            Price::from("1.000"),
4669            close_type,
4670            UnixNanos::from(400),
4671            UnixNanos::from(401),
4672        );
4673
4674        let result = position.apply_instrument_close(close);
4675
4676        assert!(result.is_err());
4677        assert_eq!(serde_json::to_value(&position).unwrap(), before);
4678    }
4679
4680    #[rstest]
4681    fn test_fill_void_replays_instrument_close(binary_option: BinaryOption) {
4682        let instrument = InstrumentAny::BinaryOption(binary_option);
4683        let opening_fill = binary_fill(&instrument, "T-OPEN", "10.00", "0.400", 100);
4684        let adding_fill = binary_fill(&instrument, "T-ADD", "5.00", "0.500", 200);
4685        let fill_voided = matching_fill_void(&adding_fill, Quantity::from("5.00"), None);
4686        let mut position = Position::new(&instrument, opening_fill);
4687        position.apply(&adding_fill);
4688        let close = contract_expired(&instrument, "1.000");
4689        position.apply_instrument_close(close).unwrap();
4690
4691        let closed_cycles_pnl = position
4692            .apply_fill_void(fill_voided, Quantity::from("5.00"), None)
4693            .unwrap();
4694
4695        assert_eq!(closed_cycles_pnl, None);
4696        assert!(position.is_closed());
4697        assert!(position.is_settled());
4698        assert_eq!(position.quantity, Quantity::from("0.00"));
4699        assert_eq!(position.peak_qty, Quantity::from("10.00"));
4700        assert_eq!(position.buy_qty, Quantity::from("10.00"));
4701        assert_eq!(position.realized_pnl.unwrap().as_decimal(), dec!(5.80));
4702        assert_eq!(position.ts_closed, Some(UnixNanos::from(300)));
4703        assert_eq!(position.fill_voids.len(), 1);
4704        assert_eq!(position.replay_events.len(), 3);
4705    }
4706
4707    #[rstest]
4708    fn test_fill_void_returns_all_closed_cycle_pnl(audusd_sim: CurrencyPair) {
4709        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4710        let position_id = PositionId::from("P-VOID-CYCLES");
4711        let fill = |client_order_id: &str,
4712                    trade_id: &str,
4713                    order_side: OrderSide,
4714                    quantity: u32,
4715                    price: &str,
4716                    ts_event: u64| {
4717            OrderFilledSpec::builder()
4718                .instrument_id(instrument.id())
4719                .client_order_id(ClientOrderId::from(client_order_id))
4720                .trade_id(TradeId::from(trade_id))
4721                .order_side(order_side)
4722                .last_qty(Quantity::from(quantity))
4723                .last_px(Price::from(price))
4724                .currency(Currency::USD())
4725                .position_id(position_id)
4726                .ts_event(UnixNanos::from(ts_event))
4727                .build()
4728        };
4729        let fills = [
4730            fill("O-OPEN-1", "T-OPEN-1", OrderSide::Buy, 10, "1.0", 1),
4731            fill("O-CLOSE-1", "T-CLOSE-1", OrderSide::Sell, 10, "2.0", 2),
4732            fill("O-OPEN-2", "T-OPEN-2", OrderSide::Buy, 10, "3.0", 3),
4733            fill("O-CLOSE-2", "T-CLOSE-2", OrderSide::Sell, 10, "5.0", 4),
4734            fill("O-CURRENT", "T-CURRENT", OrderSide::Buy, 5, "6.0", 5),
4735        ];
4736        let current = fills.last().unwrap();
4737        let fill_voided = matching_fill_void(current, Quantity::from(1), None);
4738        let mut position = Position::new(&instrument, fills[0].clone());
4739        for fill in &fills[1..] {
4740            position.apply(fill);
4741        }
4742
4743        let closed_cycles_pnl = position
4744            .apply_fill_void(fill_voided, Quantity::from(1), None)
4745            .unwrap();
4746
4747        assert_eq!(closed_cycles_pnl, Some(Money::from("30.00 USD")));
4748        assert_eq!(position.side, PositionSide::Long);
4749        assert_eq!(position.quantity, Quantity::from(4));
4750        assert_eq!(position.avg_px_open, 6.0);
4751        assert_eq!(position.realized_pnl, Some(Money::from("0.00 USD")));
4752        assert_eq!(position.events.len(), 1);
4753        assert_eq!(position.replay_events.len(), 5);
4754        assert_eq!(position.fill_voids.len(), 1);
4755    }
4756
4757    #[rstest]
4758    fn test_full_fill_void_preserves_unvoided_commission(audusd_sim: CurrencyPair) {
4759        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
4760        let position_id = PositionId::from("P-FEE-VOID");
4761        let fill = OrderFilledSpec::builder()
4762            .instrument_id(instrument.id())
4763            .client_order_id(ClientOrderId::from("O-FEE"))
4764            .trade_id(TradeId::from("T-FEE"))
4765            .order_side(OrderSide::Buy)
4766            .last_qty(Quantity::from(10))
4767            .last_px(Price::from("1.00000"))
4768            .currency(Currency::USD())
4769            .position_id(position_id)
4770            .commission(Money::from("1.00 USD"))
4771            .build();
4772        let fill_voided = OrderFillVoidedSpec::builder()
4773            .instrument_id(fill.instrument_id)
4774            .client_order_id(fill.client_order_id)
4775            .venue_order_id(fill.venue_order_id)
4776            .account_id(fill.account_id)
4777            .trade_id(fill.trade_id)
4778            .voided_qty(fill.last_qty)
4779            .order_side(fill.order_side)
4780            .order_type(fill.order_type)
4781            .last_px(fill.last_px)
4782            .currency(fill.currency)
4783            .liquidity_side(fill.liquidity_side)
4784            .build();
4785        let mut position = Position::new(&instrument, fill);
4786
4787        position
4788            .apply_fill_void(fill_voided, Quantity::from(10), None)
4789            .unwrap();
4790
4791        assert_eq!(position.side, PositionSide::Flat);
4792        assert_eq!(position.quantity, Quantity::from(0));
4793        assert_eq!(position.commissions(), vec![Money::from("1.00 USD")]);
4794        assert_eq!(position.realized_pnl, Some(Money::from("-1.00 USD")));
4795        assert!(position.events.is_empty());
4796    }
4797
4798    #[rstest]
4799    fn test_full_fill_void_preserves_unvoided_base_commission() {
4800        let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
4801        let position_id = PositionId::from("P-BASE-FEE-VOID");
4802        let fill = OrderFilledSpec::builder()
4803            .instrument_id(instrument.id())
4804            .client_order_id(ClientOrderId::from("O-BASE-FEE"))
4805            .trade_id(TradeId::from("T-BASE-FEE"))
4806            .order_side(OrderSide::Buy)
4807            .last_qty(Quantity::from("1.000000"))
4808            .last_px(Price::from("50000.00"))
4809            .currency(Currency::USDT())
4810            .position_id(position_id)
4811            .commission(Money::from("0.00100000 BTC"))
4812            .ts_event(UnixNanos::from(2_000))
4813            .ts_init(UnixNanos::from(1_900))
4814            .build();
4815        let fill_voided = matching_fill_void(&fill, fill.last_qty, None);
4816        let mut position = Position::new(&instrument, fill);
4817
4818        let closed_cycles_pnl = position
4819            .apply_fill_void(fill_voided, Quantity::from("1.000000"), None)
4820            .unwrap();
4821
4822        assert_eq!(closed_cycles_pnl, None);
4823        assert_eq!(position.entry, OrderSide::Sell);
4824        assert_eq!(position.side, PositionSide::Short);
4825        assert_eq!(position.signed_decimal_qty(), dec!(-0.001));
4826        assert_eq!(position.quantity.as_decimal(), dec!(0.001));
4827        assert_eq!(position.buy_qty.as_decimal(), Decimal::ZERO);
4828        assert_eq!(position.sell_qty.as_decimal(), Decimal::ZERO);
4829        assert_eq!(position.commissions(), vec![Money::from("0.00100000 BTC")]);
4830        assert_eq!(position.adjustments.len(), 1);
4831        assert_eq!(
4832            position.adjustments[0].adjustment_type,
4833            PositionAdjustmentType::Commission
4834        );
4835        assert_eq!(position.adjustments[0].quantity_change, Some(dec!(-0.001)));
4836        assert_eq!(position.opening_order_id, ClientOrderId::from("O-BASE-FEE"));
4837        assert_eq!(position.closing_order_id, None);
4838        assert_eq!(position.ts_init, UnixNanos::from(1_900));
4839        assert_eq!(position.ts_opened, UnixNanos::from(2_000));
4840        assert_eq!(position.ts_last, UnixNanos::from(2_000));
4841        assert_eq!(position.ts_closed, None);
4842        assert_eq!(position.duration_ns, DurationNanos::default());
4843        assert_eq!(position.avg_px_open, 50_000.0);
4844        assert_eq!(position.avg_px_close, None);
4845        assert_eq!(position.realized_pnl, None);
4846        assert!(position.events.is_empty());
4847        assert!(position.is_open());
4848        assert!(!position.is_closed());
4849    }
4850
4851    #[rstest]
4852    fn test_surviving_base_commission_can_close_position() {
4853        let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
4854        let position_id = PositionId::from("P-BASE-FEE-CLOSE");
4855        let opening = OrderFilledSpec::builder()
4856            .instrument_id(instrument.id())
4857            .client_order_id(ClientOrderId::from("O-BASE-OPEN"))
4858            .trade_id(TradeId::from("T-BASE-OPEN"))
4859            .order_side(OrderSide::Buy)
4860            .last_qty(Quantity::from("0.001000"))
4861            .last_px(Price::from("50000.00"))
4862            .currency(Currency::USDT())
4863            .position_id(position_id)
4864            .ts_event(UnixNanos::from(1_000))
4865            .ts_init(UnixNanos::from(900))
4866            .build();
4867        let fee_fill = OrderFilledSpec::builder()
4868            .instrument_id(instrument.id())
4869            .client_order_id(ClientOrderId::from("O-BASE-FEE"))
4870            .trade_id(TradeId::from("T-BASE-FEE"))
4871            .order_side(OrderSide::Buy)
4872            .last_qty(Quantity::from("1.000000"))
4873            .last_px(Price::from("51000.00"))
4874            .currency(Currency::USDT())
4875            .position_id(position_id)
4876            .commission(Money::from("0.00100000 BTC"))
4877            .ts_event(UnixNanos::from(2_000))
4878            .ts_init(UnixNanos::from(1_900))
4879            .build();
4880        let fill_voided = matching_fill_void(&fee_fill, fee_fill.last_qty, None);
4881        let mut position = Position::new(&instrument, opening);
4882        position.apply(&fee_fill);
4883
4884        let closed_cycles_pnl = position
4885            .apply_fill_void(fill_voided, Quantity::from("1.000000"), None)
4886            .unwrap();
4887
4888        assert_eq!(closed_cycles_pnl, None);
4889        assert_eq!(position.entry, OrderSide::Buy);
4890        assert_eq!(position.side, PositionSide::Flat);
4891        assert_eq!(position.signed_decimal_qty(), Decimal::ZERO);
4892        assert_eq!(position.quantity.as_decimal(), Decimal::ZERO);
4893        assert_eq!(position.buy_qty.as_decimal(), dec!(0.001));
4894        assert_eq!(position.sell_qty.as_decimal(), Decimal::ZERO);
4895        assert_eq!(position.commissions(), vec![Money::from("0.00100000 BTC")]);
4896        assert_eq!(position.events.len(), 1);
4897        assert_eq!(position.adjustments.len(), 1);
4898        assert_eq!(
4899            position.opening_order_id,
4900            ClientOrderId::from("O-BASE-OPEN")
4901        );
4902        assert_eq!(
4903            position.closing_order_id,
4904            Some(ClientOrderId::from("O-BASE-FEE"))
4905        );
4906        assert_eq!(position.ts_init, UnixNanos::from(900));
4907        assert_eq!(position.ts_opened, UnixNanos::from(1_000));
4908        assert_eq!(position.ts_last, UnixNanos::from(2_000));
4909        assert_eq!(position.ts_closed, Some(UnixNanos::from(2_000)));
4910        assert_eq!(position.duration_ns, DurationNanos::new(1_000));
4911        assert_eq!(position.avg_px_open, 50_000.0);
4912        assert_eq!(position.avg_px_close, None);
4913        assert_eq!(position.realized_pnl, Some(Money::from("0.00 USDT")));
4914        assert!(!position.is_open());
4915        assert!(position.is_closed());
4916    }
4917
4918    #[rstest]
4919    fn test_surviving_base_commission_reopen_returns_previous_cycle_pnl() {
4920        let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
4921        let position_id = PositionId::from("P-BASE-FEE-REOPEN");
4922        let opening = OrderFilledSpec::builder()
4923            .instrument_id(instrument.id())
4924            .client_order_id(ClientOrderId::from("O-OPEN"))
4925            .trade_id(TradeId::from("T-OPEN"))
4926            .order_side(OrderSide::Buy)
4927            .last_qty(Quantity::from("1.000000"))
4928            .last_px(Price::from("50000.00"))
4929            .currency(Currency::USDT())
4930            .position_id(position_id)
4931            .ts_event(UnixNanos::from(1_000))
4932            .build();
4933        let closing = OrderFilledSpec::builder()
4934            .instrument_id(instrument.id())
4935            .client_order_id(ClientOrderId::from("O-CLOSE"))
4936            .trade_id(TradeId::from("T-CLOSE"))
4937            .order_side(OrderSide::Sell)
4938            .last_qty(Quantity::from("1.000000"))
4939            .last_px(Price::from("51000.00"))
4940            .currency(Currency::USDT())
4941            .position_id(position_id)
4942            .ts_event(UnixNanos::from(2_000))
4943            .build();
4944        let reopening = OrderFilledSpec::builder()
4945            .instrument_id(instrument.id())
4946            .client_order_id(ClientOrderId::from("O-REOPEN"))
4947            .trade_id(TradeId::from("T-REOPEN"))
4948            .order_side(OrderSide::Buy)
4949            .last_qty(Quantity::from("1.000000"))
4950            .last_px(Price::from("52000.00"))
4951            .currency(Currency::USDT())
4952            .position_id(position_id)
4953            .commission(Money::from("0.00100000 BTC"))
4954            .ts_event(UnixNanos::from(3_000))
4955            .ts_init(UnixNanos::from(2_900))
4956            .build();
4957        let fill_voided = matching_fill_void(&reopening, reopening.last_qty, None);
4958        let mut position = Position::new(&instrument, opening);
4959        position.apply(&closing);
4960        position.apply(&reopening);
4961
4962        let closed_cycles_pnl = position
4963            .apply_fill_void(fill_voided, Quantity::from("1.000000"), None)
4964            .unwrap();
4965
4966        assert_eq!(closed_cycles_pnl, Some(Money::from("1000.00 USDT")));
4967        assert_eq!(position.entry, OrderSide::Sell);
4968        assert_eq!(position.side, PositionSide::Short);
4969        assert_eq!(position.signed_decimal_qty(), dec!(-0.001));
4970        assert_eq!(position.quantity.as_decimal(), dec!(0.001));
4971        assert_eq!(position.buy_qty.as_decimal(), Decimal::ZERO);
4972        assert_eq!(position.sell_qty.as_decimal(), Decimal::ZERO);
4973        assert_eq!(position.commissions(), vec![Money::from("0.00100000 BTC")]);
4974        assert!(position.events.is_empty());
4975        assert_eq!(position.adjustments.len(), 1);
4976        assert_eq!(position.opening_order_id, ClientOrderId::from("O-REOPEN"));
4977        assert_eq!(position.closing_order_id, None);
4978        assert_eq!(position.ts_init, UnixNanos::from(2_900));
4979        assert_eq!(position.ts_opened, UnixNanos::from(3_000));
4980        assert_eq!(position.ts_last, UnixNanos::from(3_000));
4981        assert_eq!(position.ts_closed, None);
4982        assert_eq!(position.duration_ns, DurationNanos::default());
4983        assert_eq!(position.avg_px_open, 52_000.0);
4984        assert_eq!(position.avg_px_close, None);
4985        assert_eq!(position.realized_pnl, None);
4986        assert!(position.is_open());
4987        assert!(!position.is_closed());
4988    }
4989
4990    #[rstest]
4991    fn test_surviving_base_commission_can_flip_position() {
4992        let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
4993        let position_id = PositionId::from("P-BASE-FEE-FLIP");
4994        let opening = OrderFilledSpec::builder()
4995            .instrument_id(instrument.id())
4996            .client_order_id(ClientOrderId::from("O-BASE-OPEN"))
4997            .trade_id(TradeId::from("T-BASE-OPEN"))
4998            .order_side(OrderSide::Buy)
4999            .last_qty(Quantity::from("0.000500"))
5000            .last_px(Price::from("50000.00"))
5001            .currency(Currency::USDT())
5002            .position_id(position_id)
5003            .ts_event(UnixNanos::from(1_000))
5004            .ts_init(UnixNanos::from(900))
5005            .build();
5006        let fee_fill = OrderFilledSpec::builder()
5007            .instrument_id(instrument.id())
5008            .client_order_id(ClientOrderId::from("O-BASE-FEE"))
5009            .trade_id(TradeId::from("T-BASE-FEE"))
5010            .order_side(OrderSide::Buy)
5011            .last_qty(Quantity::from("1.000000"))
5012            .last_px(Price::from("52000.00"))
5013            .currency(Currency::USDT())
5014            .position_id(position_id)
5015            .commission(Money::from("0.00100000 BTC"))
5016            .ts_event(UnixNanos::from(2_000))
5017            .ts_init(UnixNanos::from(1_900))
5018            .build();
5019        let fill_voided = matching_fill_void(&fee_fill, fee_fill.last_qty, None);
5020        let mut position = Position::new(&instrument, opening);
5021        position.apply(&fee_fill);
5022
5023        let closed_cycles_pnl = position
5024            .apply_fill_void(fill_voided, Quantity::from("1.000000"), None)
5025            .unwrap();
5026
5027        assert_eq!(closed_cycles_pnl, None);
5028        assert_eq!(position.entry, OrderSide::Sell);
5029        assert_eq!(position.side, PositionSide::Short);
5030        assert_eq!(position.signed_decimal_qty(), dec!(-0.0005));
5031        assert_eq!(position.quantity.as_decimal(), dec!(0.0005));
5032        assert_eq!(position.buy_qty.as_decimal(), dec!(0.0005));
5033        assert_eq!(position.sell_qty.as_decimal(), Decimal::ZERO);
5034        assert_eq!(position.commissions(), vec![Money::from("0.00100000 BTC")]);
5035        assert_eq!(position.events.len(), 1);
5036        assert_eq!(position.adjustments.len(), 1);
5037        assert_eq!(
5038            position.opening_order_id,
5039            ClientOrderId::from("O-BASE-OPEN")
5040        );
5041        assert_eq!(position.closing_order_id, None);
5042        assert_eq!(position.ts_init, UnixNanos::from(900));
5043        assert_eq!(position.ts_opened, UnixNanos::from(1_000));
5044        assert_eq!(position.ts_last, UnixNanos::from(2_000));
5045        assert_eq!(position.ts_closed, None);
5046        assert_eq!(position.duration_ns, DurationNanos::default());
5047        assert_eq!(position.avg_px_open, 52_000.0);
5048        assert_eq!(position.avg_px_close, None);
5049        assert_eq!(position.realized_pnl, Some(Money::from("0.00 USDT")));
5050        assert!(position.is_open());
5051        assert!(!position.is_closed());
5052    }
5053
5054    #[rstest]
5055    fn test_fill_void_replays_netting_flip_fragments_with_one_trade_id(audusd_sim: CurrencyPair) {
5056        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5057        let position_id = PositionId::from("P-FLIP-VOID");
5058        let opening = OrderFilledSpec::builder()
5059            .instrument_id(instrument.id())
5060            .client_order_id(ClientOrderId::from("O-OPEN"))
5061            .trade_id(TradeId::from("T-OPEN"))
5062            .order_side(OrderSide::Buy)
5063            .last_qty(Quantity::from(10))
5064            .last_px(Price::from("1.00000"))
5065            .currency(Currency::USD())
5066            .position_id(position_id)
5067            .build();
5068        let closing = OrderFilledSpec::builder()
5069            .instrument_id(instrument.id())
5070            .client_order_id(ClientOrderId::from("O-FLIP"))
5071            .trade_id(TradeId::from("T-FLIP"))
5072            .order_side(OrderSide::Sell)
5073            .last_qty(Quantity::from(10))
5074            .last_px(Price::from("1.10000"))
5075            .currency(Currency::USD())
5076            .position_id(position_id)
5077            .build();
5078        let mut reopening = closing.clone();
5079        reopening.last_qty = Quantity::from(5);
5080        reopening.event_id = uuid4();
5081        reopening.causation_id = Some(closing.event_id);
5082        let fill_voided = matching_fill_void(&closing, Quantity::from(12), None);
5083        let mut position = Position::new(&instrument, opening);
5084        position.apply(&closing);
5085        assert!(!position.is_duplicate_replay_fill(&reopening));
5086        position.apply(&reopening);
5087
5088        position
5089            .apply_fill_void(fill_voided, Quantity::from(12), None)
5090            .unwrap();
5091
5092        assert_eq!(position.side, PositionSide::Long);
5093        assert_eq!(position.quantity, Quantity::from(7));
5094        assert_eq!(position.buy_qty, Quantity::from(10));
5095        assert_eq!(position.sell_qty, Quantity::from(3));
5096        assert_eq!(position.replay_events.len(), 3);
5097        assert!(position.is_duplicate_replay_fill(&reopening));
5098    }
5099
5100    #[rstest]
5101    fn test_fill_void_replays_split_fragments_in_one_corrected_cycle(audusd_sim: CurrencyPair) {
5102        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5103        let position_id = PositionId::from("P-FLIP-CYCLE-VOID");
5104        let opening = OrderFilledSpec::builder()
5105            .instrument_id(instrument.id())
5106            .client_order_id(ClientOrderId::from("O-SELL-1"))
5107            .trade_id(TradeId::from("T-SELL-1"))
5108            .order_side(OrderSide::Sell)
5109            .last_qty(Quantity::from(17))
5110            .last_px(Price::from("1.00000"))
5111            .currency(Currency::USD())
5112            .position_id(position_id)
5113            .build();
5114        let second_sell = OrderFilledSpec::builder()
5115            .instrument_id(instrument.id())
5116            .client_order_id(ClientOrderId::from("O-SELL-2"))
5117            .trade_id(TradeId::from("T-SELL-2"))
5118            .order_side(OrderSide::Sell)
5119            .last_qty(Quantity::from(17))
5120            .last_px(Price::from("1.00000"))
5121            .currency(Currency::USD())
5122            .position_id(position_id)
5123            .build();
5124        let closing = OrderFilledSpec::builder()
5125            .instrument_id(instrument.id())
5126            .client_order_id(ClientOrderId::from("O-FLIP"))
5127            .trade_id(TradeId::from("T-FLIP"))
5128            .order_side(OrderSide::Buy)
5129            .last_qty(Quantity::from(34))
5130            .last_px(Price::from("1.10000"))
5131            .currency(Currency::USD())
5132            .position_id(position_id)
5133            .build();
5134        let mut reopening = closing.clone();
5135        reopening.last_qty = Quantity::from(591);
5136        reopening.event_id = uuid4();
5137        reopening.causation_id = Some(closing.event_id);
5138        let fill_voided = matching_fill_void(&second_sell, Quantity::from(2), None);
5139        let mut position = Position::new(&instrument, opening);
5140        position.apply(&second_sell);
5141        position.apply(&closing);
5142        position.apply(&reopening);
5143
5144        position
5145            .apply_fill_void(fill_voided, Quantity::from(2), None)
5146            .unwrap();
5147
5148        assert_eq!(position.side, PositionSide::Long);
5149        assert_eq!(position.quantity, Quantity::from(593));
5150        // The reversal starts a new episode with only the opening residual
5151        assert_eq!(position.buy_qty, Quantity::from(593));
5152        assert_eq!(position.sell_qty, Quantity::from(0));
5153        assert_eq!(position.events.len(), 4);
5154        assert_eq!(position.replay_events.len(), 4);
5155        assert_eq!(position.fill_voids.len(), 1);
5156        assert_eq!(position.trade_ids.len(), 3);
5157        assert!(position.trade_ids.contains(&TradeId::from("T-FLIP")));
5158    }
5159
5160    #[rstest]
5161    fn test_fill_void_replays_partially_voided_reversal(audusd_sim: CurrencyPair) {
5162        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
5163        let position_id = PositionId::from("P-REVERSAL-PARTIAL-VOID");
5164        let opening = OrderFilledSpec::builder()
5165            .instrument_id(instrument.id())
5166            .client_order_id(ClientOrderId::from("O-OPEN"))
5167            .trade_id(TradeId::from("T-OPEN"))
5168            .order_side(OrderSide::Sell)
5169            .last_qty(Quantity::from(10))
5170            .last_px(Price::from("1.00000"))
5171            .currency(Currency::USD())
5172            .position_id(position_id)
5173            .build();
5174        let partial_close = OrderFilledSpec::builder()
5175            .instrument_id(instrument.id())
5176            .client_order_id(ClientOrderId::from("O-CLOSE"))
5177            .trade_id(TradeId::from("T-CLOSE"))
5178            .order_side(OrderSide::Buy)
5179            .last_qty(Quantity::from(4))
5180            .last_px(Price::from("0.90000"))
5181            .currency(Currency::USD())
5182            .position_id(position_id)
5183            .build();
5184        let reversal = OrderFilledSpec::builder()
5185            .instrument_id(instrument.id())
5186            .client_order_id(ClientOrderId::from("O-REVERSE"))
5187            .trade_id(TradeId::from("T-REVERSE"))
5188            .order_side(OrderSide::Buy)
5189            .last_qty(Quantity::from(11))
5190            .last_px(Price::from("1.10000"))
5191            .currency(Currency::USD())
5192            .position_id(position_id)
5193            .build();
5194        let fill_voided = matching_fill_void(&reversal, Quantity::from(2), None);
5195        let mut position = Position::new(&instrument, opening);
5196        position.apply(&partial_close);
5197        position.apply(&reversal);
5198
5199        position
5200            .apply_fill_void(fill_voided, Quantity::from(2), None)
5201            .unwrap();
5202
5203        assert_eq!(position.side, PositionSide::Long);
5204        assert_eq!(position.quantity, Quantity::from(3));
5205        assert_eq!(position.avg_px_open, 1.1);
5206        assert_eq!(position.avg_px_close, None);
5207        assert_eq!(position.realized_return, 0.0);
5208        assert_eq!(position.buy_qty, Quantity::from(3));
5209        assert_eq!(position.sell_qty, Quantity::from(0));
5210        assert_eq!(position.realized_pnl, Some(Money::from("-0.20 USD")));
5211        assert_eq!(position.fill_voids.len(), 1);
5212    }
5213
5214    #[rstest]
5215    fn test_position_realized_pnl_with_interleaved_order_sides(
5216        currency_pair_btcusdt: CurrencyPair,
5217    ) {
5218        let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
5219        let order1 = OrderTestBuilder::new(OrderType::Market)
5220            .instrument_id(btcusdt.id())
5221            .side(OrderSide::Buy)
5222            .quantity(Quantity::from(12))
5223            .build();
5224        let commission1 = calculate_commission(
5225            &btcusdt,
5226            order1.quantity(),
5227            Price::from("10000.0"),
5228            None,
5229            dec!(0.001),
5230        );
5231        let fill1 = TestOrderEventStubs::filled(
5232            &order1,
5233            &btcusdt,
5234            Some(TradeId::from("1")),
5235            Some(PositionId::from("P-19700101-000000-001-001-1")),
5236            Some(Price::from("10000.0")),
5237            None,
5238            None,
5239            Some(commission1),
5240            None,
5241            None,
5242        );
5243        let mut position = Position::new(&btcusdt, fill1.into());
5244        let order2 = OrderTestBuilder::new(OrderType::Market)
5245            .instrument_id(btcusdt.id())
5246            .side(OrderSide::Buy)
5247            .quantity(Quantity::from(17))
5248            .build();
5249        let commission2 = calculate_commission(
5250            &btcusdt,
5251            order2.quantity(),
5252            Price::from("9999.0"),
5253            None,
5254            dec!(0.001),
5255        );
5256        let fill2 = TestOrderEventStubs::filled(
5257            &order2,
5258            &btcusdt,
5259            Some(TradeId::from("2")),
5260            Some(PositionId::from("P-19700101-000000-001-001-1")),
5261            Some(Price::from("9999.0")),
5262            None,
5263            None,
5264            Some(commission2),
5265            None,
5266            None,
5267        );
5268        position.apply(&fill2.into());
5269        assert_eq!(position.quantity, Quantity::from(29));
5270        assert_eq!(
5271            position.realized_pnl,
5272            Some(Money::from("-289.98300000 USDT"))
5273        );
5274        assert_eq!(position.avg_px_open, 9_999.413_793_103_447);
5275        let order3 = OrderTestBuilder::new(OrderType::Market)
5276            .instrument_id(btcusdt.id())
5277            .side(OrderSide::Sell)
5278            .quantity(Quantity::from(9))
5279            .build();
5280        let commission3 = calculate_commission(
5281            &btcusdt,
5282            order3.quantity(),
5283            Price::from("10001.0"),
5284            None,
5285            dec!(0.001),
5286        );
5287        let fill3 = TestOrderEventStubs::filled(
5288            &order3,
5289            &btcusdt,
5290            Some(TradeId::from("3")),
5291            Some(PositionId::from("P-19700101-000000-001-001-1")),
5292            Some(Price::from("10001.0")),
5293            None,
5294            None,
5295            Some(commission3),
5296            None,
5297            None,
5298        );
5299        position.apply(&fill3.into());
5300        assert_eq!(position.quantity, Quantity::from(20));
5301        assert_eq!(
5302            position.realized_pnl,
5303            Some(Money::from("-365.71613793 USDT"))
5304        );
5305        assert_eq!(position.avg_px_open, 9_999.413_793_103_447);
5306        let order4 = OrderTestBuilder::new(OrderType::Market)
5307            .instrument_id(btcusdt.id())
5308            .side(OrderSide::Buy)
5309            .quantity(Quantity::from(3))
5310            .build();
5311        let commission4 = calculate_commission(
5312            &btcusdt,
5313            order4.quantity(),
5314            Price::from("10003.0"),
5315            None,
5316            dec!(0.001),
5317        );
5318        let fill4 = TestOrderEventStubs::filled(
5319            &order4,
5320            &btcusdt,
5321            Some(TradeId::from("4")),
5322            Some(PositionId::from("P-19700101-000000-001-001-1")),
5323            Some(Price::from("10003.0")),
5324            None,
5325            None,
5326            Some(commission4),
5327            None,
5328            None,
5329        );
5330        position.apply(&fill4.into());
5331        assert_eq!(position.quantity, Quantity::from(23));
5332        assert_eq!(
5333            position.realized_pnl,
5334            Some(Money::from("-395.72513793 USDT"))
5335        );
5336        assert_eq!(position.avg_px_open, 9_999.881_559_220_39);
5337        let order5 = OrderTestBuilder::new(OrderType::Market)
5338            .instrument_id(btcusdt.id())
5339            .side(OrderSide::Sell)
5340            .quantity(Quantity::from(4))
5341            .build();
5342        let commission5 = calculate_commission(
5343            &btcusdt,
5344            order5.quantity(),
5345            Price::from("10005.0"),
5346            None,
5347            dec!(0.001),
5348        );
5349        let fill5 = TestOrderEventStubs::filled(
5350            &order5,
5351            &btcusdt,
5352            Some(TradeId::from("5")),
5353            Some(PositionId::from("P-19700101-000000-001-001-1")),
5354            Some(Price::from("10005.0")),
5355            None,
5356            None,
5357            Some(commission5),
5358            None,
5359            None,
5360        );
5361        position.apply(&fill5.into());
5362        assert_eq!(position.quantity, Quantity::from(19));
5363        assert_eq!(
5364            position.realized_pnl,
5365            Some(Money::from("-415.27137481 USDT"))
5366        );
5367        assert_eq!(position.avg_px_open, 9_999.881_559_220_39);
5368        assert_eq!(
5369            format!("{position}"),
5370            "Position(LONG 19.000000 BTCUSDT.BINANCE, id=P-19700101-000000-001-001-1)"
5371        );
5372    }
5373
5374    #[rstest]
5375    fn test_calculate_pnl_when_given_position_side_flat_returns_zero(
5376        currency_pair_btcusdt: CurrencyPair,
5377    ) {
5378        let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
5379        let order = OrderTestBuilder::new(OrderType::Market)
5380            .instrument_id(btcusdt.id())
5381            .side(OrderSide::Buy)
5382            .quantity(Quantity::from(12))
5383            .build();
5384        let fill = TestOrderEventStubs::filled(
5385            &order,
5386            &btcusdt,
5387            None,
5388            Some(PositionId::from("P-123456")),
5389            Some(Price::from("10500.0")),
5390            None,
5391            None,
5392            None,
5393            None,
5394            None,
5395        );
5396        let position = Position::new(&btcusdt, fill.into());
5397        let result = position.calculate_pnl(10500.0, 10500.0, Quantity::from("100000.0"));
5398        assert_eq!(result, Money::from("0 USDT"));
5399    }
5400
5401    #[rstest]
5402    fn test_calculate_pnl_for_long_position_win(currency_pair_btcusdt: CurrencyPair) {
5403        let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
5404        let order = OrderTestBuilder::new(OrderType::Market)
5405            .instrument_id(btcusdt.id())
5406            .side(OrderSide::Buy)
5407            .quantity(Quantity::from(12))
5408            .build();
5409        let commission = calculate_commission(
5410            &btcusdt,
5411            order.quantity(),
5412            Price::from("10500.0"),
5413            None,
5414            dec!(0.001),
5415        );
5416        let fill = TestOrderEventStubs::filled(
5417            &order,
5418            &btcusdt,
5419            None,
5420            Some(PositionId::from("P-123456")),
5421            Some(Price::from("10500.0")),
5422            None,
5423            None,
5424            Some(commission),
5425            None,
5426            None,
5427        );
5428        let position = Position::new(&btcusdt, fill.into());
5429        let pnl = position.calculate_pnl(10500.0, 10510.0, Quantity::from("12.0"));
5430        assert_eq!(pnl, Money::from("120 USDT"));
5431        assert_eq!(position.realized_pnl, Some(Money::from("-126 USDT")));
5432        assert_eq!(
5433            position.unrealized_pnl(Price::from("10510.0")),
5434            Money::from("120.0 USDT")
5435        );
5436        assert_eq!(
5437            position.total_pnl(Price::from("10510.0")),
5438            Money::from("-6 USDT")
5439        );
5440        assert_eq!(position.commissions(), vec![Money::from("126.0 USDT")]);
5441    }
5442
5443    #[rstest]
5444    fn test_calculate_pnl_for_long_position_loss(currency_pair_btcusdt: CurrencyPair) {
5445        let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
5446        let order = OrderTestBuilder::new(OrderType::Market)
5447            .instrument_id(btcusdt.id())
5448            .side(OrderSide::Buy)
5449            .quantity(Quantity::from(12))
5450            .build();
5451        let commission = calculate_commission(
5452            &btcusdt,
5453            order.quantity(),
5454            Price::from("10500.0"),
5455            None,
5456            dec!(0.001),
5457        );
5458        let fill = TestOrderEventStubs::filled(
5459            &order,
5460            &btcusdt,
5461            None,
5462            Some(PositionId::from("P-123456")),
5463            Some(Price::from("10500.0")),
5464            None,
5465            None,
5466            Some(commission),
5467            None,
5468            None,
5469        );
5470        let position = Position::new(&btcusdt, fill.into());
5471        let pnl = position.calculate_pnl(10500.0, 10480.5, Quantity::from("10.0"));
5472        assert_eq!(pnl, Money::from("-195 USDT"));
5473        assert_eq!(position.realized_pnl, Some(Money::from("-126 USDT")));
5474        assert_eq!(
5475            position.unrealized_pnl(Price::from("10480.50")),
5476            Money::from("-234.0 USDT")
5477        );
5478        assert_eq!(
5479            position.total_pnl(Price::from("10480.50")),
5480            Money::from("-360 USDT")
5481        );
5482        assert_eq!(position.commissions(), vec![Money::from("126.0 USDT")]);
5483    }
5484
5485    #[rstest]
5486    fn test_calculate_pnl_for_short_position_winning(currency_pair_btcusdt: CurrencyPair) {
5487        let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
5488        let order = OrderTestBuilder::new(OrderType::Market)
5489            .instrument_id(btcusdt.id())
5490            .side(OrderSide::Sell)
5491            .quantity(Quantity::from("10.15"))
5492            .build();
5493        let commission = calculate_commission(
5494            &btcusdt,
5495            order.quantity(),
5496            Price::from("10500.0"),
5497            None,
5498            dec!(0.001),
5499        );
5500        let fill = TestOrderEventStubs::filled(
5501            &order,
5502            &btcusdt,
5503            None,
5504            Some(PositionId::from("P-123456")),
5505            Some(Price::from("10500.0")),
5506            None,
5507            None,
5508            Some(commission),
5509            None,
5510            None,
5511        );
5512        let position = Position::new(&btcusdt, fill.into());
5513        let pnl = position.calculate_pnl(10500.0, 10390.0, Quantity::from("10.15"));
5514        assert_eq!(pnl, Money::from("1116.5 USDT"));
5515        assert_eq!(
5516            position.unrealized_pnl(Price::from("10390.0")),
5517            Money::from("1116.5 USDT")
5518        );
5519        assert_eq!(position.realized_pnl, Some(Money::from("-106.575 USDT")));
5520        assert_eq!(position.commissions(), vec![Money::from("106.575 USDT")]);
5521        assert_eq!(
5522            position.notional_value(Price::from("10390.0")),
5523            Money::from("105458.5 USDT")
5524        );
5525    }
5526
5527    #[rstest]
5528    fn test_calculate_pnl_for_short_position_loss(currency_pair_btcusdt: CurrencyPair) {
5529        let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
5530        let order = OrderTestBuilder::new(OrderType::Market)
5531            .instrument_id(btcusdt.id())
5532            .side(OrderSide::Sell)
5533            .quantity(Quantity::from("10.0"))
5534            .build();
5535        let commission = calculate_commission(
5536            &btcusdt,
5537            order.quantity(),
5538            Price::from("10500.0"),
5539            None,
5540            dec!(0.001),
5541        );
5542        let fill = TestOrderEventStubs::filled(
5543            &order,
5544            &btcusdt,
5545            None,
5546            Some(PositionId::from("P-123456")),
5547            Some(Price::from("10500.0")),
5548            None,
5549            None,
5550            Some(commission),
5551            None,
5552            None,
5553        );
5554        let position = Position::new(&btcusdt, fill.into());
5555        let pnl = position.calculate_pnl(10500.0, 10670.5, Quantity::from("10.0"));
5556        assert_eq!(pnl, Money::from("-1705 USDT"));
5557        assert_eq!(
5558            position.unrealized_pnl(Price::from("10670.5")),
5559            Money::from("-1705 USDT")
5560        );
5561        assert_eq!(position.realized_pnl, Some(Money::from("-105 USDT")));
5562        assert_eq!(position.commissions(), vec![Money::from("105 USDT")]);
5563        assert_eq!(
5564            position.notional_value(Price::from("10670.5")),
5565            Money::from("106705 USDT")
5566        );
5567    }
5568
5569    #[rstest]
5570    fn test_calculate_pnl_for_inverse1(btcusd_bybit: CryptoPerpetual) {
5571        let btcusd_bybit = InstrumentAny::CryptoPerpetual(btcusd_bybit);
5572        let order = OrderTestBuilder::new(OrderType::Market)
5573            .instrument_id(btcusd_bybit.id())
5574            .side(OrderSide::Sell)
5575            .quantity(Quantity::from("100000"))
5576            .build();
5577        let commission = calculate_commission(
5578            &btcusd_bybit,
5579            order.quantity(),
5580            Price::from("10000.0"),
5581            None,
5582            dec!(0.00075),
5583        );
5584        let fill = TestOrderEventStubs::filled(
5585            &order,
5586            &btcusd_bybit,
5587            None,
5588            Some(PositionId::from("P-123456")),
5589            Some(Price::from("10000.0")),
5590            None,
5591            None,
5592            Some(commission),
5593            None,
5594            None,
5595        );
5596        let position = Position::new(&btcusd_bybit, fill.into());
5597        let pnl = position.calculate_pnl(10000.0, 11000.0, Quantity::from("100000.0"));
5598        assert_eq!(pnl, Money::from("-0.90909091 BTC"));
5599        assert_eq!(
5600            position.unrealized_pnl(Price::from("11000.0")),
5601            Money::from("-0.90909091 BTC")
5602        );
5603        assert_eq!(position.realized_pnl, Some(Money::from("-0.00750000 BTC")));
5604        assert_eq!(
5605            position.notional_value(Price::from("11000.0")),
5606            Money::from("9.09090909 BTC")
5607        );
5608    }
5609
5610    #[rstest]
5611    #[case("0.030", "0.001 BTC", "0.003 BTC", "0.00098 BTC")]
5612    #[case("0.000", "-0.002 BTC", "0 BTC", "-0.00202 BTC")]
5613    fn test_calculate_pnl_for_inverse_option_values_premium_in_base(
5614        mut crypto_option_btc_deribit: CryptoOption,
5615        #[case] close_px: &str,
5616        #[case] expected_unrealized_pnl: &str,
5617        #[case] expected_notional: &str,
5618        #[case] expected_realized_pnl: &str,
5619    ) {
5620        crypto_option_btc_deribit.is_inverse = true;
5621        crypto_option_btc_deribit.multiplier = Quantity::from("0.01");
5622        let option = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
5623        let open_order = OrderTestBuilder::new(OrderType::Market)
5624            .instrument_id(option.id())
5625            .side(OrderSide::Buy)
5626            .quantity(Quantity::from("10.0"))
5627            .build();
5628        let close_order = OrderTestBuilder::new(OrderType::Market)
5629            .instrument_id(option.id())
5630            .side(OrderSide::Sell)
5631            .quantity(Quantity::from("10.0"))
5632            .build();
5633        let open_fill = TestOrderEventStubs::filled(
5634            &open_order,
5635            &option,
5636            Some(TradeId::new("1")),
5637            None,
5638            Some(Price::from("0.020")),
5639            None,
5640            None,
5641            Some(Money::from("0.00001 BTC")),
5642            None,
5643            None,
5644        );
5645        let close_fill = TestOrderEventStubs::filled(
5646            &close_order,
5647            &option,
5648            Some(TradeId::new("2")),
5649            None,
5650            Some(Price::from(close_px)),
5651            None,
5652            None,
5653            Some(Money::from("0.00001 BTC")),
5654            None,
5655            None,
5656        );
5657        let mut position = Position::new(&option, open_fill.into());
5658        let unrealized_pnl = position.unrealized_pnl(Price::from(close_px));
5659        let notional = position.notional_value(Price::from(close_px));
5660
5661        position.apply(&close_fill.into());
5662
5663        assert_eq!(unrealized_pnl, Money::from(expected_unrealized_pnl));
5664        assert_eq!(notional, Money::from(expected_notional));
5665        assert_eq!(
5666            position.realized_pnl,
5667            Some(Money::from(expected_realized_pnl))
5668        );
5669    }
5670
5671    #[rstest]
5672    fn test_calculate_pnl_scales_by_contract_multiplier(
5673        mut btcusd_bybit: CryptoPerpetual,
5674        mut crypto_perpetual_ethusdt: CryptoPerpetual,
5675    ) {
5676        btcusd_bybit.multiplier = Quantity::from(10);
5677        let btcusd_bybit = InstrumentAny::CryptoPerpetual(btcusd_bybit);
5678        let inverse_order = OrderTestBuilder::new(OrderType::Market)
5679            .instrument_id(btcusd_bybit.id())
5680            .side(OrderSide::Sell)
5681            .quantity(Quantity::from("100000"))
5682            .build();
5683        let inverse_fill = TestOrderEventStubs::filled(
5684            &inverse_order,
5685            &btcusd_bybit,
5686            None,
5687            Some(PositionId::from("P-MULTIPLIER-INVERSE")),
5688            Some(Price::from("10000.0")),
5689            None,
5690            None,
5691            None,
5692            None,
5693            None,
5694        );
5695        let inverse_position = Position::new(&btcusd_bybit, inverse_fill.into());
5696
5697        assert_eq!(
5698            inverse_position.calculate_pnl(10000.0, 11000.0, Quantity::from("100000.0")),
5699            Money::from("-9.09090909 BTC")
5700        );
5701
5702        crypto_perpetual_ethusdt.multiplier = Quantity::from(10);
5703        let ethusdt = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt);
5704        let linear_order = OrderTestBuilder::new(OrderType::Market)
5705            .instrument_id(ethusdt.id())
5706            .side(OrderSide::Buy)
5707            .quantity(Quantity::from("2.000"))
5708            .build();
5709        let linear_fill = TestOrderEventStubs::filled(
5710            &linear_order,
5711            &ethusdt,
5712            None,
5713            Some(PositionId::from("P-MULTIPLIER-LINEAR")),
5714            Some(Price::from("1000.00")),
5715            None,
5716            None,
5717            None,
5718            None,
5719            None,
5720        );
5721        let linear_position = Position::new(&ethusdt, linear_fill.into());
5722
5723        assert_eq!(
5724            linear_position.calculate_pnl(1000.0, 1100.0, Quantity::from("2.000")),
5725            Money::new(2000.0, linear_position.settlement_currency)
5726        );
5727    }
5728
5729    #[rstest]
5730    fn test_try_notional_value_for_inverse_zero_price_returns_error(btcusd_bybit: CryptoPerpetual) {
5731        let btcusd_bybit = InstrumentAny::CryptoPerpetual(btcusd_bybit);
5732        let order = OrderTestBuilder::new(OrderType::Market)
5733            .instrument_id(btcusd_bybit.id())
5734            .side(OrderSide::Sell)
5735            .quantity(Quantity::from("100000"))
5736            .build();
5737        let fill = TestOrderEventStubs::filled(
5738            &order,
5739            &btcusd_bybit,
5740            None,
5741            Some(PositionId::from("P-ZERO-PRICE")),
5742            Some(Price::from("10000.0")),
5743            None,
5744            None,
5745            None,
5746            None,
5747            None,
5748        );
5749        let mut position = Position::new(&btcusd_bybit, fill.into());
5750
5751        let result = position.try_notional_value(Price::new(0.0, 1));
5752
5753        assert_eq!(
5754            result.unwrap_err().to_string(),
5755            "price must be positive for inverse notional valuation"
5756        );
5757        assert!(
5758            position
5759                .try_calculate_pnl(10_000.0, 0.0, position.quantity)
5760                .is_err()
5761        );
5762        assert!(position.try_unrealized_pnl(Price::new(0.0, 1)).is_err());
5763        assert!(position.try_total_pnl(Price::new(0.0, 1)).is_err());
5764        assert!(position.try_unrealized_pnl(Price::new(-1.0, 1)).is_err());
5765        assert_eq!(
5766            position.calculate_pnl(10_000.0, 0.0, position.quantity),
5767            Money::zero(position.settlement_currency)
5768        );
5769        assert_eq!(
5770            position.unrealized_pnl(Price::new(0.0, 1)),
5771            Money::zero(position.settlement_currency)
5772        );
5773        assert_eq!(
5774            position.total_pnl(Price::new(0.0, 1)),
5775            Money::zero(position.settlement_currency)
5776        );
5777
5778        position.base_currency = None;
5779        let result = position.try_notional_value(Price::from("10000.0"));
5780
5781        assert_eq!(
5782            result.unwrap_err().to_string(),
5783            "inverse position BTCUSD.BYBIT has no base currency"
5784        );
5785        assert!(position.try_unrealized_pnl(Price::from("10000.0")).is_err());
5786    }
5787
5788    #[rstest]
5789    fn test_calculate_avg_px_zero_quantity_paths(stub_position_long: Position) {
5790        assert_eq!(
5791            stub_position_long
5792                .calculate_avg_px(0.0, 1.0, 2.0, 5.0)
5793                .unwrap(),
5794            2.0
5795        );
5796        assert_eq!(
5797            stub_position_long
5798                .calculate_avg_px(0.0, 1.0, 2.0, 0.0)
5799                .unwrap_err()
5800                .to_string(),
5801            "Cannot calculate average price: both quantities are zero"
5802        );
5803        assert_eq!(
5804            stub_position_long
5805                .calculate_avg_px(5.0, 1.0, 2.0, 0.0)
5806                .unwrap_err()
5807                .to_string(),
5808            "Cannot calculate average price: fill quantity is zero"
5809        );
5810    }
5811
5812    #[rstest]
5813    fn test_calculate_return_rejects_zero_open_price(stub_position_long: Position) {
5814        assert_eq!(
5815            stub_position_long
5816                .calculate_return(0.0, 1.5)
5817                .unwrap_err()
5818                .to_string(),
5819            "Cannot calculate return: open price is zero (close price: 1.5)"
5820        );
5821        assert_eq!(stub_position_long.calculate_return(2.0, 3.0).unwrap(), 0.5);
5822    }
5823
5824    #[rstest]
5825    fn test_calculate_points_inverse_rejects_non_positive_prices(btcusd_bybit: CryptoPerpetual) {
5826        let btcusd_bybit = InstrumentAny::CryptoPerpetual(btcusd_bybit);
5827        let order = OrderTestBuilder::new(OrderType::Market)
5828            .instrument_id(btcusd_bybit.id())
5829            .side(OrderSide::Sell)
5830            .quantity(Quantity::from("100000"))
5831            .build();
5832        let fill = TestOrderEventStubs::filled(
5833            &order,
5834            &btcusd_bybit,
5835            None,
5836            Some(PositionId::from("P-INVERSE-GUARD")),
5837            Some(Price::from("10000.0")),
5838            None,
5839            None,
5840            None,
5841            None,
5842            None,
5843        );
5844        let position = Position::new(&btcusd_bybit, fill.into());
5845
5846        assert_eq!(
5847            position
5848                .calculate_points_inverse(-1.0, 10_000.0)
5849                .unwrap_err()
5850                .to_string(),
5851            "Cannot calculate inverse points: open price is not positive or is too small (-1)"
5852        );
5853        assert_eq!(
5854            position
5855                .calculate_points_inverse(10_000.0, -1.0)
5856                .unwrap_err()
5857                .to_string(),
5858            "Cannot calculate inverse points: close price is not positive or is too small (-1)"
5859        );
5860        assert!(position.calculate_points_inverse(1e-16, 10_000.0).is_err());
5861        assert!(position.calculate_points_inverse(10_000.0, 1e-16).is_err());
5862        assert!(position.calculate_points_inverse(1e-15, 10_000.0).is_ok());
5863        assert!(position.calculate_points_inverse(10_000.0, 1e-15).is_ok());
5864    }
5865
5866    #[rstest]
5867    fn test_calculate_pnl_for_inverse2(ethusd_bybit: CryptoPerpetual) {
5868        let ethusd_bybit = InstrumentAny::CryptoPerpetual(ethusd_bybit);
5869        let order = OrderTestBuilder::new(OrderType::Market)
5870            .instrument_id(ethusd_bybit.id())
5871            .side(OrderSide::Sell)
5872            .quantity(Quantity::from("100000"))
5873            .build();
5874        let commission = calculate_commission(
5875            &ethusd_bybit,
5876            order.quantity(),
5877            Price::from("375.95"),
5878            None,
5879            dec!(0.00075),
5880        );
5881        let fill = TestOrderEventStubs::filled(
5882            &order,
5883            &ethusd_bybit,
5884            None,
5885            Some(PositionId::from("P-123456")),
5886            Some(Price::from("375.95")),
5887            None,
5888            None,
5889            Some(commission),
5890            None,
5891            None,
5892        );
5893        let position = Position::new(&ethusd_bybit, fill.into());
5894
5895        assert_eq!(
5896            position.unrealized_pnl(Price::from("370.00")),
5897            Money::from("4.27745208 ETH")
5898        );
5899        assert_eq!(
5900            position.notional_value(Price::from("370.00")),
5901            Money::from("270.27027027 ETH")
5902        );
5903    }
5904
5905    #[rstest]
5906    fn test_notional_value_for_quanto_uses_settlement_currency(ethbtc_quanto: CryptoFuture) {
5907        let instrument = InstrumentAny::CryptoFuture(ethbtc_quanto);
5908        let order = OrderTestBuilder::new(OrderType::Market)
5909            .instrument_id(instrument.id())
5910            .side(OrderSide::Buy)
5911            .quantity(Quantity::from("5"))
5912            .build();
5913        let price = Price::from("0.03600");
5914        let fill = TestOrderEventStubs::filled(
5915            &order,
5916            &instrument,
5917            None,
5918            Some(PositionId::from("P-QUANTO-NOTIONAL")),
5919            Some(price),
5920            None,
5921            None,
5922            None,
5923            None,
5924            None,
5925        );
5926        let position = Position::new(&instrument, fill.into());
5927        let position_notional = position.notional_value(price);
5928        let instrument_notional =
5929            instrument.calculate_notional_value(position.quantity, price, None);
5930
5931        assert_eq!(position_notional, instrument_notional);
5932        assert_eq!(position_notional, Money::from("0.18 USDT"));
5933    }
5934
5935    #[rstest]
5936    fn test_calculate_unrealized_pnl_for_long(currency_pair_btcusdt: CurrencyPair) {
5937        let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
5938        let order1 = OrderTestBuilder::new(OrderType::Market)
5939            .instrument_id(btcusdt.id())
5940            .side(OrderSide::Buy)
5941            .quantity(Quantity::from("2.000000"))
5942            .build();
5943        let order2 = OrderTestBuilder::new(OrderType::Market)
5944            .instrument_id(btcusdt.id())
5945            .side(OrderSide::Buy)
5946            .quantity(Quantity::from("2.000000"))
5947            .build();
5948        let commission1 = calculate_commission(
5949            &btcusdt,
5950            order1.quantity(),
5951            Price::from("10500.0"),
5952            None,
5953            dec!(0.001),
5954        );
5955        let fill1 = TestOrderEventStubs::filled(
5956            &order1,
5957            &btcusdt,
5958            Some(TradeId::new("1")),
5959            Some(PositionId::new("P-123456")),
5960            Some(Price::from("10500.00")),
5961            None,
5962            None,
5963            Some(commission1),
5964            None,
5965            None,
5966        );
5967        let commission2 = calculate_commission(
5968            &btcusdt,
5969            order2.quantity(),
5970            Price::from("10500.0"),
5971            None,
5972            dec!(0.001),
5973        );
5974        let fill2 = TestOrderEventStubs::filled(
5975            &order2,
5976            &btcusdt,
5977            Some(TradeId::new("2")),
5978            Some(PositionId::new("P-123456")),
5979            Some(Price::from("10500.00")),
5980            None,
5981            None,
5982            Some(commission2),
5983            None,
5984            None,
5985        );
5986        let mut position = Position::new(&btcusdt, fill1.into());
5987        position.apply(&fill2.into());
5988        let pnl = position.unrealized_pnl(Price::from("11505.60"));
5989        assert_eq!(pnl, Money::from("4022.40000000 USDT"));
5990        assert_eq!(
5991            position.realized_pnl,
5992            Some(Money::from("-42.00000000 USDT"))
5993        );
5994        assert_eq!(
5995            position.commissions(),
5996            vec![Money::from("42.00000000 USDT")]
5997        );
5998    }
5999
6000    #[rstest]
6001    fn test_calculate_unrealized_pnl_for_short(currency_pair_btcusdt: CurrencyPair) {
6002        let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
6003        let order = OrderTestBuilder::new(OrderType::Market)
6004            .instrument_id(btcusdt.id())
6005            .side(OrderSide::Sell)
6006            .quantity(Quantity::from("5.912000"))
6007            .build();
6008        let commission = calculate_commission(
6009            &btcusdt,
6010            order.quantity(),
6011            Price::from("10505.60"),
6012            None,
6013            dec!(0.001),
6014        );
6015        let fill = TestOrderEventStubs::filled(
6016            &order,
6017            &btcusdt,
6018            Some(TradeId::new("1")),
6019            Some(PositionId::new("P-123456")),
6020            Some(Price::from("10505.60")),
6021            None,
6022            None,
6023            Some(commission),
6024            None,
6025            None,
6026        );
6027        let position = Position::new(&btcusdt, fill.into());
6028        let pnl = position.unrealized_pnl(Price::from("10407.15"));
6029        assert_eq!(pnl, Money::from("582.03640000 USDT"));
6030        assert_eq!(
6031            position.realized_pnl,
6032            Some(Money::from("-62.10910720 USDT"))
6033        );
6034        assert_eq!(
6035            position.commissions(),
6036            vec![Money::from("62.10910720 USDT")]
6037        );
6038    }
6039
6040    #[rstest]
6041    fn test_calculate_unrealized_pnl_for_long_inverse(btcusd_bybit: CryptoPerpetual) {
6042        let btcusd_bybit = InstrumentAny::CryptoPerpetual(btcusd_bybit);
6043        let order = OrderTestBuilder::new(OrderType::Market)
6044            .instrument_id(btcusd_bybit.id())
6045            .side(OrderSide::Buy)
6046            .quantity(Quantity::from("100000"))
6047            .build();
6048        let commission = calculate_commission(
6049            &btcusd_bybit,
6050            order.quantity(),
6051            Price::from("10500.0"),
6052            None,
6053            dec!(0.00075),
6054        );
6055        let fill = TestOrderEventStubs::filled(
6056            &order,
6057            &btcusd_bybit,
6058            Some(TradeId::new("1")),
6059            Some(PositionId::new("P-123456")),
6060            Some(Price::from("10500.00")),
6061            None,
6062            None,
6063            Some(commission),
6064            None,
6065            None,
6066        );
6067
6068        let position = Position::new(&btcusd_bybit, fill.into());
6069        let pnl = position.unrealized_pnl(Price::from("11505.60"));
6070        assert_eq!(pnl, Money::from("0.83238969 BTC"));
6071        assert_eq!(position.realized_pnl, Some(Money::from("-0.00714286 BTC")));
6072        assert_eq!(position.commissions(), vec![Money::from("0.00714286 BTC")]);
6073    }
6074
6075    #[rstest]
6076    fn test_calculate_unrealized_pnl_for_short_inverse(btcusd_bybit: CryptoPerpetual) {
6077        let btcusd_bybit = InstrumentAny::CryptoPerpetual(btcusd_bybit);
6078        let order = OrderTestBuilder::new(OrderType::Market)
6079            .instrument_id(btcusd_bybit.id())
6080            .side(OrderSide::Sell)
6081            .quantity(Quantity::from("1250000"))
6082            .build();
6083        let commission = calculate_commission(
6084            &btcusd_bybit,
6085            order.quantity(),
6086            Price::from("15500.00"),
6087            None,
6088            dec!(0.00075),
6089        );
6090        let fill = TestOrderEventStubs::filled(
6091            &order,
6092            &btcusd_bybit,
6093            Some(TradeId::new("1")),
6094            Some(PositionId::new("P-123456")),
6095            Some(Price::from("15500.00")),
6096            None,
6097            None,
6098            Some(commission),
6099            None,
6100            None,
6101        );
6102        let position = Position::new(&btcusd_bybit, fill.into());
6103        let pnl = position.unrealized_pnl(Price::from("12506.65"));
6104
6105        assert_eq!(pnl, Money::from("19.30166700 BTC"));
6106        assert_eq!(position.realized_pnl, Some(Money::from("-0.06048387 BTC")));
6107        assert_eq!(position.commissions(), vec![Money::from("0.06048387 BTC")]);
6108    }
6109
6110    #[rstest]
6111    #[case(OrderSide::Buy, 25, 25.0)]
6112    #[case(OrderSide::Sell,25,-25.0)]
6113    fn test_signed_qty_decimal_qty_for_equity(
6114        #[case] order_side: OrderSide,
6115        #[case] quantity: i64,
6116        #[case] expected: f64,
6117        audusd_sim: CurrencyPair,
6118    ) {
6119        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
6120        let order = OrderTestBuilder::new(OrderType::Market)
6121            .instrument_id(audusd_sim.id())
6122            .side(order_side)
6123            .quantity(Quantity::from(quantity))
6124            .build();
6125
6126        let commission = calculate_commission(
6127            &audusd_sim,
6128            order.quantity(),
6129            Price::from("1.0"),
6130            None,
6131            dec!(0.00002),
6132        );
6133        let fill = TestOrderEventStubs::filled(
6134            &order,
6135            &audusd_sim,
6136            None,
6137            Some(PositionId::from("P-123456")),
6138            None,
6139            None,
6140            None,
6141            Some(commission),
6142            None,
6143            None,
6144        );
6145        let position = Position::new(&audusd_sim, fill.into());
6146        assert_eq!(position.signed_qty, expected);
6147    }
6148
6149    #[rstest]
6150    fn test_position_with_commission_none(audusd_sim: CurrencyPair) {
6151        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
6152        let fill = OrderFilledSpec::builder()
6153            .position_id(PositionId::from("1"))
6154            .build();
6155
6156        let position = Position::new(&audusd_sim, fill);
6157        assert_eq!(position.realized_pnl, Some(Money::from("0 USD")));
6158    }
6159
6160    #[rstest]
6161    fn test_position_with_commission_zero(audusd_sim: CurrencyPair) {
6162        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
6163        let fill = OrderFilledSpec::builder()
6164            .position_id(PositionId::from("1"))
6165            .commission(Money::from("0 USD"))
6166            .build();
6167
6168        let position = Position::new(&audusd_sim, fill);
6169        assert_eq!(position.realized_pnl, Some(Money::from("0 USD")));
6170    }
6171
6172    #[rstest]
6173    fn test_cache_purge_order_events() {
6174        let audusd_sim = audusd_sim();
6175        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
6176
6177        let order1 = OrderTestBuilder::new(OrderType::Market)
6178            .client_order_id(ClientOrderId::new("O-1"))
6179            .instrument_id(audusd_sim.id())
6180            .side(OrderSide::Buy)
6181            .quantity(Quantity::from(50_000))
6182            .build();
6183
6184        let order2 = OrderTestBuilder::new(OrderType::Market)
6185            .client_order_id(ClientOrderId::new("O-2"))
6186            .instrument_id(audusd_sim.id())
6187            .side(OrderSide::Buy)
6188            .quantity(Quantity::from(50_000))
6189            .build();
6190
6191        let position_id = PositionId::new("P-123456");
6192
6193        let fill1 = TestOrderEventStubs::filled(
6194            &order1,
6195            &audusd_sim,
6196            Some(TradeId::new("1")),
6197            Some(position_id),
6198            Some(Price::from("1.00001")),
6199            None,
6200            None,
6201            None,
6202            None,
6203            None,
6204        );
6205
6206        let mut position = Position::new(&audusd_sim, fill1.into());
6207
6208        let fill2 = TestOrderEventStubs::filled(
6209            &order2,
6210            &audusd_sim,
6211            Some(TradeId::new("2")),
6212            Some(position_id),
6213            Some(Price::from("1.00002")),
6214            None,
6215            None,
6216            None,
6217            None,
6218            None,
6219        );
6220
6221        position.apply(&fill2.into());
6222        position.purge_events_for_order(order1.client_order_id());
6223
6224        assert_eq!(position.events.len(), 1);
6225        assert_eq!(position.trade_ids.len(), 1);
6226        assert_eq!(position.events[0].client_order_id, order2.client_order_id());
6227        assert!(position.trade_ids.contains(&TradeId::new("2")));
6228    }
6229
6230    #[rstest]
6231    fn test_purge_all_events_returns_none_for_last_event_and_trade_id() {
6232        let audusd_sim = audusd_sim();
6233        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
6234
6235        let order = OrderTestBuilder::new(OrderType::Market)
6236            .client_order_id(ClientOrderId::new("O-1"))
6237            .instrument_id(audusd_sim.id())
6238            .side(OrderSide::Buy)
6239            .quantity(Quantity::from(100_000))
6240            .build();
6241
6242        let position_id = PositionId::new("P-123456");
6243        let fill = TestOrderEventStubs::filled(
6244            &order,
6245            &audusd_sim,
6246            Some(TradeId::new("1")),
6247            Some(position_id),
6248            Some(Price::from("1.00050")),
6249            None,
6250            None,
6251            None,
6252            Some(UnixNanos::from(1_000_000_000)), // Explicit non-zero timestamp
6253            None,
6254        );
6255
6256        let mut position = Position::new(&audusd_sim, fill.into());
6257
6258        assert_eq!(position.events.len(), 1);
6259        assert!(position.last_event().is_some());
6260        assert!(position.last_trade_id().is_some());
6261
6262        // Store original timestamps (should be non-zero)
6263        let original_ts_opened = position.ts_opened;
6264        let original_ts_last = position.ts_last;
6265        assert_ne!(original_ts_opened, UnixNanos::default());
6266        assert_ne!(original_ts_last, UnixNanos::default());
6267
6268        position.purge_events_for_order(order.client_order_id());
6269
6270        assert_eq!(position.events.len(), 0);
6271        assert_eq!(position.trade_ids.len(), 0);
6272        assert!(position.last_event().is_none());
6273        assert!(position.last_trade_id().is_none());
6274
6275        // Verify timestamps are zeroed - empty shell has no meaningful history
6276        // ts_closed is set to Some(0) so position reports as closed and is eligible for purge
6277        assert_eq!(position.ts_opened, UnixNanos::default());
6278        assert_eq!(position.ts_last, UnixNanos::default());
6279        assert_eq!(position.ts_closed, Some(UnixNanos::default()));
6280        assert_eq!(position.duration_ns, DurationNanos::default());
6281
6282        // Verify empty shell reports as closed (this was the bug we fixed!)
6283        // is_closed() must return true so cache purge logic recognizes empty shells
6284        assert!(position.is_closed());
6285        assert!(!position.is_open());
6286        assert_eq!(position.side, PositionSide::Flat);
6287    }
6288
6289    #[rstest]
6290    fn test_revive_from_empty_shell(audusd_sim: CurrencyPair) {
6291        // Test adding a fill to an empty shell position
6292        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
6293
6294        // Create and then purge position to get empty shell
6295        let order1 = OrderTestBuilder::new(OrderType::Market)
6296            .instrument_id(audusd_sim.id())
6297            .side(OrderSide::Buy)
6298            .quantity(Quantity::from(100_000))
6299            .build();
6300
6301        let fill1 = TestOrderEventStubs::filled(
6302            &order1,
6303            &audusd_sim,
6304            None,
6305            Some(PositionId::new("P-1")),
6306            Some(Price::from("1.00000")),
6307            None,
6308            None,
6309            None,
6310            Some(UnixNanos::from(1_000_000_000)),
6311            None,
6312        );
6313
6314        let mut position = Position::new(&audusd_sim, fill1.into());
6315        position.purge_events_for_order(order1.client_order_id());
6316
6317        // Verify it's an empty shell
6318        assert!(position.is_closed());
6319        assert_eq!(position.ts_closed, Some(UnixNanos::default()));
6320        assert_eq!(position.event_count(), 0);
6321
6322        // Add new fill to revive the position
6323        let order2 = OrderTestBuilder::new(OrderType::Market)
6324            .instrument_id(audusd_sim.id())
6325            .side(OrderSide::Buy)
6326            .quantity(Quantity::from(50_000))
6327            .build();
6328
6329        let fill2 = TestOrderEventStubs::filled(
6330            &order2,
6331            &audusd_sim,
6332            None,
6333            Some(PositionId::new("P-1")),
6334            Some(Price::from("1.00020")),
6335            None,
6336            None,
6337            None,
6338            Some(UnixNanos::from(3_000_000_000)),
6339            None,
6340        );
6341
6342        let fill2_typed: OrderFilled = fill2.clone().into();
6343        position.apply(&fill2_typed);
6344
6345        // Position should be alive with new timestamps
6346        assert!(position.is_long());
6347        assert!(!position.is_closed());
6348        assert!(position.ts_closed.is_none());
6349        assert_eq!(position.ts_opened, fill2.ts_event());
6350        assert_eq!(position.ts_last, fill2.ts_event());
6351        assert_eq!(position.event_count(), 1);
6352        assert_eq!(position.quantity, Quantity::from(50_000));
6353    }
6354
6355    #[rstest]
6356    fn test_empty_shell_position_invariants(audusd_sim: CurrencyPair) {
6357        // Property-based test: Any position with event_count == 0 must satisfy invariants
6358        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
6359
6360        let order = OrderTestBuilder::new(OrderType::Market)
6361            .instrument_id(audusd_sim.id())
6362            .side(OrderSide::Buy)
6363            .quantity(Quantity::from(100_000))
6364            .build();
6365
6366        let fill = TestOrderEventStubs::filled(
6367            &order,
6368            &audusd_sim,
6369            None,
6370            Some(PositionId::new("P-1")),
6371            Some(Price::from("1.00000")),
6372            None,
6373            None,
6374            None,
6375            Some(UnixNanos::from(1_000_000_000)),
6376            None,
6377        );
6378
6379        let mut position = Position::new(&audusd_sim, fill.into());
6380        position.purge_events_for_order(order.client_order_id());
6381
6382        // INVARIANTS: When event_count == 0, the following MUST be true
6383        assert_eq!(
6384            position.event_count(),
6385            0,
6386            "Precondition: event_count must be 0"
6387        );
6388
6389        // Invariant 1: Position must report as closed
6390        assert!(
6391            position.is_closed(),
6392            "INV1: Empty shell must report is_closed() == true"
6393        );
6394        assert!(
6395            !position.is_open(),
6396            "INV1: Empty shell must report is_open() == false"
6397        );
6398
6399        // Invariant 2: Position must be FLAT
6400        assert_eq!(
6401            position.side,
6402            PositionSide::Flat,
6403            "INV2: Empty shell must be FLAT"
6404        );
6405
6406        // Invariant 3: ts_closed must be Some (not None)
6407        assert!(
6408            position.ts_closed.is_some(),
6409            "INV3: Empty shell must have ts_closed.is_some()"
6410        );
6411        assert_eq!(
6412            position.ts_closed,
6413            Some(UnixNanos::default()),
6414            "INV3: Empty shell ts_closed must be 0"
6415        );
6416
6417        // Invariant 4: All lifecycle timestamps must be zeroed
6418        assert_eq!(
6419            position.ts_opened,
6420            UnixNanos::default(),
6421            "INV4: Empty shell ts_opened must be 0"
6422        );
6423        assert_eq!(
6424            position.ts_last,
6425            UnixNanos::default(),
6426            "INV4: Empty shell ts_last must be 0"
6427        );
6428        assert_eq!(
6429            position.duration_ns,
6430            DurationNanos::default(),
6431            "INV4: Empty shell duration_ns must be 0"
6432        );
6433
6434        // Invariant 5: Quantity must be zero
6435        assert_eq!(
6436            position.quantity,
6437            Quantity::zero(audusd_sim.size_precision()),
6438            "INV5: Empty shell quantity must be 0"
6439        );
6440
6441        // Invariant 6: No events or trade IDs
6442        assert!(
6443            position.events.is_empty(),
6444            "INV6: Empty shell must have no events"
6445        );
6446        assert!(
6447            position.trade_ids.is_empty(),
6448            "INV6: Empty shell must have no trade IDs"
6449        );
6450        assert!(
6451            position.last_event().is_none(),
6452            "INV6: Empty shell must have no last event"
6453        );
6454        assert!(
6455            position.last_trade_id().is_none(),
6456            "INV6: Empty shell must have no last trade ID"
6457        );
6458    }
6459
6460    #[rstest]
6461    fn test_position_pnl_precision_with_very_small_amounts(audusd_sim: CurrencyPair) {
6462        // Tests behavior with very small commission amounts
6463        // NOTE: Amounts below f64 epsilon (~1e-15) may be lost to precision
6464        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
6465        let order = OrderTestBuilder::new(OrderType::Market)
6466            .instrument_id(audusd_sim.id())
6467            .side(OrderSide::Buy)
6468            .quantity(Quantity::from(100))
6469            .build();
6470
6471        // Test with a commission that won't be lost to Money precision (0.01 USD)
6472        let small_commission = Money::new(0.01, Currency::USD());
6473        let fill = TestOrderEventStubs::filled(
6474            &order,
6475            &audusd_sim,
6476            None,
6477            None,
6478            Some(Price::from("1.00001")),
6479            Some(Quantity::from(100)),
6480            None,
6481            Some(small_commission),
6482            None,
6483            None,
6484        );
6485
6486        let position = Position::new(&audusd_sim, fill.into());
6487
6488        // Commission is recorded and preserved in f64 arithmetic
6489        assert_eq!(position.commissions().len(), 1);
6490        let recorded_commission = position.commissions()[0];
6491        assert!(
6492            recorded_commission.as_f64() > 0.0,
6493            "Commission of 0.01 should be preserved"
6494        );
6495
6496        // Realized PnL should include commission (negative)
6497        let realized = position.realized_pnl.unwrap().as_f64();
6498        assert!(
6499            realized < 0.0,
6500            "Realized PnL should be negative due to commission"
6501        );
6502    }
6503
6504    #[rstest]
6505    fn test_position_pnl_precision_with_high_precision_instrument() {
6506        // Tests precision with high-precision crypto instrument
6507        use crate::instruments::stubs::crypto_perpetual_ethusdt;
6508        let ethusdt = crypto_perpetual_ethusdt();
6509        let ethusdt = InstrumentAny::CryptoPerpetual(ethusdt);
6510
6511        // Check instrument precision
6512        let size_precision = ethusdt.size_precision();
6513
6514        let order = OrderTestBuilder::new(OrderType::Market)
6515            .instrument_id(ethusdt.id())
6516            .side(OrderSide::Buy)
6517            .quantity(Quantity::from("1.123456789"))
6518            .build();
6519
6520        let fill = TestOrderEventStubs::filled(
6521            &order,
6522            &ethusdt,
6523            None,
6524            None,
6525            Some(Price::from("2345.123456789")),
6526            Some(Quantity::from("1.123456789")),
6527            None,
6528            Some(Money::from("0.1 USDT")),
6529            None,
6530            None,
6531        );
6532
6533        let position = Position::new(&ethusdt, fill.into());
6534
6535        // Verify high-precision price is preserved in f64 (within tolerance)
6536        let avg_px = position.avg_px_open;
6537        assert!(
6538            (avg_px - 2_345.123_456_789).abs() < 1e-6,
6539            "High precision price should be preserved within f64 tolerance"
6540        );
6541
6542        // Quantity will be rounded to instrument's size_precision
6543        // Verify it matches the instrument's precision
6544        assert_eq!(
6545            position.quantity.precision, size_precision,
6546            "Quantity precision should match instrument"
6547        );
6548
6549        // f64 representation will be close but may have rounding based on precision
6550        let qty_f64 = position.quantity.as_f64();
6551        assert!(
6552            qty_f64 > 1.0 && qty_f64 < 2.0,
6553            "Quantity should be in expected range"
6554        );
6555    }
6556
6557    #[rstest]
6558    fn test_position_pnl_accumulation_across_many_fills(audusd_sim: CurrencyPair) {
6559        // Tests precision drift across 100 fills
6560        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
6561        let order = OrderTestBuilder::new(OrderType::Market)
6562            .instrument_id(audusd_sim.id())
6563            .side(OrderSide::Buy)
6564            .quantity(Quantity::from(1000))
6565            .build();
6566
6567        let initial_fill = TestOrderEventStubs::filled(
6568            &order,
6569            &audusd_sim,
6570            Some(TradeId::new("1")),
6571            None,
6572            Some(Price::from("1.00000")),
6573            Some(Quantity::from(10)),
6574            None,
6575            Some(Money::from("0.01 USD")),
6576            None,
6577            None,
6578        );
6579
6580        let mut position = Position::new(&audusd_sim, initial_fill.into());
6581
6582        // Apply 99 more fills with varying prices
6583        for i in 2..=100 {
6584            let price_offset = f64::from(i) * 0.00001;
6585            let fill = TestOrderEventStubs::filled(
6586                &order,
6587                &audusd_sim,
6588                Some(TradeId::new(i.to_string())),
6589                None,
6590                Some(Price::from(&format!("{:.5}", 1.0 + price_offset))),
6591                Some(Quantity::from(10)),
6592                None,
6593                Some(Money::from("0.01 USD")),
6594                None,
6595                None,
6596            );
6597            position.apply(&fill.into());
6598        }
6599
6600        // Verify we accumulated 100 fills
6601        assert_eq!(position.events.len(), 100);
6602        assert_eq!(position.quantity, Quantity::from(1000));
6603
6604        // Verify commissions accumulated (should be 100 * 0.01 = 1.0 USD)
6605        let total_commission: f64 = position.commissions().iter().map(Money::as_f64).sum();
6606        assert!(
6607            (total_commission - 1.0).abs() < 1e-10,
6608            "Commission accumulation should be accurate: expected 1.0, was {total_commission}"
6609        );
6610
6611        // Verify average price is reasonable (should be around 1.0005)
6612        let avg_px = position.avg_px_open;
6613        assert!(
6614            avg_px > 1.0 && avg_px < 1.001,
6615            "Average price should be reasonable: got {avg_px}"
6616        );
6617    }
6618
6619    #[rstest]
6620    fn test_position_pnl_with_extreme_price_values(audusd_sim: CurrencyPair) {
6621        // Tests position handling with very large and very small prices
6622        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
6623
6624        // Test with very small price
6625        let order_small = OrderTestBuilder::new(OrderType::Market)
6626            .instrument_id(audusd_sim.id())
6627            .side(OrderSide::Buy)
6628            .quantity(Quantity::from(100_000))
6629            .build();
6630
6631        let fill_small = TestOrderEventStubs::filled(
6632            &order_small,
6633            &audusd_sim,
6634            None,
6635            None,
6636            Some(Price::from("0.00001")),
6637            Some(Quantity::from(100_000)),
6638            None,
6639            None,
6640            None,
6641            None,
6642        );
6643
6644        let position_small = Position::new(&audusd_sim, fill_small.into());
6645        assert_eq!(position_small.avg_px_open, 0.00001);
6646
6647        // Verify notional calculation doesn't underflow
6648        let last_price_small = Price::from("0.00002");
6649        let unrealized = position_small.unrealized_pnl(last_price_small);
6650        assert!(
6651            unrealized.as_f64() > 0.0,
6652            "Unrealized PnL should be positive when price doubles"
6653        );
6654
6655        // Test with very large price
6656        let order_large = OrderTestBuilder::new(OrderType::Market)
6657            .instrument_id(audusd_sim.id())
6658            .side(OrderSide::Buy)
6659            .quantity(Quantity::from(100))
6660            .build();
6661
6662        let fill_large = TestOrderEventStubs::filled(
6663            &order_large,
6664            &audusd_sim,
6665            None,
6666            None,
6667            Some(Price::from("99999.99999")),
6668            Some(Quantity::from(100)),
6669            None,
6670            None,
6671            None,
6672            None,
6673        );
6674
6675        let position_large = Position::new(&audusd_sim, fill_large.into());
6676        assert!(
6677            (position_large.avg_px_open - 99999.99999).abs() < 1e-6,
6678            "Large price should be preserved within f64 tolerance"
6679        );
6680    }
6681
6682    #[rstest]
6683    fn test_position_pnl_roundtrip_precision(audusd_sim: CurrencyPair) {
6684        // Tests that opening and closing a position preserves precision
6685        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
6686        let buy_order = OrderTestBuilder::new(OrderType::Market)
6687            .instrument_id(audusd_sim.id())
6688            .side(OrderSide::Buy)
6689            .quantity(Quantity::from(100_000))
6690            .build();
6691
6692        let sell_order = OrderTestBuilder::new(OrderType::Market)
6693            .instrument_id(audusd_sim.id())
6694            .side(OrderSide::Sell)
6695            .quantity(Quantity::from(100_000))
6696            .build();
6697
6698        // Open at precise price
6699        let open_fill = TestOrderEventStubs::filled(
6700            &buy_order,
6701            &audusd_sim,
6702            Some(TradeId::new("1")),
6703            None,
6704            Some(Price::from("1.123456")),
6705            None,
6706            None,
6707            Some(Money::from("0.50 USD")),
6708            None,
6709            None,
6710        );
6711
6712        let mut position = Position::new(&audusd_sim, open_fill.into());
6713
6714        // Close at same price (no profit/loss except commission)
6715        let close_fill = TestOrderEventStubs::filled(
6716            &sell_order,
6717            &audusd_sim,
6718            Some(TradeId::new("2")),
6719            None,
6720            Some(Price::from("1.123456")),
6721            None,
6722            None,
6723            Some(Money::from("0.50 USD")),
6724            None,
6725            None,
6726        );
6727
6728        position.apply(&close_fill.into());
6729
6730        // Position should be flat
6731        assert!(position.is_closed());
6732
6733        // Realized PnL should be exactly -1.0 USD (two commissions of 0.50)
6734        let realized = position.realized_pnl.unwrap().as_f64();
6735        assert!(
6736            (realized - (-1.0)).abs() < 1e-10,
6737            "Realized PnL should be exactly -1.0 USD (commissions), was {realized}"
6738        );
6739    }
6740
6741    #[rstest]
6742    fn test_position_commission_in_base_currency_buy() {
6743        // Test that commission in base currency reduces position quantity on buy (SPOT only)
6744        let btc_usdt = currency_pair_btcusdt();
6745        let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
6746
6747        let order = OrderTestBuilder::new(OrderType::Market)
6748            .instrument_id(btc_usdt.id())
6749            .side(OrderSide::Buy)
6750            .quantity(Quantity::from("1.0"))
6751            .build();
6752
6753        // Buy 1.0 BTC with 0.001 BTC commission
6754        let fill = match TestOrderEventStubs::filled(
6755            &order,
6756            &btc_usdt,
6757            Some(TradeId::new("1")),
6758            None,
6759            Some(Price::from("50000.0")),
6760            Some(Quantity::from("1.0")),
6761            None,
6762            Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
6763            None,
6764            None,
6765        ) {
6766            OrderEventAny::Filled(fill) => fill,
6767            _ => unreachable!(),
6768        };
6769
6770        let position = Position::new(&btc_usdt, fill.clone());
6771        let replayed_position = Position::new(&btc_usdt, fill);
6772
6773        // Position quantity should be 1.0 - 0.001 = 0.999 BTC
6774        assert!(
6775            (position.quantity.as_f64() - 0.999).abs() < 1e-9,
6776            "Position quantity should be 0.999 BTC (1.0 - 0.001 commission), was {}",
6777            position.quantity.as_f64()
6778        );
6779
6780        // Signed qty should also be 0.999
6781        assert!(
6782            (position.signed_qty - 0.999).abs() < 1e-9,
6783            "Signed qty should be 0.999, was {}",
6784            position.signed_qty
6785        );
6786
6787        // Verify PositionAdjusted event was created
6788        assert_eq!(
6789            position.adjustments.len(),
6790            1,
6791            "Should have 1 adjustment event"
6792        );
6793        let adjustment = &position.adjustments[0];
6794        assert_eq!(
6795            adjustment.adjustment_type,
6796            PositionAdjustmentType::Commission
6797        );
6798        assert_eq!(
6799            adjustment.quantity_change,
6800            Some(rust_decimal_macros::dec!(-0.001))
6801        );
6802        assert_eq!(adjustment.pnl_change, None);
6803        assert_eq!(
6804            adjustment.event_id,
6805            replayed_position.adjustments[0].event_id
6806        );
6807    }
6808
6809    #[rstest]
6810    fn test_base_commission_adjustment_id_flips_low_bit_of_fill_id(audusd_sim: CurrencyPair) {
6811        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
6812        let base_currency = instrument.base_currency().unwrap();
6813        let fill = OrderFilledSpec::builder()
6814            .instrument_id(instrument.id())
6815            .client_order_id(ClientOrderId::from("O-1"))
6816            .trade_id(TradeId::from("T-1"))
6817            .order_side(OrderSide::Buy)
6818            .last_qty(Quantity::from(10))
6819            .last_px(Price::from("1.00000"))
6820            .currency(Currency::USD())
6821            .commission(Money::new(2.0, base_currency))
6822            .position_id(PositionId::from("P-COMMISSION"))
6823            .event_id(UUID4::from_str("a1b2c3d4-e5f6-4789-8abc-def012345679").unwrap())
6824            .ts_event(UnixNanos::from(1))
6825            .build();
6826
6827        let position = Position::new(&instrument, fill);
6828
6829        assert_eq!(position.adjustments.len(), 1);
6830        assert_eq!(
6831            position.adjustments[0].event_id,
6832            UUID4::from_str("a1b2c3d4-e5f6-4789-8abc-def012345678").unwrap()
6833        );
6834    }
6835
6836    #[rstest]
6837    fn test_position_commission_in_base_currency_sell() {
6838        // Test that commission in base currency increases short position on sell
6839        let btc_usdt = currency_pair_btcusdt();
6840        let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
6841
6842        let order = OrderTestBuilder::new(OrderType::Market)
6843            .instrument_id(btc_usdt.id())
6844            .side(OrderSide::Sell)
6845            .quantity(Quantity::from("1.0"))
6846            .build();
6847
6848        // Sell 1.0 BTC with 0.001 BTC commission
6849        let fill = TestOrderEventStubs::filled(
6850            &order,
6851            &btc_usdt,
6852            Some(TradeId::new("1")),
6853            None,
6854            Some(Price::from("50000.0")),
6855            Some(Quantity::from("1.0")),
6856            None,
6857            Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
6858            None,
6859            None,
6860        );
6861
6862        let position = Position::new(&btc_usdt, fill.into());
6863
6864        // Position quantity should be 1.0 + 0.001 = 1.001 BTC
6865        // (you sold 1.0 and paid 0.001 commission, so total short exposure is 1.001)
6866        assert!(
6867            (position.quantity.as_f64() - 1.001).abs() < 1e-9,
6868            "Position quantity should be 1.001 BTC (1.0 + 0.001 commission), was {}",
6869            position.quantity.as_f64()
6870        );
6871
6872        // Signed qty should be -1.001 (short position)
6873        assert!(
6874            (position.signed_qty - (-1.001)).abs() < 1e-9,
6875            "Signed qty should be -1.001, was {}",
6876            position.signed_qty
6877        );
6878
6879        // Verify PositionAdjusted event was created
6880        assert_eq!(
6881            position.adjustments.len(),
6882            1,
6883            "Should have 1 adjustment event"
6884        );
6885        let adjustment = &position.adjustments[0];
6886        assert_eq!(
6887            adjustment.adjustment_type,
6888            PositionAdjustmentType::Commission
6889        );
6890        // For sell, commission increases the short (negative adjustment)
6891        assert_eq!(
6892            adjustment.quantity_change,
6893            Some(rust_decimal_macros::dec!(-0.001))
6894        );
6895        assert_eq!(adjustment.pnl_change, None);
6896    }
6897
6898    #[rstest]
6899    fn test_position_commission_in_quote_currency_no_adjustment() {
6900        // Test that commission in quote currency does NOT reduce position quantity
6901        let btc_usdt = currency_pair_btcusdt();
6902        let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
6903
6904        let order = OrderTestBuilder::new(OrderType::Market)
6905            .instrument_id(btc_usdt.id())
6906            .side(OrderSide::Buy)
6907            .quantity(Quantity::from("1.0"))
6908            .build();
6909
6910        // Buy 1.0 BTC with 50 USDT commission (in quote currency)
6911        let fill = TestOrderEventStubs::filled(
6912            &order,
6913            &btc_usdt,
6914            Some(TradeId::new("1")),
6915            None,
6916            Some(Price::from("50000.0")),
6917            Some(Quantity::from("1.0")),
6918            None,
6919            Some(Money::new(50.0, Currency::USD())),
6920            None,
6921            None,
6922        );
6923
6924        let position = Position::new(&btc_usdt, fill.into());
6925
6926        // Position quantity should be exactly 1.0 BTC (no adjustment)
6927        assert!(
6928            (position.quantity.as_f64() - 1.0).abs() < 1e-9,
6929            "Position quantity should be 1.0 BTC (no adjustment for quote currency commission), was {}",
6930            position.quantity.as_f64()
6931        );
6932
6933        // Verify NO PositionAdjusted event was created (commission in quote currency)
6934        assert_eq!(
6935            position.adjustments.len(),
6936            0,
6937            "Should have no adjustment events for quote currency commission"
6938        );
6939    }
6940
6941    #[rstest]
6942    fn test_position_reset_clears_adjustments() {
6943        // Test that closing and reopening a position clears adjustment history
6944        let btc_usdt = currency_pair_btcusdt();
6945        let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
6946
6947        // Open long position with commission adjustment
6948        let buy_order = OrderTestBuilder::new(OrderType::Market)
6949            .instrument_id(btc_usdt.id())
6950            .side(OrderSide::Buy)
6951            .quantity(Quantity::from("1.0"))
6952            .build();
6953
6954        let buy_fill = TestOrderEventStubs::filled(
6955            &buy_order,
6956            &btc_usdt,
6957            Some(TradeId::new("1")),
6958            None,
6959            Some(Price::from("50000.0")),
6960            Some(Quantity::from("1.0")),
6961            None,
6962            Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
6963            None,
6964            None,
6965        );
6966
6967        let mut position = Position::new(&btc_usdt, buy_fill.into());
6968        assert_eq!(position.adjustments.len(), 1, "Should have 1 adjustment");
6969
6970        // Close the position (sell the actual quantity, use quote currency commission to avoid complexity)
6971        let sell_order = OrderTestBuilder::new(OrderType::Market)
6972            .instrument_id(btc_usdt.id())
6973            .side(OrderSide::Sell)
6974            .quantity(Quantity::from("0.999"))
6975            .build();
6976
6977        let sell_fill = TestOrderEventStubs::filled(
6978            &sell_order,
6979            &btc_usdt,
6980            Some(TradeId::new("2")),
6981            None,
6982            Some(Price::from("51000.0")),
6983            Some(Quantity::from("0.999")),
6984            None,
6985            Some(Money::new(50.0, Currency::USD())), // Quote currency commission - no adjustment
6986            None,
6987            None,
6988        );
6989
6990        position.apply(&sell_fill.into());
6991        assert_eq!(position.side, PositionSide::Flat);
6992        assert_eq!(
6993            position.adjustments.len(),
6994            1,
6995            "Should still have 1 adjustment (no new one from quote commission)"
6996        );
6997
6998        // Reopen the position - adjustments should be cleared
6999        let buy_order2 = OrderTestBuilder::new(OrderType::Market)
7000            .instrument_id(btc_usdt.id())
7001            .side(OrderSide::Buy)
7002            .quantity(Quantity::from("2.0"))
7003            .build();
7004
7005        let buy_fill2 = TestOrderEventStubs::filled(
7006            &buy_order2,
7007            &btc_usdt,
7008            Some(TradeId::new("3")),
7009            None,
7010            Some(Price::from("52000.0")),
7011            Some(Quantity::from("2.0")),
7012            None,
7013            Some(Money::new(0.002, btc_usdt.base_currency().unwrap())),
7014            None,
7015            None,
7016        );
7017
7018        position.apply(&buy_fill2.into());
7019
7020        // Verify adjustments were cleared and only new adjustment exists
7021        assert_eq!(
7022            position.adjustments.len(),
7023            1,
7024            "Adjustments should be cleared on position reset, only new adjustment"
7025        );
7026        assert_eq!(
7027            position.adjustments[0].quantity_change,
7028            Some(rust_decimal_macros::dec!(-0.002)),
7029            "New adjustment should be for the new fill"
7030        );
7031        assert_eq!(position.events.len(), 1, "Events should also be reset");
7032    }
7033
7034    #[rstest]
7035    fn test_id_accessors_deduplicate_and_sort(audusd_sim: CurrencyPair) {
7036        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
7037        let position_id = PositionId::from("P-IDS");
7038        let fill1 = OrderFilledSpec::builder()
7039            .instrument_id(instrument.id())
7040            .client_order_id(ClientOrderId::from("O-2"))
7041            .venue_order_id(VenueOrderId::from("V-2"))
7042            .trade_id(TradeId::from("T-3"))
7043            .order_side(OrderSide::Buy)
7044            .last_qty(Quantity::from(10))
7045            .last_px(Price::from("1.00000"))
7046            .currency(Currency::USD())
7047            .position_id(position_id)
7048            .ts_event(UnixNanos::from(1))
7049            .build();
7050        let fill2 = OrderFilledSpec::builder()
7051            .instrument_id(instrument.id())
7052            .client_order_id(ClientOrderId::from("O-1"))
7053            .venue_order_id(VenueOrderId::from("V-1"))
7054            .trade_id(TradeId::from("T-1"))
7055            .order_side(OrderSide::Buy)
7056            .last_qty(Quantity::from(10))
7057            .last_px(Price::from("1.00000"))
7058            .currency(Currency::USD())
7059            .position_id(position_id)
7060            .ts_event(UnixNanos::from(2))
7061            .build();
7062        let fill3 = OrderFilledSpec::builder()
7063            .instrument_id(instrument.id())
7064            .client_order_id(ClientOrderId::from("O-1"))
7065            .venue_order_id(VenueOrderId::from("V-1"))
7066            .trade_id(TradeId::from("T-2"))
7067            .order_side(OrderSide::Buy)
7068            .last_qty(Quantity::from(10))
7069            .last_px(Price::from("1.00000"))
7070            .currency(Currency::USD())
7071            .position_id(position_id)
7072            .ts_event(UnixNanos::from(3))
7073            .build();
7074        let mut position = Position::new(&instrument, fill1);
7075        position.apply(&fill2);
7076        position.apply(&fill3);
7077
7078        assert_eq!(
7079            position.client_order_ids(),
7080            vec![ClientOrderId::from("O-1"), ClientOrderId::from("O-2")]
7081        );
7082        assert_eq!(
7083            position.venue_order_ids(),
7084            vec![VenueOrderId::from("V-1"), VenueOrderId::from("V-2")]
7085        );
7086        assert_eq!(
7087            position.trade_ids(),
7088            vec![
7089                TradeId::from("T-1"),
7090                TradeId::from("T-2"),
7091                TradeId::from("T-3")
7092            ]
7093        );
7094    }
7095
7096    #[rstest]
7097    fn test_is_open_and_is_closed_require_both_conditions(audusd_sim: CurrencyPair) {
7098        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
7099        let position_id = PositionId::from("P-PREDICATES");
7100        let fill_open = OrderFilledSpec::builder()
7101            .instrument_id(instrument.id())
7102            .client_order_id(ClientOrderId::from("O-1"))
7103            .trade_id(TradeId::from("T-1"))
7104            .order_side(OrderSide::Buy)
7105            .last_qty(Quantity::from(10))
7106            .last_px(Price::from("1.00000"))
7107            .currency(Currency::USD())
7108            .position_id(position_id)
7109            .ts_event(UnixNanos::from(1))
7110            .build();
7111        let fill_close = OrderFilledSpec::builder()
7112            .instrument_id(instrument.id())
7113            .client_order_id(ClientOrderId::from("O-2"))
7114            .trade_id(TradeId::from("T-2"))
7115            .order_side(OrderSide::Sell)
7116            .last_qty(Quantity::from(10))
7117            .last_px(Price::from("1.00000"))
7118            .currency(Currency::USD())
7119            .position_id(position_id)
7120            .ts_event(UnixNanos::from(2))
7121            .build();
7122        let mut position = Position::new(&instrument, fill_open);
7123        position.apply(&fill_close);
7124
7125        assert!(position.is_closed());
7126        assert!(!position.is_open());
7127
7128        position.apply_adjustment(PositionAdjusted::new(
7129            position.trader_id,
7130            position.strategy_id,
7131            position.instrument_id,
7132            position.id,
7133            position.account_id,
7134            PositionAdjustmentType::Funding,
7135            Some(dec!(5)),
7136            None,
7137            None,
7138            uuid4(),
7139            UnixNanos::from(3),
7140            UnixNanos::from(3),
7141        ));
7142
7143        assert_eq!(position.side, PositionSide::Long);
7144        assert_eq!(position.ts_closed, Some(UnixNanos::from(2)));
7145        assert!(!position.is_open());
7146        assert!(!position.is_closed());
7147    }
7148
7149    #[rstest]
7150    fn test_voided_fill_rebate_leaves_no_residual_commission(audusd_sim: CurrencyPair) {
7151        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
7152        let position_id = PositionId::from("P-VOID-REBATE");
7153        let fill1 = OrderFilledSpec::builder()
7154            .instrument_id(instrument.id())
7155            .client_order_id(ClientOrderId::from("O-1"))
7156            .trade_id(TradeId::from("T-1"))
7157            .order_side(OrderSide::Buy)
7158            .last_qty(Quantity::from(10))
7159            .last_px(Price::from("1.00000"))
7160            .currency(Currency::USD())
7161            .position_id(position_id)
7162            .ts_event(UnixNanos::from(1))
7163            .build();
7164        let rebate = Money::from("-2.00 USD");
7165        let fill2 = OrderFilledSpec::builder()
7166            .instrument_id(instrument.id())
7167            .client_order_id(ClientOrderId::from("O-2"))
7168            .trade_id(TradeId::from("T-2"))
7169            .order_side(OrderSide::Buy)
7170            .last_qty(Quantity::from(10))
7171            .last_px(Price::from("1.00000"))
7172            .currency(Currency::USD())
7173            .commission(rebate)
7174            .position_id(position_id)
7175            .ts_event(UnixNanos::from(2))
7176            .build();
7177        let void2 = matching_fill_void(&fill2, Quantity::from(10), Some(rebate));
7178        let mut position = Position::new(&instrument, fill1);
7179        position.apply(&fill2);
7180
7181        position
7182            .apply_fill_void(void2, Quantity::from(10), Some(rebate))
7183            .unwrap();
7184
7185        assert_eq!(position.quantity, Quantity::from(10));
7186        assert!(position.commissions().is_empty());
7187        assert_eq!(position.realized_pnl, Some(Money::from("0.00 USD")));
7188    }
7189
7190    #[rstest]
7191    fn test_voided_fill_commission_accumulates_into_realized_pnl(audusd_sim: CurrencyPair) {
7192        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
7193        let position_id = PositionId::from("P-SURVIVING-QUOTE");
7194        let fill1 = OrderFilledSpec::builder()
7195            .instrument_id(instrument.id())
7196            .client_order_id(ClientOrderId::from("O-1"))
7197            .trade_id(TradeId::from("T-1"))
7198            .order_side(OrderSide::Buy)
7199            .last_qty(Quantity::from(10))
7200            .last_px(Price::from("1.00000"))
7201            .currency(Currency::USD())
7202            .commission(Money::from("2.00 USD"))
7203            .position_id(position_id)
7204            .ts_event(UnixNanos::from(1))
7205            .build();
7206        let fill2 = OrderFilledSpec::builder()
7207            .instrument_id(instrument.id())
7208            .client_order_id(ClientOrderId::from("O-2"))
7209            .trade_id(TradeId::from("T-2"))
7210            .order_side(OrderSide::Buy)
7211            .last_qty(Quantity::from(10))
7212            .last_px(Price::from("2.00000"))
7213            .currency(Currency::USD())
7214            .commission(Money::from("1.00 USD"))
7215            .position_id(position_id)
7216            .ts_event(UnixNanos::from(2))
7217            .build();
7218        let void2 = matching_fill_void(&fill2, Quantity::from(10), None);
7219        let mut position = Position::new(&instrument, fill1);
7220        position.apply(&fill2);
7221
7222        position
7223            .apply_fill_void(void2, Quantity::from(10), None)
7224            .unwrap();
7225
7226        assert_eq!(position.quantity, Quantity::from(10));
7227        assert_eq!(position.commissions(), vec![Money::from("3.00 USD")]);
7228        assert_eq!(position.realized_pnl, Some(Money::from("-3.00 USD")));
7229    }
7230
7231    #[rstest]
7232    fn test_voided_fill_base_commission_preserves_avg_px_open(audusd_sim: CurrencyPair) {
7233        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
7234        let base_currency = instrument.base_currency().unwrap();
7235        let position_id = PositionId::from("P-SURVIVING-BASE");
7236        let fill1 = OrderFilledSpec::builder()
7237            .instrument_id(instrument.id())
7238            .client_order_id(ClientOrderId::from("O-1"))
7239            .trade_id(TradeId::from("T-1"))
7240            .order_side(OrderSide::Buy)
7241            .last_qty(Quantity::from(10))
7242            .last_px(Price::from("1.00000"))
7243            .currency(Currency::USD())
7244            .commission(Money::new(2.0, base_currency))
7245            .position_id(position_id)
7246            .ts_event(UnixNanos::from(1))
7247            .build();
7248        let fill2 = OrderFilledSpec::builder()
7249            .instrument_id(instrument.id())
7250            .client_order_id(ClientOrderId::from("O-2"))
7251            .trade_id(TradeId::from("T-2"))
7252            .order_side(OrderSide::Buy)
7253            .last_qty(Quantity::from(10))
7254            .last_px(Price::from("2.00000"))
7255            .currency(Currency::USD())
7256            .commission(Money::new(1.0, base_currency))
7257            .position_id(position_id)
7258            .ts_event(UnixNanos::from(2))
7259            .build();
7260        let void2 = matching_fill_void(&fill2, Quantity::from(10), None);
7261        let mut position = Position::new(&instrument, fill1);
7262        position.apply(&fill2);
7263
7264        position
7265            .apply_fill_void(void2, Quantity::from(10), None)
7266            .unwrap();
7267
7268        assert_eq!(position.quantity, Quantity::from(7));
7269        assert_eq!(position.avg_px_open, 1.0);
7270        assert_eq!(position.commissions(), vec![Money::new(3.0, base_currency)]);
7271    }
7272
7273    #[rstest]
7274    fn test_fill_fragments_requires_matching_order_and_trade(audusd_sim: CurrencyPair) {
7275        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
7276        let position_id = PositionId::from("P-FRAGMENTS");
7277        let fill1 = OrderFilledSpec::builder()
7278            .instrument_id(instrument.id())
7279            .client_order_id(ClientOrderId::from("O-1"))
7280            .trade_id(TradeId::from("T-1"))
7281            .order_side(OrderSide::Buy)
7282            .last_qty(Quantity::from(10))
7283            .last_px(Price::from("1.00000"))
7284            .currency(Currency::USD())
7285            .position_id(position_id)
7286            .ts_event(UnixNanos::from(1))
7287            .build();
7288        let fill2 = OrderFilledSpec::builder()
7289            .instrument_id(instrument.id())
7290            .client_order_id(ClientOrderId::from("O-1"))
7291            .trade_id(TradeId::from("T-2"))
7292            .order_side(OrderSide::Buy)
7293            .last_qty(Quantity::from(10))
7294            .last_px(Price::from("1.00000"))
7295            .currency(Currency::USD())
7296            .position_id(position_id)
7297            .ts_event(UnixNanos::from(2))
7298            .build();
7299        let mut position = Position::new(&instrument, fill1);
7300        position.apply(&fill2);
7301
7302        let fragments = position.fill_fragments(ClientOrderId::from("O-1"), TradeId::from("T-1"));
7303
7304        assert_eq!(fragments.len(), 1);
7305        assert_eq!(fragments[0].trade_id, TradeId::from("T-1"));
7306        assert!(
7307            position
7308                .fill_fragments(ClientOrderId::from("O-2"), TradeId::from("T-1"))
7309                .is_empty()
7310        );
7311    }
7312
7313    #[rstest]
7314    fn test_apply_fill_void_scopes_staleness_check_to_same_trade(audusd_sim: CurrencyPair) {
7315        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
7316        let position_id = PositionId::from("P-VOID-TRADES");
7317        let fill1 = OrderFilledSpec::builder()
7318            .instrument_id(instrument.id())
7319            .client_order_id(ClientOrderId::from("O-1"))
7320            .trade_id(TradeId::from("T-1"))
7321            .order_side(OrderSide::Buy)
7322            .last_qty(Quantity::from(10))
7323            .last_px(Price::from("1.00000"))
7324            .currency(Currency::USD())
7325            .position_id(position_id)
7326            .ts_event(UnixNanos::from(1))
7327            .build();
7328        let fill2 = OrderFilledSpec::builder()
7329            .instrument_id(instrument.id())
7330            .client_order_id(ClientOrderId::from("O-1"))
7331            .trade_id(TradeId::from("T-2"))
7332            .order_side(OrderSide::Buy)
7333            .last_qty(Quantity::from(10))
7334            .last_px(Price::from("1.00000"))
7335            .currency(Currency::USD())
7336            .position_id(position_id)
7337            .ts_event(UnixNanos::from(2))
7338            .build();
7339        let void1 = matching_fill_void(&fill1, Quantity::from(6), None);
7340        let void2 = matching_fill_void(&fill2, Quantity::from(2), None);
7341        let mut position = Position::new(&instrument, fill1);
7342        position.apply(&fill2);
7343        position
7344            .apply_fill_void(void1, Quantity::from(6), None)
7345            .unwrap();
7346
7347        position
7348            .apply_fill_void(void2, Quantity::from(2), None)
7349            .unwrap();
7350
7351        assert_eq!(position.quantity, Quantity::from(12));
7352        assert_eq!(position.fill_voids.len(), 2);
7353    }
7354
7355    #[rstest]
7356    fn test_apply_fill_void_removes_quantity_from_voided_trade_only(audusd_sim: CurrencyPair) {
7357        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
7358        let position_id = PositionId::from("P-VOID-ATTRIBUTION");
7359        let fill1 = OrderFilledSpec::builder()
7360            .instrument_id(instrument.id())
7361            .client_order_id(ClientOrderId::from("O-1"))
7362            .trade_id(TradeId::from("T-1"))
7363            .order_side(OrderSide::Buy)
7364            .last_qty(Quantity::from(10))
7365            .last_px(Price::from("1.00000"))
7366            .currency(Currency::USD())
7367            .position_id(position_id)
7368            .ts_event(UnixNanos::from(1))
7369            .build();
7370        let fill2 = OrderFilledSpec::builder()
7371            .instrument_id(instrument.id())
7372            .client_order_id(ClientOrderId::from("O-1"))
7373            .trade_id(TradeId::from("T-2"))
7374            .order_side(OrderSide::Buy)
7375            .last_qty(Quantity::from(10))
7376            .last_px(Price::from("2.00000"))
7377            .currency(Currency::USD())
7378            .position_id(position_id)
7379            .ts_event(UnixNanos::from(2))
7380            .build();
7381        let void1 = matching_fill_void(&fill1, Quantity::from(4), None);
7382        let mut position = Position::new(&instrument, fill1);
7383        position.apply(&fill2);
7384
7385        position
7386            .apply_fill_void(void1, Quantity::from(4), None)
7387            .unwrap();
7388
7389        assert_eq!(position.quantity, Quantity::from(16));
7390        assert_eq!(position.avg_px_open, 1.625);
7391    }
7392
7393    #[rstest]
7394    fn test_purge_events_for_order_retains_other_order_fill_voids(audusd_sim: CurrencyPair) {
7395        let instrument = InstrumentAny::CurrencyPair(audusd_sim);
7396        let position_id = PositionId::from("P-PURGE-VOID");
7397        let fill1 = OrderFilledSpec::builder()
7398            .instrument_id(instrument.id())
7399            .client_order_id(ClientOrderId::from("O-1"))
7400            .trade_id(TradeId::from("T-1"))
7401            .order_side(OrderSide::Buy)
7402            .last_qty(Quantity::from(10))
7403            .last_px(Price::from("1.00000"))
7404            .currency(Currency::USD())
7405            .position_id(position_id)
7406            .ts_event(UnixNanos::from(1))
7407            .build();
7408        let fill2 = OrderFilledSpec::builder()
7409            .instrument_id(instrument.id())
7410            .client_order_id(ClientOrderId::from("O-2"))
7411            .trade_id(TradeId::from("T-2"))
7412            .order_side(OrderSide::Buy)
7413            .last_qty(Quantity::from(10))
7414            .last_px(Price::from("1.00000"))
7415            .currency(Currency::USD())
7416            .position_id(position_id)
7417            .ts_event(UnixNanos::from(2))
7418            .build();
7419        let void1 = matching_fill_void(&fill1, Quantity::from(2), None);
7420        let void2 = matching_fill_void(&fill2, Quantity::from(3), None);
7421        let mut position = Position::new(&instrument, fill1);
7422        position.apply(&fill2);
7423        position
7424            .apply_fill_void(void1, Quantity::from(2), None)
7425            .unwrap();
7426        position
7427            .apply_fill_void(void2, Quantity::from(3), None)
7428            .unwrap();
7429
7430        assert_eq!(position.fill_voids.len(), 2);
7431
7432        position.purge_events_for_order(ClientOrderId::from("O-1"));
7433
7434        assert_eq!(position.fill_voids.len(), 1);
7435        assert_eq!(
7436            position.fill_voids[0].event.client_order_id,
7437            ClientOrderId::from("O-2")
7438        );
7439        assert_eq!(position.fill_voids[0].voided_qty, Quantity::from(3));
7440    }
7441
7442    #[rstest]
7443    fn test_purge_events_for_order_clears_adjustments_when_flat() {
7444        // Test that purging all fills clears adjustment history
7445        let btc_usdt = currency_pair_btcusdt();
7446        let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
7447
7448        let order = OrderTestBuilder::new(OrderType::Market)
7449            .instrument_id(btc_usdt.id())
7450            .side(OrderSide::Buy)
7451            .quantity(Quantity::from("1.0"))
7452            .build();
7453
7454        let fill = TestOrderEventStubs::filled(
7455            &order,
7456            &btc_usdt,
7457            Some(TradeId::new("1")),
7458            None,
7459            Some(Price::from("50000.0")),
7460            Some(Quantity::from("1.0")),
7461            None,
7462            Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
7463            None,
7464            None,
7465        );
7466
7467        let mut position = Position::new(&btc_usdt, fill.into());
7468        assert_eq!(position.adjustments.len(), 1, "Should have 1 adjustment");
7469        assert_eq!(position.events.len(), 1);
7470
7471        // Purge the only fill - should go to flat and clear everything
7472        position.purge_events_for_order(order.client_order_id());
7473
7474        assert_eq!(position.side, PositionSide::Flat);
7475        assert_eq!(position.events.len(), 0, "Events should be cleared");
7476        assert_eq!(
7477            position.adjustments.len(),
7478            0,
7479            "Adjustments should be cleared when position goes flat"
7480        );
7481        assert_eq!(position.quantity, Quantity::zero(btc_usdt.size_precision()));
7482    }
7483
7484    #[rstest]
7485    fn test_purge_events_for_order_clears_adjustments_on_rebuild() {
7486        // Test that rebuilding position from remaining fills clears and recreates adjustments
7487        let btc_usdt = currency_pair_btcusdt();
7488        let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
7489
7490        // First fill with adjustment
7491        let order1 = OrderTestBuilder::new(OrderType::Market)
7492            .instrument_id(btc_usdt.id())
7493            .side(OrderSide::Buy)
7494            .quantity(Quantity::from("1.0"))
7495            .client_order_id(ClientOrderId::new("O-001"))
7496            .build();
7497
7498        let fill1 = TestOrderEventStubs::filled(
7499            &order1,
7500            &btc_usdt,
7501            Some(TradeId::new("1")),
7502            None,
7503            Some(Price::from("50000.0")),
7504            Some(Quantity::from("1.0")),
7505            None,
7506            Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
7507            None,
7508            None,
7509        );
7510
7511        let mut position = Position::new(&btc_usdt, fill1.into());
7512        assert_eq!(position.adjustments.len(), 1);
7513
7514        // Second fill with different order and adjustment
7515        let order2 = OrderTestBuilder::new(OrderType::Market)
7516            .instrument_id(btc_usdt.id())
7517            .side(OrderSide::Buy)
7518            .quantity(Quantity::from("2.0"))
7519            .client_order_id(ClientOrderId::new("O-002"))
7520            .build();
7521
7522        let fill2 = TestOrderEventStubs::filled(
7523            &order2,
7524            &btc_usdt,
7525            Some(TradeId::new("2")),
7526            None,
7527            Some(Price::from("51000.0")),
7528            Some(Quantity::from("2.0")),
7529            None,
7530            Some(Money::new(0.002, btc_usdt.base_currency().unwrap())),
7531            None,
7532            None,
7533        );
7534
7535        position.apply(&fill2.into());
7536        assert_eq!(position.adjustments.len(), 2, "Should have 2 adjustments");
7537        assert_eq!(position.events.len(), 2);
7538
7539        // Purge first order - should rebuild from remaining fill
7540        position.purge_events_for_order(order1.client_order_id());
7541
7542        assert_eq!(position.events.len(), 1, "Should have 1 remaining event");
7543        assert_eq!(
7544            position.adjustments.len(),
7545            1,
7546            "Should have only the adjustment from remaining fill"
7547        );
7548        assert_eq!(
7549            position.adjustments[0].quantity_change,
7550            Some(rust_decimal_macros::dec!(-0.002)),
7551            "Should be the adjustment from order2"
7552        );
7553        assert!(
7554            (position.quantity.as_f64() - 1.998).abs() < 1e-9,
7555            "Quantity should be 2.0 - 0.002 commission"
7556        );
7557    }
7558
7559    fn btcusdt_fill(trade_id: &str, client_order_id: &str, quantity: &str) -> OrderFilled {
7560        let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
7561        let order = OrderTestBuilder::new(OrderType::Market)
7562            .instrument_id(instrument.id())
7563            .side(OrderSide::Buy)
7564            .quantity(Quantity::from(quantity))
7565            .client_order_id(ClientOrderId::new(client_order_id))
7566            .build();
7567
7568        TestOrderEventStubs::filled(
7569            &order,
7570            &instrument,
7571            Some(TradeId::new(trade_id)),
7572            None,
7573            Some(Price::from("50000.0")),
7574            Some(Quantity::from(quantity)),
7575            None,
7576            Some(Money::new(1.0, instrument.quote_currency())),
7577            None,
7578            None,
7579        )
7580        .into()
7581    }
7582
7583    fn funding_payment(position: &Position, pnl: f64) -> PositionAdjusted {
7584        PositionAdjusted::new(
7585            position.trader_id,
7586            position.strategy_id,
7587            position.instrument_id,
7588            position.id,
7589            position.account_id,
7590            PositionAdjustmentType::Funding,
7591            None,
7592            Some(Money::new(pnl, position.settlement_currency)),
7593            None,
7594            uuid4(),
7595            UnixNanos::from(5),
7596            UnixNanos::from(5),
7597        )
7598    }
7599
7600    fn btcusdt_position() -> Position {
7601        let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
7602        Position::new(&instrument, btcusdt_fill("1", "O-001", "1.0"))
7603    }
7604
7605    #[rstest]
7606    fn test_revert_last_adjustment_restores_prior_state() {
7607        let mut position = btcusdt_position();
7608        let before = serde_json::to_value(&position).unwrap();
7609        let prior = position.clone_without_events();
7610        position.apply_adjustment(funding_payment(&position, -10.0));
7611
7612        position.revert_last_adjustment(&prior).unwrap();
7613
7614        assert_eq!(serde_json::to_value(&position).unwrap(), before);
7615    }
7616
7617    fn assert_reverts_to_snapshot(mut position: Position, payment: PositionAdjusted) {
7618        let before = serde_json::to_value(&position).unwrap();
7619        let prior = position.clone_without_events();
7620        position.apply_adjustment(payment);
7621
7622        position.revert_last_adjustment(&prior).unwrap();
7623
7624        assert_eq!(serde_json::to_value(&position).unwrap(), before);
7625    }
7626
7627    #[rstest]
7628    fn test_revert_last_adjustment_after_earlier_adjustment() {
7629        let mut position = btcusdt_position();
7630        position.apply_adjustment(funding_payment(&position, -10.0));
7631        let mut payment = funding_payment(&position, -5.0);
7632        payment.ts_event = UnixNanos::from(6);
7633
7634        assert_reverts_to_snapshot(position, payment);
7635    }
7636
7637    #[rstest]
7638    fn test_revert_last_adjustment_after_zero_value_adjustment_at_same_time() {
7639        let mut position = btcusdt_position();
7640        let prior = position.clone_without_events();
7641        let mut zero_payment = funding_payment(&position, 0.0);
7642        zero_payment.ts_event = position.ts_last;
7643        position.apply_adjustment(zero_payment);
7644        let before = serde_json::to_value(&position).unwrap();
7645        position.apply_adjustment(funding_payment(&position, -5.0));
7646
7647        // The zero value payment at the snapshot's time leaves the snapshot's state unchanged
7648        position.revert_last_adjustment(&prior).unwrap();
7649
7650        assert_eq!(serde_json::to_value(&position).unwrap(), before);
7651    }
7652
7653    #[rstest]
7654    #[case::partial("0.2", None)]
7655    #[case::full("0.5", Some(1.0))]
7656    fn test_revert_last_adjustment_after_voided_fill(
7657        #[case] voided_qty: &str,
7658        #[case] commission_voided: Option<f64>,
7659    ) {
7660        let mut position = btcusdt_position();
7661        let mut fill = btcusdt_fill("2", "O-002", "0.5");
7662        fill.ts_event = UnixNanos::from(3);
7663        position.apply(&fill);
7664        let commission_voided =
7665            commission_voided.map(|amount| Money::new(amount, position.settlement_currency));
7666        position
7667            .apply_fill_void(
7668                matching_fill_void(&fill, Quantity::from(voided_qty), commission_voided),
7669                Quantity::from(voided_qty),
7670                commission_voided,
7671            )
7672            .unwrap();
7673        let payment = funding_payment(&position, -5.0);
7674
7675        assert_reverts_to_snapshot(position, payment);
7676    }
7677
7678    #[rstest]
7679    fn test_revert_last_adjustment_after_purge() {
7680        let mut position = btcusdt_position();
7681        let mut fill = btcusdt_fill("2", "O-002", "0.5");
7682        fill.ts_event = UnixNanos::from(3);
7683        position.apply(&fill);
7684        position.apply_adjustment(funding_payment(&position, -10.0));
7685        position.purge_events_for_order(ClientOrderId::new("O-002"));
7686        let mut payment = funding_payment(&position, -5.0);
7687        payment.ts_event = UnixNanos::from(6);
7688
7689        assert_reverts_to_snapshot(position, payment);
7690    }
7691
7692    #[rstest]
7693    fn test_revert_last_adjustment_after_reopening() {
7694        let mut position = btcusdt_position();
7695        let mut close = btcusdt_fill("2", "O-002", "1.0");
7696        close.order_side = OrderSide::Sell;
7697        close.ts_event = UnixNanos::from(2);
7698        position.apply(&close);
7699        let mut reopen = btcusdt_fill("3", "O-003", "0.5");
7700        reopen.ts_event = UnixNanos::from(3);
7701        position.apply(&reopen);
7702        let payment = funding_payment(&position, -5.0);
7703
7704        assert_reverts_to_snapshot(position, payment);
7705    }
7706
7707    #[rstest]
7708    fn test_revert_last_adjustment_rejects_snapshot_of_another_position() {
7709        let mut position = btcusdt_position();
7710        let mut prior = position.clone_without_events();
7711        prior.id = PositionId::new("P-OTHER");
7712        position.apply_adjustment(funding_payment(&position, -10.0));
7713        let after = serde_json::to_value(&position).unwrap();
7714
7715        let error = position.revert_last_adjustment(&prior).unwrap_err();
7716
7717        assert!(error.to_string().contains("prior.id"), "{error}");
7718        assert_eq!(serde_json::to_value(&position).unwrap(), after);
7719    }
7720
7721    #[rstest]
7722    fn test_revert_last_adjustment_rejects_after_later_fill() {
7723        let mut position = btcusdt_position();
7724        let prior = position.clone_without_events();
7725        position.apply_adjustment(funding_payment(&position, -10.0));
7726        position.apply(&btcusdt_fill("2", "O-002", "0.5"));
7727        let after = serde_json::to_value(&position).unwrap();
7728
7729        let error = position.revert_last_adjustment(&prior).unwrap_err();
7730
7731        assert!(
7732            error.to_string().contains("not its latest adjustment"),
7733            "{error}"
7734        );
7735        assert_eq!(serde_json::to_value(&position).unwrap(), after);
7736    }
7737
7738    #[rstest]
7739    fn test_revert_last_adjustment_rejects_stale_snapshot() {
7740        let mut position = btcusdt_position();
7741        let prior = position.clone_without_events();
7742        position.apply_adjustment(funding_payment(&position, -10.0));
7743        position.apply_adjustment(funding_payment(&position, -5.0));
7744        let after = serde_json::to_value(&position).unwrap();
7745
7746        let error = position.revert_last_adjustment(&prior).unwrap_err();
7747
7748        assert!(error.to_string().contains("predates adjustment"), "{error}");
7749        assert_eq!(serde_json::to_value(&position).unwrap(), after);
7750    }
7751
7752    #[rstest]
7753    fn test_revert_last_adjustment_rejects_snapshot_before_zero_value_adjustment() {
7754        let mut position = btcusdt_position();
7755        let prior = position.clone_without_events();
7756        position.apply_adjustment(funding_payment(&position, 0.0));
7757        let mut later_payment = funding_payment(&position, -5.0);
7758        later_payment.ts_event = UnixNanos::from(6);
7759        later_payment.ts_init = UnixNanos::from(6);
7760        position.apply_adjustment(later_payment);
7761        let after = serde_json::to_value(&position).unwrap();
7762
7763        let error = position.revert_last_adjustment(&prior).unwrap_err();
7764
7765        assert!(error.to_string().contains("predates adjustment"), "{error}");
7766        assert_eq!(serde_json::to_value(&position).unwrap(), after);
7767    }
7768
7769    #[rstest]
7770    fn test_revert_last_adjustment_rejects_snapshot_before_zero_value_adjustment_and_voided_fill() {
7771        let mut position = btcusdt_position();
7772        let prior = position.clone_without_events();
7773        position.apply_adjustment(funding_payment(&position, 0.0));
7774        let mut fill = btcusdt_fill("2", "O-002", "0.5");
7775        fill.ts_event = UnixNanos::from(6);
7776        position.apply(&fill);
7777        let commission = Money::new(1.0, position.settlement_currency);
7778        position
7779            .apply_fill_void(
7780                matching_fill_void(&fill, Quantity::from("0.5"), Some(commission)),
7781                Quantity::from("0.5"),
7782                Some(commission),
7783            )
7784            .unwrap();
7785        let mut later_payment = funding_payment(&position, -5.0);
7786        later_payment.ts_event = UnixNanos::from(7);
7787        position.apply_adjustment(later_payment);
7788        let after = serde_json::to_value(&position).unwrap();
7789
7790        let error = position.revert_last_adjustment(&prior).unwrap_err();
7791
7792        assert!(error.to_string().contains("predates adjustment"), "{error}");
7793        assert_eq!(serde_json::to_value(&position).unwrap(), after);
7794    }
7795
7796    #[rstest]
7797    fn test_revert_last_adjustment_rejects_snapshot_with_pnl_at_money_bounds() {
7798        let mut position = btcusdt_position();
7799        let mut prior = position.clone_without_events();
7800        prior.realized_pnl = Some(Money::from_raw(MONEY_RAW_MAX, position.settlement_currency));
7801        position.apply_adjustment(funding_payment(&position, 10.0));
7802        let after = serde_json::to_value(&position).unwrap();
7803
7804        let error = position.revert_last_adjustment(&prior).unwrap_err();
7805
7806        assert!(
7807            error.to_string().contains("cannot take the PnL change"),
7808            "{error}"
7809        );
7810        assert_eq!(serde_json::to_value(&position).unwrap(), after);
7811    }
7812
7813    #[rstest]
7814    #[case::size_precision("prior.size_precision")]
7815    #[case::settlement_currency("prior.settlement_currency")]
7816    fn test_revert_last_adjustment_rejects_snapshot_of_other_instrument_config(
7817        #[case] field: &str,
7818    ) {
7819        let mut position = btcusdt_position();
7820        let mut prior = position.clone_without_events();
7821        match field {
7822            "prior.size_precision" => prior.size_precision += 1,
7823            _ => prior.settlement_currency = Currency::USD(),
7824        }
7825        position.apply_adjustment(funding_payment(&position, -10.0));
7826        let after = serde_json::to_value(&position).unwrap();
7827
7828        let error = position.revert_last_adjustment(&prior).unwrap_err();
7829
7830        assert!(error.to_string().contains(field), "{error}");
7831        assert_eq!(serde_json::to_value(&position).unwrap(), after);
7832    }
7833
7834    #[rstest]
7835    fn test_revert_last_adjustment_rejects_snapshot_with_other_pnl_currency() {
7836        let mut position = btcusdt_position();
7837        let mut prior = position.clone_without_events();
7838        prior.realized_pnl = Some(Money::new(1.0, Currency::USD()));
7839        position.apply_adjustment(funding_payment(&position, -10.0));
7840        let after = serde_json::to_value(&position).unwrap();
7841
7842        let error = position.revert_last_adjustment(&prior).unwrap_err();
7843
7844        assert!(
7845            error.to_string().contains("cannot take the PnL change"),
7846            "{error}"
7847        );
7848        assert_eq!(serde_json::to_value(&position).unwrap(), after);
7849    }
7850
7851    #[rstest]
7852    fn test_purge_events_preserves_manual_adjustments() {
7853        // Test that manual adjustments (e.g., funding payments) are preserved when purging unrelated fills
7854        let btc_usdt = currency_pair_btcusdt();
7855        let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
7856
7857        // First fill
7858        let order1 = OrderTestBuilder::new(OrderType::Market)
7859            .instrument_id(btc_usdt.id())
7860            .side(OrderSide::Buy)
7861            .quantity(Quantity::from("1.0"))
7862            .client_order_id(ClientOrderId::new("O-001"))
7863            .build();
7864
7865        let fill1 = TestOrderEventStubs::filled(
7866            &order1,
7867            &btc_usdt,
7868            Some(TradeId::new("1")),
7869            None,
7870            Some(Price::from("50000.0")),
7871            Some(Quantity::from("1.0")),
7872            None,
7873            Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
7874            None,
7875            None,
7876        );
7877
7878        let mut position = Position::new(&btc_usdt, fill1.into());
7879        assert_eq!(position.adjustments.len(), 1);
7880
7881        // Apply a manual funding payment adjustment (no reason field)
7882        let funding_adjustment = PositionAdjusted::new(
7883            position.trader_id,
7884            position.strategy_id,
7885            position.instrument_id,
7886            position.id,
7887            position.account_id,
7888            PositionAdjustmentType::Funding,
7889            None,
7890            Some(Money::new(10.0, btc_usdt.quote_currency())),
7891            None, // No reason - this is a manual adjustment
7892            uuid4(),
7893            UnixNanos::default(),
7894            UnixNanos::default(),
7895        );
7896        position.apply_adjustment(funding_adjustment);
7897        assert_eq!(position.adjustments.len(), 2);
7898
7899        // Second fill with different order
7900        let order2 = OrderTestBuilder::new(OrderType::Market)
7901            .instrument_id(btc_usdt.id())
7902            .side(OrderSide::Buy)
7903            .quantity(Quantity::from("2.0"))
7904            .client_order_id(ClientOrderId::new("O-002"))
7905            .build();
7906
7907        let fill2 = TestOrderEventStubs::filled(
7908            &order2,
7909            &btc_usdt,
7910            Some(TradeId::new("2")),
7911            None,
7912            Some(Price::from("51000.0")),
7913            Some(Quantity::from("2.0")),
7914            None,
7915            Some(Money::new(0.002, btc_usdt.base_currency().unwrap())),
7916            None,
7917            None,
7918        );
7919
7920        position.apply(&fill2.into());
7921        assert_eq!(
7922            position.adjustments.len(),
7923            3,
7924            "Should have 3 adjustments: 2 commissions + 1 funding"
7925        );
7926
7927        // Purge first order - manual funding adjustment should be preserved
7928        position.purge_events_for_order(order1.client_order_id());
7929
7930        assert_eq!(position.events.len(), 1, "Should have 1 remaining event");
7931        assert_eq!(
7932            position.adjustments.len(),
7933            2,
7934            "Should have funding adjustment + commission from remaining fill"
7935        );
7936
7937        // Verify funding adjustment is preserved
7938        let has_funding = position.adjustments.iter().any(|adj| {
7939            adj.adjustment_type == PositionAdjustmentType::Funding
7940                && adj.pnl_change == Some(Money::new(10.0, btc_usdt.quote_currency()))
7941        });
7942        assert!(has_funding, "Funding adjustment should be preserved");
7943
7944        // Verify realized_pnl includes the funding payment
7945        // Note: Commission is in BTC (base currency), so it doesn't directly affect USDT realized_pnl
7946        assert_eq!(
7947            position.realized_pnl,
7948            Some(Money::new(10.0, btc_usdt.quote_currency())),
7949            "Realized PnL should be the funding payment only (commission is in BTC, not USDT)"
7950        );
7951    }
7952
7953    #[rstest]
7954    fn test_position_commission_affects_buy_and_sell_qty() {
7955        // Test that commission in base currency affects both buy_qty and sell_qty tracking
7956        let btc_usdt = currency_pair_btcusdt();
7957        let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
7958
7959        let buy_order = OrderTestBuilder::new(OrderType::Market)
7960            .instrument_id(btc_usdt.id())
7961            .side(OrderSide::Buy)
7962            .quantity(Quantity::from("1.0"))
7963            .build();
7964
7965        // Buy 1.0 BTC with 0.001 BTC commission
7966        let fill = TestOrderEventStubs::filled(
7967            &buy_order,
7968            &btc_usdt,
7969            Some(TradeId::new("1")),
7970            None,
7971            Some(Price::from("50000.0")),
7972            Some(Quantity::from("1.0")),
7973            None,
7974            Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
7975            None,
7976            None,
7977        );
7978
7979        let position = Position::new(&btc_usdt, fill.into());
7980
7981        // buy_qty tracks order fills (1.0 BTC), adjustments tracked separately
7982        assert!(
7983            (position.buy_qty.as_f64() - 1.0).abs() < 1e-9,
7984            "buy_qty should be 1.0 (order fill amount), was {}",
7985            position.buy_qty.as_f64()
7986        );
7987
7988        // Position quantity reflects both order fill and commission adjustment
7989        assert!(
7990            (position.quantity.as_f64() - 0.999).abs() < 1e-9,
7991            "position.quantity should be 0.999 (1.0 - 0.001 commission), was {}",
7992            position.quantity.as_f64()
7993        );
7994
7995        // Adjustment event tracks the commission
7996        assert_eq!(position.adjustments.len(), 1);
7997        assert_eq!(
7998            position.adjustments[0].quantity_change,
7999            Some(rust_decimal_macros::dec!(-0.001))
8000        );
8001    }
8002
8003    #[rstest]
8004    fn test_position_perpetual_commission_no_adjustment() {
8005        // Test that perpetuals/futures do NOT adjust quantity for base currency commission
8006        let eth_perp = crypto_perpetual_ethusdt();
8007        let eth_perp = InstrumentAny::CryptoPerpetual(eth_perp);
8008
8009        let order = OrderTestBuilder::new(OrderType::Market)
8010            .instrument_id(eth_perp.id())
8011            .side(OrderSide::Buy)
8012            .quantity(Quantity::from("1.0"))
8013            .build();
8014
8015        // Buy 1.0 ETH-PERP contracts with 0.001 ETH commission
8016        let fill = TestOrderEventStubs::filled(
8017            &order,
8018            &eth_perp,
8019            Some(TradeId::new("1")),
8020            None,
8021            Some(Price::from("3000.0")),
8022            Some(Quantity::from("1.0")),
8023            None,
8024            Some(Money::new(0.001, eth_perp.base_currency().unwrap())),
8025            None,
8026            None,
8027        );
8028
8029        let position = Position::new(&eth_perp, fill.into());
8030
8031        // Position quantity should be exactly 1.0 (NO adjustment for derivatives)
8032        assert!(
8033            (position.quantity.as_f64() - 1.0).abs() < 1e-9,
8034            "Perpetual position should be 1.0 contracts (no adjustment), was {}",
8035            position.quantity.as_f64()
8036        );
8037
8038        // Signed qty should also be 1.0
8039        assert!(
8040            (position.signed_qty - 1.0).abs() < 1e-9,
8041            "Signed qty should be 1.0, was {}",
8042            position.signed_qty
8043        );
8044    }
8045
8046    #[rstest]
8047    fn test_signed_decimal_qty_long(stub_position_long: Position) {
8048        let signed_qty = stub_position_long.signed_decimal_qty();
8049        assert!(signed_qty > Decimal::ZERO);
8050        assert_eq!(
8051            signed_qty,
8052            Decimal::try_from(stub_position_long.signed_qty).unwrap()
8053        );
8054    }
8055
8056    #[rstest]
8057    fn test_signed_decimal_qty_short(stub_position_short: Position) {
8058        let signed_qty = stub_position_short.signed_decimal_qty();
8059        assert!(signed_qty < Decimal::ZERO);
8060        assert_eq!(
8061            signed_qty,
8062            Decimal::try_from(stub_position_short.signed_qty).unwrap()
8063        );
8064    }
8065
8066    #[rstest]
8067    fn test_signed_decimal_qty_flat(audusd_sim: CurrencyPair) {
8068        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
8069        let order = OrderTestBuilder::new(OrderType::Market)
8070            .instrument_id(audusd_sim.id())
8071            .side(OrderSide::Buy)
8072            .quantity(Quantity::from(100_000))
8073            .build();
8074        let fill = TestOrderEventStubs::filled(
8075            &order,
8076            &audusd_sim,
8077            Some(TradeId::new("1")),
8078            None,
8079            Some(Price::from("1.00001")),
8080            None,
8081            None,
8082            None,
8083            None,
8084            None,
8085        );
8086        let mut position = Position::new(&audusd_sim, fill.into());
8087
8088        let close_order = OrderTestBuilder::new(OrderType::Market)
8089            .instrument_id(audusd_sim.id())
8090            .side(OrderSide::Sell)
8091            .quantity(Quantity::from(100_000))
8092            .build();
8093        let close_fill = TestOrderEventStubs::filled(
8094            &close_order,
8095            &audusd_sim,
8096            Some(TradeId::new("2")),
8097            None,
8098            Some(Price::from("1.00002")),
8099            None,
8100            None,
8101            None,
8102            None,
8103            None,
8104        );
8105        position.apply(&close_fill.into());
8106
8107        assert_eq!(position.side, PositionSide::Flat);
8108        assert_eq!(position.signed_decimal_qty(), Decimal::ZERO);
8109    }
8110
8111    #[rstest]
8112    fn test_position_flat_with_floating_point_precision_edge_case() {
8113        // This test verifies that when signed_qty has accumulated floating-point
8114        // errors (tiny non-zero value) but quantity rounds to zero, the position
8115        // correctly becomes FLAT with signed_qty normalized to 0.0
8116        let btc_usdt = currency_pair_btcusdt();
8117        let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
8118
8119        let order1 = OrderTestBuilder::new(OrderType::Market)
8120            .instrument_id(btc_usdt.id())
8121            .side(OrderSide::Buy)
8122            .quantity(Quantity::from("0.123456789"))
8123            .build();
8124        let fill1 = TestOrderEventStubs::filled(
8125            &order1,
8126            &btc_usdt,
8127            Some(TradeId::new("1")),
8128            None,
8129            Some(Price::from("50000.00")),
8130            None,
8131            None,
8132            None,
8133            None,
8134            None,
8135        );
8136        let mut position = Position::new(&btc_usdt, fill1.into());
8137
8138        assert_eq!(position.side, PositionSide::Long);
8139        assert!(position.quantity.is_positive());
8140
8141        let order2 = OrderTestBuilder::new(OrderType::Market)
8142            .instrument_id(btc_usdt.id())
8143            .side(OrderSide::Sell)
8144            .quantity(Quantity::from("0.123456789"))
8145            .build();
8146        let fill2 = TestOrderEventStubs::filled(
8147            &order2,
8148            &btc_usdt,
8149            Some(TradeId::new("2")),
8150            None,
8151            Some(Price::from("50000.00")),
8152            None,
8153            None,
8154            None,
8155            None,
8156            None,
8157        );
8158        position.apply(&fill2.into());
8159
8160        assert_eq!(
8161            position.side,
8162            PositionSide::Flat,
8163            "Position should be FLAT, not {:?}",
8164            position.side
8165        );
8166        assert!(
8167            position.quantity.is_zero(),
8168            "Quantity should be zero, was {}",
8169            position.quantity
8170        );
8171        assert_eq!(
8172            position.signed_qty, 0.0,
8173            "signed_qty should be normalized to 0.0, was {}",
8174            position.signed_qty
8175        );
8176        assert!(position.is_closed());
8177    }
8178
8179    #[rstest]
8180    #[case(OrderSide::Buy, OrderSide::Sell, "162.50", "176.50", 171.5)]
8181    #[case(OrderSide::Sell, OrderSide::Buy, "140.00", "126.00", 131.0)]
8182    fn test_position_exact_close_after_partial_fills_preserves_open_average(
8183        #[case] entry: OrderSide,
8184        #[case] exit: OrderSide,
8185        #[case] first_close_px: &str,
8186        #[case] final_close_px: &str,
8187        #[case] expected_avg_close: f64,
8188    ) {
8189        let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
8190        let position_id = PositionId::new("P-PARTIAL-CLOSE");
8191        let open_order = OrderTestBuilder::new(OrderType::Market)
8192            .instrument_id(instrument.id())
8193            .client_order_id(ClientOrderId::new("O-OPEN"))
8194            .side(entry)
8195            .quantity(Quantity::from("0.7"))
8196            .build();
8197        let open_fill = TestOrderEventStubs::filled(
8198            &open_order,
8199            &instrument,
8200            Some(TradeId::new("T-OPEN")),
8201            Some(position_id),
8202            Some(Price::from("151.25")),
8203            None,
8204            None,
8205            Some(Money::from("0 USDT")),
8206            Some(UnixNanos::from(1_000)),
8207            None,
8208        );
8209        let mut position = Position::new(&instrument, open_fill.into());
8210
8211        for (client_order_id, trade_id, quantity, price, ts_event) in [
8212            ("O-CLOSE-1", "T-CLOSE-1", "0.25", first_close_px, 1_100),
8213            ("O-CLOSE-2", "T-CLOSE-2", "0.45", final_close_px, 1_250),
8214        ] {
8215            let close_order = OrderTestBuilder::new(OrderType::Market)
8216                .instrument_id(instrument.id())
8217                .client_order_id(ClientOrderId::new(client_order_id))
8218                .side(exit)
8219                .quantity(Quantity::from(quantity))
8220                .build();
8221            let close_fill = TestOrderEventStubs::filled(
8222                &close_order,
8223                &instrument,
8224                Some(TradeId::new(trade_id)),
8225                Some(position_id),
8226                Some(Price::from(price)),
8227                None,
8228                None,
8229                Some(Money::from("0 USDT")),
8230                Some(UnixNanos::from(ts_event)),
8231                None,
8232            );
8233            position.apply(&close_fill.into());
8234        }
8235
8236        assert_eq!(position.entry, entry);
8237        assert_eq!(position.side, PositionSide::Flat);
8238        assert_eq!(position.signed_qty, 0.0);
8239        assert_eq!(position.quantity, Quantity::zero(6));
8240        assert_eq!(position.peak_qty, Quantity::from("0.7"));
8241        assert_eq!(position.buy_qty, Quantity::from("0.7"));
8242        assert_eq!(position.sell_qty, Quantity::from("0.7"));
8243        assert_eq!(position.avg_px_open, 151.25);
8244        assert_eq!(position.avg_px_close, Some(expected_avg_close));
8245        assert_eq!(position.realized_return, 0.133_884_297_520_661_17);
8246        assert_eq!(position.realized_pnl, Some(Money::from("14.17500000 USDT")));
8247        assert_eq!(position.commissions(), vec![Money::from("0 USDT")]);
8248        assert_eq!(position.opening_order_id, ClientOrderId::new("O-OPEN"));
8249        assert_eq!(
8250            position.closing_order_id,
8251            Some(ClientOrderId::new("O-CLOSE-2"))
8252        );
8253        assert_eq!(position.ts_opened, UnixNanos::from(1_000));
8254        assert_eq!(position.ts_last, UnixNanos::from(1_250));
8255        assert_eq!(position.ts_closed, Some(UnixNanos::from(1_250)));
8256        assert_eq!(position.duration_ns, DurationNanos::new(250));
8257        assert_eq!(position.event_count(), 3);
8258        assert!(position.is_closed());
8259    }
8260
8261    #[rstest]
8262    #[case(
8263        OrderSide::Buy,
8264        OrderSide::Sell,
8265        "140.00",
8266        "126.00",
8267        PositionSide::Short,
8268        -0.000_001
8269    )]
8270    #[case(
8271        OrderSide::Sell,
8272        OrderSide::Buy,
8273        "162.50",
8274        "176.50",
8275        PositionSide::Long,
8276        0.000_001
8277    )]
8278    fn test_position_true_reversal_uses_fill_price(
8279        #[case] entry: OrderSide,
8280        #[case] exit: OrderSide,
8281        #[case] first_close_px: &str,
8282        #[case] reversal_px: &str,
8283        #[case] expected_side: PositionSide,
8284        #[case] expected_signed_qty: f64,
8285    ) {
8286        let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
8287        let position_id = PositionId::new("P-REVERSAL");
8288        let open_order = OrderTestBuilder::new(OrderType::Market)
8289            .instrument_id(instrument.id())
8290            .client_order_id(ClientOrderId::new("O-REVERSAL-OPEN"))
8291            .side(entry)
8292            .quantity(Quantity::from("0.7"))
8293            .build();
8294        let open_fill = TestOrderEventStubs::filled(
8295            &open_order,
8296            &instrument,
8297            Some(TradeId::new("T-REVERSAL-OPEN")),
8298            Some(position_id),
8299            Some(Price::from("151.25")),
8300            None,
8301            None,
8302            Some(Money::from("0 USDT")),
8303            Some(UnixNanos::from(2_000)),
8304            None,
8305        );
8306        let mut position = Position::new(&instrument, open_fill.into());
8307
8308        let close_order = OrderTestBuilder::new(OrderType::Market)
8309            .instrument_id(instrument.id())
8310            .client_order_id(ClientOrderId::new("O-REVERSAL-CLOSE"))
8311            .side(exit)
8312            .quantity(Quantity::from("0.25"))
8313            .build();
8314        let close_fill = TestOrderEventStubs::filled(
8315            &close_order,
8316            &instrument,
8317            Some(TradeId::new("T-REVERSAL-CLOSE")),
8318            Some(position_id),
8319            Some(Price::from(first_close_px)),
8320            None,
8321            None,
8322            Some(Money::from("0 USDT")),
8323            Some(UnixNanos::from(2_050)),
8324            None,
8325        );
8326        position.apply(&close_fill.into());
8327
8328        let reversal_order = OrderTestBuilder::new(OrderType::Market)
8329            .instrument_id(instrument.id())
8330            .client_order_id(ClientOrderId::new("O-REVERSAL"))
8331            .side(exit)
8332            .quantity(Quantity::from("0.450001"))
8333            .build();
8334        let reversal_fill = TestOrderEventStubs::filled(
8335            &reversal_order,
8336            &instrument,
8337            Some(TradeId::new("T-REVERSAL")),
8338            Some(position_id),
8339            Some(Price::from(reversal_px)),
8340            None,
8341            None,
8342            Some(Money::from("0 USDT")),
8343            Some(UnixNanos::from(2_100)),
8344            None,
8345        );
8346        position.apply(&reversal_fill.into());
8347
8348        assert_eq!(position.entry, exit);
8349        assert_eq!(position.side, expected_side);
8350        assert!((position.signed_qty - expected_signed_qty).abs() < 1e-12);
8351        assert_eq!(position.quantity, Quantity::from("0.000001"));
8352        assert_eq!(position.peak_qty, Quantity::from("0.7"));
8353        assert_eq!(position.avg_px_open, Price::from(reversal_px).as_f64());
8354        assert_eq!(
8355            position.realized_pnl,
8356            Some(Money::from("-14.17500000 USDT"))
8357        );
8358        assert_eq!(position.commissions(), vec![Money::from("0 USDT")]);
8359        assert_eq!(
8360            position.opening_order_id,
8361            ClientOrderId::new("O-REVERSAL-OPEN")
8362        );
8363        assert_eq!(position.closing_order_id, None);
8364        assert_eq!(position.ts_opened, UnixNanos::from(2_000));
8365        assert_eq!(position.ts_last, UnixNanos::from(2_100));
8366        assert_eq!(position.ts_closed, None);
8367        assert_eq!(position.duration_ns, DurationNanos::default());
8368        assert_eq!(position.event_count(), 3);
8369        assert!(position.is_open());
8370    }
8371
8372    #[rstest]
8373    #[case(OrderSide::Buy, OrderSide::Sell)]
8374    #[case(OrderSide::Sell, OrderSide::Buy)]
8375    fn test_position_reversal_starts_new_close_episode(
8376        #[case] entry: OrderSide,
8377        #[case] exit: OrderSide,
8378    ) {
8379        let (
8380            open_px,
8381            first_close_px,
8382            reversal_px,
8383            expected_side,
8384            new_first_close_px,
8385            new_final_close_px,
8386            expected_avg_close,
8387            expected_return,
8388        ) = if entry == OrderSide::Buy {
8389            (
8390                "100.00",
8391                "110.00",
8392                "120.00",
8393                PositionSide::Short,
8394                "110.00",
8395                "90.00",
8396                95.0,
8397                0.208_333_333_333_333_34,
8398            )
8399        } else {
8400            (
8401                "120.00",
8402                "110.00",
8403                "100.00",
8404                PositionSide::Long,
8405                "110.00",
8406                "130.00",
8407                125.0,
8408                0.25,
8409            )
8410        };
8411        let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
8412        let position_id = PositionId::new("P-REVERSAL-EPISODE");
8413        let open_order = OrderTestBuilder::new(OrderType::Market)
8414            .instrument_id(instrument.id())
8415            .client_order_id(ClientOrderId::new("O-OPEN"))
8416            .side(entry)
8417            .quantity(Quantity::from("10"))
8418            .build();
8419        let open_fill = TestOrderEventStubs::filled(
8420            &open_order,
8421            &instrument,
8422            Some(TradeId::new("T-OPEN")),
8423            Some(position_id),
8424            Some(Price::from(open_px)),
8425            None,
8426            None,
8427            Some(Money::from("1 USDT")),
8428            Some(UnixNanos::from(3_000)),
8429            None,
8430        );
8431        let mut position = Position::new(&instrument, open_fill.into());
8432
8433        for (client_order_id, trade_id, quantity, price, ts_event) in [
8434            ("O-CLOSE", "T-CLOSE", "4", first_close_px, 3_100),
8435            ("O-REVERSE", "T-REVERSE", "8", reversal_px, 3_200),
8436        ] {
8437            let order = OrderTestBuilder::new(OrderType::Market)
8438                .instrument_id(instrument.id())
8439                .client_order_id(ClientOrderId::new(client_order_id))
8440                .side(exit)
8441                .quantity(Quantity::from(quantity))
8442                .build();
8443            let fill = TestOrderEventStubs::filled(
8444                &order,
8445                &instrument,
8446                Some(TradeId::new(trade_id)),
8447                Some(position_id),
8448                Some(Price::from(price)),
8449                None,
8450                None,
8451                Some(Money::from("1 USDT")),
8452                Some(UnixNanos::from(ts_event)),
8453                None,
8454            );
8455            position.apply(&fill.into());
8456        }
8457
8458        assert_eq!(position.side, expected_side);
8459        assert_eq!(position.quantity, Quantity::from("2"));
8460        assert_eq!(position.avg_px_open, Price::from(reversal_px).as_f64());
8461        assert_eq!(position.avg_px_close, None);
8462        assert_eq!(position.realized_return, 0.0);
8463        if expected_side == PositionSide::Long {
8464            assert_eq!(position.buy_qty, Quantity::from("2"));
8465            assert_eq!(position.sell_qty, Quantity::from("0"));
8466        } else {
8467            assert_eq!(position.buy_qty, Quantity::from("0"));
8468            assert_eq!(position.sell_qty, Quantity::from("2"));
8469        }
8470        assert_eq!(position.realized_pnl, Some(Money::from("157 USDT")));
8471
8472        for (client_order_id, trade_id, quantity, price, ts_event) in [
8473            (
8474                "O-NEW-CLOSE-1",
8475                "T-NEW-CLOSE-1",
8476                "0.5",
8477                new_first_close_px,
8478                3_300,
8479            ),
8480            (
8481                "O-NEW-CLOSE-2",
8482                "T-NEW-CLOSE-2",
8483                "1.5",
8484                new_final_close_px,
8485                3_400,
8486            ),
8487        ] {
8488            let order = OrderTestBuilder::new(OrderType::Market)
8489                .instrument_id(instrument.id())
8490                .client_order_id(ClientOrderId::new(client_order_id))
8491                .side(entry)
8492                .quantity(Quantity::from(quantity))
8493                .build();
8494            let fill = TestOrderEventStubs::filled(
8495                &order,
8496                &instrument,
8497                Some(TradeId::new(trade_id)),
8498                Some(position_id),
8499                Some(Price::from(price)),
8500                None,
8501                None,
8502                Some(Money::from("1 USDT")),
8503                Some(UnixNanos::from(ts_event)),
8504                None,
8505            );
8506            position.apply(&fill.into());
8507        }
8508
8509        assert_eq!(position.side, PositionSide::Flat);
8510        assert_eq!(position.quantity, Quantity::zero(6));
8511        assert_eq!(position.buy_qty, Quantity::from("2"));
8512        assert_eq!(position.sell_qty, Quantity::from("2"));
8513        assert_eq!(position.avg_px_close, Some(expected_avg_close));
8514        assert_eq!(position.realized_return, expected_return);
8515        assert_eq!(position.realized_pnl, Some(Money::from("205 USDT")));
8516        assert_eq!(position.commissions(), vec![Money::from("5 USDT")]);
8517    }
8518
8519    #[rstest]
8520    fn test_position_adjustment_floating_point_precision_edge_case() {
8521        // Test that apply_adjustment handles precision edge cases correctly
8522        let btc_usdt = currency_pair_btcusdt();
8523        let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
8524
8525        let order = OrderTestBuilder::new(OrderType::Market)
8526            .instrument_id(btc_usdt.id())
8527            .side(OrderSide::Buy)
8528            .quantity(Quantity::from("1.0"))
8529            .build();
8530        let fill = TestOrderEventStubs::filled(
8531            &order,
8532            &btc_usdt,
8533            Some(TradeId::new("1")),
8534            None,
8535            Some(Price::from("50000.00")),
8536            None,
8537            None,
8538            None,
8539            None,
8540            None,
8541        );
8542        let mut position = Position::new(&btc_usdt, fill.into());
8543
8544        let adjustment = PositionAdjusted::new(
8545            position.trader_id,
8546            position.strategy_id,
8547            position.instrument_id,
8548            position.id,
8549            position.account_id,
8550            PositionAdjustmentType::Commission,
8551            Some(Decimal::from_str("-1.0").unwrap()),
8552            None,
8553            None,
8554            uuid4(),
8555            UnixNanos::default(),
8556            UnixNanos::default(),
8557        );
8558        position.apply_adjustment(adjustment);
8559
8560        assert_eq!(
8561            position.side,
8562            PositionSide::Flat,
8563            "Position should be FLAT after zeroing adjustment"
8564        );
8565        assert!(
8566            position.quantity.is_zero(),
8567            "Quantity should be zero after adjustment"
8568        );
8569        assert_eq!(
8570            position.signed_qty, 0.0,
8571            "signed_qty should be normalized to 0.0"
8572        );
8573    }
8574
8575    #[rstest]
8576    fn test_position_spot_buy_partial_fills_with_base_commission() {
8577        // Reproduce GitHub issue #3546: partial fills with base currency commission
8578        // should reduce position quantity, not increase it
8579        let eth_usdt = currency_pair_ethusdt();
8580        let eth_usdt = InstrumentAny::CurrencyPair(eth_usdt);
8581
8582        let order1 = OrderTestBuilder::new(OrderType::Market)
8583            .instrument_id(eth_usdt.id())
8584            .side(OrderSide::Buy)
8585            .quantity(Quantity::from("0.00350"))
8586            .build();
8587
8588        let fill1 = TestOrderEventStubs::filled(
8589            &order1,
8590            &eth_usdt,
8591            Some(TradeId::new("1")),
8592            None,
8593            Some(Price::from("2042.69")),
8594            Some(Quantity::from("0.00350")),
8595            None,
8596            Some(Money::new(0.00001, eth_usdt.base_currency().unwrap())),
8597            None,
8598            None,
8599        );
8600
8601        let mut position = Position::new(&eth_usdt, fill1.into());
8602
8603        assert_eq!(position.quantity, Quantity::from("0.00349"));
8604        assert!((position.signed_qty - 0.00349).abs() < 1e-9);
8605        assert_eq!(position.side, PositionSide::Long);
8606        assert_eq!(position.adjustments.len(), 1);
8607        assert_eq!(
8608            position.adjustments[0].quantity_change,
8609            Some(rust_decimal_macros::dec!(-0.00001))
8610        );
8611
8612        let order2 = OrderTestBuilder::new(OrderType::Market)
8613            .instrument_id(eth_usdt.id())
8614            .side(OrderSide::Buy)
8615            .quantity(Quantity::from("0.00350"))
8616            .build();
8617
8618        let fill2 = TestOrderEventStubs::filled(
8619            &order2,
8620            &eth_usdt,
8621            Some(TradeId::new("2")),
8622            None,
8623            Some(Price::from("2042.69")),
8624            Some(Quantity::from("0.00350")),
8625            None,
8626            Some(Money::new(0.00001, eth_usdt.base_currency().unwrap())),
8627            None,
8628            None,
8629        );
8630
8631        position.apply(&fill2.into());
8632
8633        assert_eq!(position.quantity, Quantity::from("0.00698"));
8634        assert!((position.signed_qty - 0.00698).abs() < 1e-9);
8635        assert_eq!(position.adjustments.len(), 2);
8636
8637        let order3 = OrderTestBuilder::new(OrderType::Market)
8638            .instrument_id(eth_usdt.id())
8639            .side(OrderSide::Buy)
8640            .quantity(Quantity::from("0.00300"))
8641            .build();
8642
8643        let fill3 = TestOrderEventStubs::filled(
8644            &order3,
8645            &eth_usdt,
8646            Some(TradeId::new("3")),
8647            None,
8648            Some(Price::from("2042.69")),
8649            Some(Quantity::from("0.00300")),
8650            None,
8651            Some(Money::new(0.00001, eth_usdt.base_currency().unwrap())),
8652            None,
8653            None,
8654        );
8655
8656        position.apply(&fill3.into());
8657
8658        // Total filled: 0.01000, total commission: 0.00003
8659        // Position should be 0.01000 - 0.00003 = 0.00997
8660        assert_eq!(position.quantity, Quantity::from("0.00997"));
8661        assert!((position.signed_qty - 0.00997).abs() < 1e-9);
8662        assert_eq!(position.side, PositionSide::Long);
8663        assert_eq!(position.adjustments.len(), 3);
8664
8665        // buy_qty tracks order fill amounts, not commission-adjusted
8666        assert_eq!(position.buy_qty, Quantity::from("0.01000"));
8667    }
8668
8669    #[rstest]
8670    fn test_position_spot_sell_partial_fills_with_base_commission() {
8671        let btc_usdt = currency_pair_btcusdt();
8672        let btc_usdt = InstrumentAny::CurrencyPair(btc_usdt);
8673
8674        let order1 = OrderTestBuilder::new(OrderType::Market)
8675            .instrument_id(btc_usdt.id())
8676            .side(OrderSide::Sell)
8677            .quantity(Quantity::from("0.5"))
8678            .build();
8679
8680        let fill1 = TestOrderEventStubs::filled(
8681            &order1,
8682            &btc_usdt,
8683            Some(TradeId::new("1")),
8684            None,
8685            Some(Price::from("50000.0")),
8686            Some(Quantity::from("0.5")),
8687            None,
8688            Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
8689            None,
8690            None,
8691        );
8692
8693        let mut position = Position::new(&btc_usdt, fill1.into());
8694
8695        // Short: sold 0.5 + paid 0.001 commission = -0.501 exposure
8696        assert!((position.signed_qty - (-0.501)).abs() < 1e-9);
8697        assert_eq!(position.side, PositionSide::Short);
8698        assert_eq!(position.adjustments.len(), 1);
8699
8700        let order2 = OrderTestBuilder::new(OrderType::Market)
8701            .instrument_id(btc_usdt.id())
8702            .side(OrderSide::Sell)
8703            .quantity(Quantity::from("0.5"))
8704            .build();
8705
8706        let fill2 = TestOrderEventStubs::filled(
8707            &order2,
8708            &btc_usdt,
8709            Some(TradeId::new("2")),
8710            None,
8711            Some(Price::from("50000.0")),
8712            Some(Quantity::from("0.5")),
8713            None,
8714            Some(Money::new(0.001, btc_usdt.base_currency().unwrap())),
8715            None,
8716            None,
8717        );
8718
8719        position.apply(&fill2.into());
8720
8721        // Total short: 1.0 sold + 0.002 commission = -1.002
8722        assert!((position.signed_qty - (-1.002)).abs() < 1e-9);
8723        assert!((position.quantity.as_f64() - 1.002).abs() < 1e-9);
8724        assert_eq!(position.adjustments.len(), 2);
8725        assert_eq!(position.sell_qty, Quantity::from("1.0"));
8726    }
8727
8728    #[rstest]
8729    fn test_position_spot_round_trip_close_flat_with_quote_commission() {
8730        let eth_usdt = currency_pair_ethusdt();
8731        let eth_usdt = InstrumentAny::CurrencyPair(eth_usdt);
8732
8733        let buy_order = OrderTestBuilder::new(OrderType::Market)
8734            .instrument_id(eth_usdt.id())
8735            .side(OrderSide::Buy)
8736            .quantity(Quantity::from("1.00000"))
8737            .build();
8738
8739        let buy_fill = TestOrderEventStubs::filled(
8740            &buy_order,
8741            &eth_usdt,
8742            Some(TradeId::new("1")),
8743            None,
8744            Some(Price::from("2000.00")),
8745            Some(Quantity::from("1.00000")),
8746            None,
8747            Some(Money::new(0.001, eth_usdt.base_currency().unwrap())),
8748            None,
8749            None,
8750        );
8751
8752        let mut position = Position::new(&eth_usdt, buy_fill.into());
8753
8754        // Position = 1.0 - 0.001 = 0.999
8755        assert_eq!(position.quantity, Quantity::from("0.99900"));
8756        assert_eq!(position.side, PositionSide::Long);
8757
8758        let sell_order = OrderTestBuilder::new(OrderType::Market)
8759            .instrument_id(eth_usdt.id())
8760            .side(OrderSide::Sell)
8761            .quantity(Quantity::from("0.99900"))
8762            .build();
8763
8764        let sell_fill = TestOrderEventStubs::filled(
8765            &sell_order,
8766            &eth_usdt,
8767            Some(TradeId::new("2")),
8768            None,
8769            Some(Price::from("2100.00")),
8770            Some(Quantity::from("0.99900")),
8771            None,
8772            Some(Money::new(2.0, Currency::USDT())),
8773            None,
8774            None,
8775        );
8776
8777        position.apply(&sell_fill.into());
8778
8779        assert_eq!(position.side, PositionSide::Flat);
8780        assert_eq!(position.signed_qty, 0.0);
8781        assert!(position.is_closed());
8782        // Only 1 adjustment from the buy (quote commission doesn't create adjustment)
8783        assert_eq!(position.adjustments.len(), 1);
8784
8785        // PnL: 0.999 ETH * $100 price move = $99.90, minus $2 commission
8786        let realized = position.realized_pnl.unwrap().as_f64();
8787        assert!(
8788            (realized - 97.9).abs() < 0.01,
8789            "Realized PnL should be ~97.90 USDT, was {realized}"
8790        );
8791    }
8792
8793    #[rstest]
8794    fn test_position_spot_commission_accumulation_multiple_partial_fills() {
8795        let eth_usdt = currency_pair_ethusdt();
8796        let eth_usdt = InstrumentAny::CurrencyPair(eth_usdt);
8797
8798        let order1 = OrderTestBuilder::new(OrderType::Market)
8799            .instrument_id(eth_usdt.id())
8800            .side(OrderSide::Buy)
8801            .quantity(Quantity::from("0.50000"))
8802            .build();
8803
8804        let fill1 = TestOrderEventStubs::filled(
8805            &order1,
8806            &eth_usdt,
8807            Some(TradeId::new("1")),
8808            None,
8809            Some(Price::from("2000.00")),
8810            Some(Quantity::from("0.50000")),
8811            None,
8812            Some(Money::new(0.0005, eth_usdt.base_currency().unwrap())),
8813            None,
8814            None,
8815        );
8816
8817        let mut position = Position::new(&eth_usdt, fill1.into());
8818
8819        let order2 = OrderTestBuilder::new(OrderType::Market)
8820            .instrument_id(eth_usdt.id())
8821            .side(OrderSide::Buy)
8822            .quantity(Quantity::from("0.50000"))
8823            .build();
8824
8825        let fill2 = TestOrderEventStubs::filled(
8826            &order2,
8827            &eth_usdt,
8828            Some(TradeId::new("2")),
8829            None,
8830            Some(Price::from("2010.00")),
8831            Some(Quantity::from("0.50000")),
8832            None,
8833            Some(Money::new(0.0005, eth_usdt.base_currency().unwrap())),
8834            None,
8835            None,
8836        );
8837
8838        position.apply(&fill2.into());
8839
8840        // Total: 1.0 filled, 0.001 total commission
8841        assert_eq!(position.quantity, Quantity::from("0.99900"));
8842        assert_eq!(position.buy_qty, Quantity::from("1.00000"));
8843
8844        assert_eq!(position.adjustments.len(), 2);
8845        for adj in &position.adjustments {
8846            assert_eq!(adj.adjustment_type, PositionAdjustmentType::Commission);
8847            assert_eq!(
8848                adj.quantity_change,
8849                Some(rust_decimal_macros::dec!(-0.0005))
8850            );
8851        }
8852
8853        let commissions = position.commissions();
8854        assert_eq!(commissions.len(), 1);
8855        let eth_commission = commissions[0];
8856        assert!(
8857            (eth_commission.as_f64() - 0.001).abs() < 1e-9,
8858            "Total ETH commission should be 0.001, was {}",
8859            eth_commission.as_f64()
8860        );
8861    }
8862
8863    #[rstest]
8864    fn test_position_apply_fill_with_earlier_timestamp_adjusts_ts_opened(audusd_sim: CurrencyPair) {
8865        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
8866        let order1 = OrderTestBuilder::new(OrderType::Market)
8867            .instrument_id(audusd_sim.id())
8868            .side(OrderSide::Buy)
8869            .quantity(Quantity::from(100_000))
8870            .build();
8871        let order2 = OrderTestBuilder::new(OrderType::Market)
8872            .instrument_id(audusd_sim.id())
8873            .side(OrderSide::Buy)
8874            .quantity(Quantity::from(100_000))
8875            .build();
8876
8877        // First fill at ts=2000
8878        let fill1 = TestOrderEventStubs::filled(
8879            &order1,
8880            &audusd_sim,
8881            Some(TradeId::new("t1")),
8882            None,
8883            Some(Price::from("1.00001")),
8884            None,
8885            None,
8886            None,
8887            Some(UnixNanos::from(2_000u64)),
8888            None,
8889        );
8890        let mut position = Position::new(&audusd_sim, fill1.into());
8891        assert_eq!(position.ts_opened, UnixNanos::from(2_000u64));
8892
8893        // Second fill at ts=1000 (earlier than position open)
8894        let fill2 = TestOrderEventStubs::filled(
8895            &order2,
8896            &audusd_sim,
8897            Some(TradeId::new("t2")),
8898            None,
8899            Some(Price::from("1.00002")),
8900            None,
8901            None,
8902            None,
8903            Some(UnixNanos::from(1_000u64)),
8904            None,
8905        );
8906
8907        // Should not panic; ts_opened and opening_order_id stay unchanged
8908        position.apply(&fill2.into());
8909        assert_eq!(position.ts_opened, UnixNanos::from(2_000u64));
8910        assert_eq!(position.opening_order_id, order1.client_order_id());
8911        assert_eq!(position.events.len(), 2);
8912    }
8913
8914    #[rstest]
8915    fn test_position_close_before_open_clamps_duration(audusd_sim: CurrencyPair) {
8916        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
8917        let opening_order = OrderTestBuilder::new(OrderType::Market)
8918            .instrument_id(audusd_sim.id())
8919            .side(OrderSide::Buy)
8920            .quantity(Quantity::from(100_000))
8921            .build();
8922        let closing_order = OrderTestBuilder::new(OrderType::Market)
8923            .instrument_id(audusd_sim.id())
8924            .side(OrderSide::Sell)
8925            .quantity(Quantity::from(100_000))
8926            .build();
8927        let opening_fill = TestOrderEventStubs::filled(
8928            &opening_order,
8929            &audusd_sim,
8930            Some(TradeId::new("OPEN")),
8931            None,
8932            Some(Price::from("1.00001")),
8933            None,
8934            None,
8935            None,
8936            Some(UnixNanos::from(2_000u64)),
8937            None,
8938        );
8939        let closing_fill = TestOrderEventStubs::filled(
8940            &closing_order,
8941            &audusd_sim,
8942            Some(TradeId::new("CLOSE")),
8943            None,
8944            Some(Price::from("1.00002")),
8945            None,
8946            None,
8947            None,
8948            Some(UnixNanos::from(1_000u64)),
8949            None,
8950        );
8951        let mut position = Position::new(&audusd_sim, opening_fill.into());
8952
8953        position.apply(&closing_fill.into());
8954
8955        assert_eq!(position.side, PositionSide::Flat);
8956        assert_eq!(position.ts_opened, UnixNanos::from(2_000u64));
8957        assert_eq!(position.ts_closed, Some(UnixNanos::from(1_000u64)));
8958        assert_eq!(position.duration_ns, DurationNanos::default());
8959        assert_eq!(
8960            position.closing_order_id,
8961            Some(closing_order.client_order_id())
8962        );
8963    }
8964
8965    #[rstest]
8966    fn test_position_commissions_multi_currency_insertion_order(audusd_sim: CurrencyPair) {
8967        // Locks in IndexMap iteration order for Position::commissions:
8968        // new currencies append to the end, existing currencies accumulate
8969        // in place. PositionSnapshot.commissions builds its Vec from this
8970        // iteration; the order must be deterministic across runs.
8971        let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
8972        let order_template = OrderTestBuilder::new(OrderType::Market)
8973            .instrument_id(audusd_sim.id())
8974            .side(OrderSide::Buy)
8975            .quantity(Quantity::from(100_000))
8976            .build();
8977
8978        let fill_usd = TestOrderEventStubs::filled(
8979            &order_template,
8980            &audusd_sim,
8981            Some(TradeId::new("t1")),
8982            None,
8983            Some(Price::from("1.00001")),
8984            None,
8985            None,
8986            Some(Money::from("1.0 USD")),
8987            None,
8988            None,
8989        );
8990        let mut position = Position::new(&audusd_sim, fill_usd.into());
8991
8992        let fill_usdt = TestOrderEventStubs::filled(
8993            &order_template,
8994            &audusd_sim,
8995            Some(TradeId::new("t2")),
8996            None,
8997            Some(Price::from("1.00001")),
8998            None,
8999            None,
9000            Some(Money::from("2.0 USDT")),
9001            None,
9002            None,
9003        );
9004        position.apply(&fill_usdt.into());
9005
9006        let fill_usd_again = TestOrderEventStubs::filled(
9007            &order_template,
9008            &audusd_sim,
9009            Some(TradeId::new("t3")),
9010            None,
9011            Some(Price::from("1.00001")),
9012            None,
9013            None,
9014            Some(Money::from("0.5 USD")),
9015            None,
9016            None,
9017        );
9018        position.apply(&fill_usd_again.into());
9019
9020        let fill_btc = TestOrderEventStubs::filled(
9021            &order_template,
9022            &audusd_sim,
9023            Some(TradeId::new("t4")),
9024            None,
9025            Some(Price::from("1.00001")),
9026            None,
9027            None,
9028            Some(Money::from("0.0001 BTC")),
9029            None,
9030            None,
9031        );
9032        position.apply(&fill_btc.into());
9033
9034        // USD entered first and accumulates in place, USDT appends second,
9035        // BTC appends third
9036        assert_eq!(
9037            position.commissions(),
9038            vec![
9039                Money::from("1.5 USD"),
9040                Money::from("2.0 USDT"),
9041                Money::from("0.0001 BTC"),
9042            ]
9043        );
9044    }
9045
9046    #[rstest]
9047    fn test_fold_net_position_empty() {
9048        let (net_qty, net_px) = fold_net_position(&[]);
9049        assert_eq!(net_qty, Decimal::ZERO);
9050        assert_eq!(net_px, Decimal::ZERO);
9051    }
9052
9053    #[rstest]
9054    fn test_fold_net_position_single_long() {
9055        let legs = [(dec!(100), dec!(1.5), 1u64)];
9056        let (net_qty, net_px) = fold_net_position(&legs);
9057        assert_eq!(net_qty, dec!(100));
9058        assert_eq!(net_px, dec!(1.5));
9059    }
9060
9061    #[rstest]
9062    fn test_fold_net_position_single_short() {
9063        let legs = [(dec!(-100), dec!(1.5), 1u64)];
9064        let (net_qty, net_px) = fold_net_position(&legs);
9065        assert_eq!(net_qty, dec!(-100));
9066        assert_eq!(net_px, dec!(1.5));
9067    }
9068
9069    #[rstest]
9070    fn test_fold_net_position_same_side_weighted_average() {
9071        // Long 100 @ 1.00, long 200 @ 0.50 -> net 300 @ 0.6667 (rounded weighted avg).
9072        let legs = [(dec!(100), dec!(1.0), 1u64), (dec!(200), dec!(0.5), 2u64)];
9073        let (net_qty, net_px) = fold_net_position(&legs);
9074        assert_eq!(net_qty, dec!(300));
9075        // (100 * 1.0 + 200 * 0.5) / 300 = 200/300 = 0.6666...
9076        assert_eq!(net_px, dec!(200) / dec!(300));
9077    }
9078
9079    #[rstest]
9080    fn test_fold_net_position_partial_close_preserves_avg() {
9081        // Long 300 @ 0.80, short 100 @ 1.00 -> net long 200 @ 0.80 (short closes part of long).
9082        let legs = [
9083            (dec!(300), dec!(0.80), 1u64),
9084            (dec!(-100), dec!(1.00), 2u64),
9085        ];
9086        let (net_qty, net_px) = fold_net_position(&legs);
9087        assert_eq!(net_qty, dec!(200));
9088        assert_eq!(net_px, dec!(0.80));
9089    }
9090
9091    #[rstest]
9092    fn test_fold_net_position_full_close() {
9093        let legs = [(dec!(100), dec!(1.0), 1u64), (dec!(-100), dec!(2.0), 2u64)];
9094        let (net_qty, net_px) = fold_net_position(&legs);
9095        assert_eq!(net_qty, Decimal::ZERO);
9096        assert_eq!(net_px, Decimal::ZERO);
9097    }
9098
9099    #[rstest]
9100    fn test_fold_net_position_single_flip_uses_flipping_price() {
9101        // L100@1, S50@2 partial-closes to L50@1, S100@3 flips to S50 @ 3
9102        let legs = [
9103            (dec!(100), dec!(1.00), 1u64),
9104            (dec!(-50), dec!(2.00), 2u64),
9105            (dec!(-100), dec!(3.00), 3u64),
9106        ];
9107        let (net_qty, net_px) = fold_net_position(&legs);
9108        assert_eq!(net_qty, dec!(-50));
9109        assert_eq!(net_px, dec!(3.00));
9110    }
9111
9112    #[rstest]
9113    fn test_fold_net_position_double_flip() {
9114        // L50, S100 flips to S50@2, B100 flips to L50@3
9115        let legs = [
9116            (dec!(50), dec!(1.00), 1u64),
9117            (dec!(-100), dec!(2.00), 2u64),
9118            (dec!(100), dec!(3.00), 3u64),
9119        ];
9120        let (net_qty, net_px) = fold_net_position(&legs);
9121        assert_eq!(net_qty, dec!(50));
9122        assert_eq!(net_px, dec!(3.00));
9123    }
9124
9125    #[rstest]
9126    fn test_fold_net_position_zero_quantity_legs_skipped() {
9127        // Zero-qty legs are filtered out (closed positions have signed_qty == 0)
9128        let legs = [
9129            (dec!(100), dec!(1.0), 1u64),
9130            (Decimal::ZERO, dec!(99.0), 2u64),
9131            (dec!(50), dec!(2.0), 3u64),
9132        ];
9133        let (net_qty, net_px) = fold_net_position(&legs);
9134        assert_eq!(net_qty, dec!(150));
9135        // (100 * 1.0 + 50 * 2.0) / 150 = 200/150 = 1.333
9136        assert_eq!(net_px, dec!(200) / dec!(150));
9137    }
9138
9139    #[rstest]
9140    fn test_fold_net_position_stable_sort_preserves_input_order_for_equal_ts() {
9141        // Caller owns tie-breaking; this pins the input-order contract for equal-ts legs
9142        let leg_a = (dec!(100), dec!(1.00), 1u64);
9143        let leg_b = (dec!(-100), dec!(2.00), 1u64);
9144
9145        let ab = [leg_a, leg_b];
9146        let ba = [leg_b, leg_a];
9147
9148        // a-then-b: L100@1, S100@2 -> net zero
9149        assert_eq!(fold_net_position(&ab), (Decimal::ZERO, Decimal::ZERO));
9150        // b-then-a: S100@2, B100@1 -> net zero
9151        assert_eq!(fold_net_position(&ba), (Decimal::ZERO, Decimal::ZERO));
9152
9153        // Same-ts legs that do NOT fully cancel: input order picks the surviving avg
9154        let leg_c = (dec!(150), dec!(1.00), 1u64);
9155        let leg_d = (dec!(-100), dec!(2.00), 1u64);
9156        let cd = [leg_c, leg_d];
9157        let dc = [leg_d, leg_c];
9158        // c-then-d: L150@1, S100@2 -> long 50 @ 1
9159        assert_eq!(fold_net_position(&cd), (dec!(50), dec!(1.00)));
9160        // d-then-c: S100@2, B150@1 -> flip to long, residual @ 1
9161        assert_eq!(fold_net_position(&dc), (dec!(50), dec!(1.00)));
9162    }
9163
9164    #[rstest]
9165    fn test_fold_net_position_close_then_reopen() {
9166        // A leg after a full close opens fresh (new net, new avg)
9167        let legs = [
9168            (dec!(100), dec!(1.00), 1u64),
9169            (dec!(-100), dec!(1.50), 2u64),
9170            (dec!(50), dec!(3.00), 3u64),
9171        ];
9172        let (net_qty, net_px) = fold_net_position(&legs);
9173        assert_eq!(net_qty, dec!(50));
9174        assert_eq!(net_px, dec!(3.00));
9175    }
9176
9177    #[rstest]
9178    fn test_fold_net_position_orders_by_ts_opened() {
9179        // Sorted vs shuffled input must fold to the same result.
9180        let in_order = [
9181            (dec!(100), dec!(1.00), 1u64),
9182            (dec!(-50), dec!(2.00), 2u64),
9183            (dec!(-100), dec!(3.00), 3u64),
9184        ];
9185        let shuffled = [
9186            (dec!(-100), dec!(3.00), 3u64),
9187            (dec!(100), dec!(1.00), 1u64),
9188            (dec!(-50), dec!(2.00), 2u64),
9189        ];
9190        assert_eq!(fold_net_position(&in_order), fold_net_position(&shuffled));
9191    }
9192
9193    // Build a NETTING-mode reference Position by applying fills sorted by ts_opened
9194    // (the same order fold_net_position uses). Returns (signed_qty, avg_px_open) as Decimals.
9195    fn netting_reference(
9196        instrument: &InstrumentAny,
9197        fills: &[(OrderSide, u32, u32, u64)],
9198    ) -> (Decimal, Decimal) {
9199        let mut sorted_fills = fills.to_vec();
9200        sorted_fills.sort_by_key(|(_, _, _, ts)| *ts);
9201
9202        let mut position: Option<Position> = None;
9203
9204        for (idx, &(side, qty, px, ts)) in sorted_fills.iter().enumerate() {
9205            let order = OrderTestBuilder::new(OrderType::Market)
9206                .instrument_id(instrument.id())
9207                .side(side)
9208                .quantity(Quantity::from(qty))
9209                .build();
9210            let fill = TestOrderEventStubs::filled(
9211                &order,
9212                instrument,
9213                Some(TradeId::new(format!("T{idx}").as_str())),
9214                Some(PositionId::new("P-NET")),
9215                Some(Price::from(px.to_string().as_str())),
9216                None,
9217                None,
9218                Some(Money::new(0.0, instrument.quote_currency())),
9219                Some(UnixNanos::from(ts)),
9220                None,
9221            );
9222            let event: OrderFilled = fill.into();
9223            if let Some(p) = position.as_mut() {
9224                p.apply(&event);
9225            } else {
9226                position = Some(Position::new(instrument, event));
9227            }
9228        }
9229        let p = position.expect("at least one fill");
9230        let signed = Decimal::try_from(p.signed_qty).unwrap_or(Decimal::ZERO);
9231        let px = Decimal::try_from(p.avg_px_open).unwrap_or(Decimal::ZERO);
9232        (signed, px)
9233    }
9234
9235    // Build the HEDGING leg list (one leg per fill) as Decimal tuples.
9236    fn hedging_legs(fills: &[(OrderSide, u32, u32, u64)]) -> Vec<(Decimal, Decimal, u64)> {
9237        fills
9238            .iter()
9239            .map(|&(side, qty, px, ts)| {
9240                let signed = if side == OrderSide::Buy {
9241                    Decimal::from(qty)
9242                } else {
9243                    -Decimal::from(qty)
9244                };
9245                (signed, Decimal::from(px), ts)
9246            })
9247            .collect()
9248    }
9249
9250    proptest! {
9251        // For any fill sequence that does not fully close mid-stream, fold_net_position
9252        // produces the same (signed_qty, avg_px_open) as applying the same fills in order
9253        // to a single NETTING-mode Position. Sequences that pass through zero mid-stream are
9254        // filtered out because Position::apply does not reset avg_px_open on the next fill
9255        // after a close (production fills are pre-split before reaching that path).
9256        #[rstest]
9257        fn prop_fold_matches_netting_replay(
9258            fills in proptest::collection::vec(
9259                (
9260                    prop_oneof![Just(OrderSide::Buy), Just(OrderSide::Sell)],
9261                    1u32..1_000u32,
9262                    1u32..100u32,
9263                    0u64..1_000_000u64,
9264                ),
9265                1..6,
9266            )
9267        ) {
9268            // Filter sequences with duplicate ts_opened: equal-ts ties resolve to stable
9269            // input order, but the proptest generator does not preserve ties meaningfully.
9270            let mut seen_ts: AHashSet<u64> = AHashSet::new();
9271            for &(_, _, _, ts) in &fills {
9272                if !seen_ts.insert(ts) {
9273                    prop_assume!(false);
9274                }
9275            }
9276
9277            // Filter sequences that fully close mid-stream on the sorted (ts_opened) order.
9278            // Position::apply on a closed position does not reset avg_px_open on the next
9279            // re-open fill (production fills are pre-split, so the path is not exercised).
9280            let mut sorted_fills = fills.clone();
9281            sorted_fills.sort_by_key(|(_, _, _, ts)| *ts);
9282            let mut running: i64 = 0;
9283            let mut zero_mid = false;
9284
9285            for (idx, &(side, qty, _, _)) in sorted_fills.iter().enumerate() {
9286                let qty_i64 = i64::from(qty);
9287                let signed: i64 = if side == OrderSide::Buy {
9288                    qty_i64
9289                } else {
9290                    -qty_i64
9291                };
9292                running += signed;
9293                if idx + 1 < sorted_fills.len() && running == 0 {
9294                    zero_mid = true;
9295                    break;
9296                }
9297            }
9298            prop_assume!(!zero_mid);
9299
9300            let instrument = InstrumentAny::CurrencyPair(audusd_sim());
9301            let (ref_qty, ref_px) = netting_reference(&instrument, &fills);
9302            let legs = hedging_legs(&fills);
9303            let (fold_qty, fold_px) = fold_net_position(&legs);
9304
9305            prop_assert_eq!(fold_qty, ref_qty);
9306
9307            // avg_px_open is only meaningful when the position is non-flat. Compare via
9308            // f64 round-trip since the reference goes through Position::apply f64 arithmetic;
9309            // fold's full-precision Decimal is more accurate but not exactly equal.
9310            if !ref_qty.is_zero() {
9311                let fold_px_f64 = fold_px.to_f64().unwrap_or(0.0);
9312                let ref_px_f64 = ref_px.to_f64().unwrap_or(0.0);
9313                let max_mag = fold_px_f64.abs().max(ref_px_f64.abs()).max(1.0);
9314                prop_assert!(
9315                    (fold_px_f64 - ref_px_f64).abs() < 1e-9 * max_mag,
9316                    "fold_px {fold_px_f64} vs ref_px {ref_px_f64}",
9317                );
9318            }
9319        }
9320    }
9321
9322    fn matching_fill_void(
9323        fill: &OrderFilled,
9324        voided_qty: Quantity,
9325        commission_voided: Option<Money>,
9326    ) -> OrderFillVoided {
9327        OrderFillVoidedSpec::builder()
9328            .trader_id(fill.trader_id)
9329            .strategy_id(fill.strategy_id)
9330            .instrument_id(fill.instrument_id)
9331            .client_order_id(fill.client_order_id)
9332            .venue_order_id(fill.venue_order_id)
9333            .account_id(fill.account_id)
9334            .trade_id(fill.trade_id)
9335            .voided_qty(voided_qty)
9336            .order_side(fill.order_side)
9337            .order_type(fill.order_type)
9338            .last_px(fill.last_px)
9339            .currency(fill.currency)
9340            .liquidity_side(fill.liquidity_side)
9341            .maybe_position_id(fill.position_id)
9342            .maybe_commission_voided(commission_voided)
9343            .build()
9344    }
9345}