Skip to main content

nautilus_model/
position.rs

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