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