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