nautilus_execution/
reconciliation.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//! Position reconciliation calculation functions.
17
18use ahash::AHashMap;
19use nautilus_core::{UUID4, UnixNanos};
20use nautilus_model::{
21    enums::{LiquiditySide, OrderSide, OrderStatus, OrderType, PositionSideSpecified, TimeInForce},
22    identifiers::{AccountId, InstrumentId, TradeId, VenueOrderId},
23    instruments::{Instrument, InstrumentAny},
24    reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
25    types::{Money, Price, Quantity},
26};
27use rust_decimal::{Decimal, prelude::ToPrimitive};
28
29/// Immutable snapshot of fill data for position simulation.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct FillSnapshot {
32    /// The event timestamp (nanoseconds).
33    pub ts_event: u64,
34    /// The order side (BUY or SELL).
35    pub side: OrderSide,
36    /// The fill quantity.
37    pub qty: Decimal,
38    /// The fill price.
39    pub px: Decimal,
40    /// The venue order ID.
41    pub venue_order_id: VenueOrderId,
42}
43
44/// Represents a position snapshot from the venue.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct VenuePositionSnapshot {
47    /// The position side (LONG, SHORT, or FLAT).
48    pub side: OrderSide, // Using OrderSide to represent position side for simplicity
49    /// The position quantity (always positive, even for SHORT).
50    pub qty: Decimal,
51    /// The average entry price (can be zero for FLAT positions).
52    pub avg_px: Decimal,
53}
54
55/// Result of the fill adjustment process.
56#[derive(Debug, Clone, PartialEq)]
57pub enum FillAdjustmentResult {
58    /// No adjustment needed - return fills unchanged.
59    NoAdjustment,
60    /// Add synthetic opening fill to oldest lifecycle.
61    AddSyntheticOpening {
62        /// The synthetic fill to add at the beginning.
63        synthetic_fill: FillSnapshot,
64        /// All existing fills to keep.
65        existing_fills: Vec<FillSnapshot>,
66    },
67    /// Replace entire current lifecycle with single synthetic fill.
68    ReplaceCurrentLifecycle {
69        /// The single synthetic fill representing the entire position.
70        synthetic_fill: FillSnapshot,
71        /// The first venue order ID to use.
72        first_venue_order_id: VenueOrderId,
73    },
74    /// Filter fills to current lifecycle only (after last zero-crossing).
75    FilterToCurrentLifecycle {
76        /// Timestamp of the last zero-crossing.
77        last_zero_crossing_ts: u64,
78        /// Fills from current lifecycle.
79        current_lifecycle_fills: Vec<FillSnapshot>,
80    },
81}
82
83impl FillSnapshot {
84    /// Create a new fill snapshot.
85    #[must_use]
86    pub fn new(
87        ts_event: u64,
88        side: OrderSide,
89        qty: Decimal,
90        px: Decimal,
91        venue_order_id: VenueOrderId,
92    ) -> Self {
93        Self {
94            ts_event,
95            side,
96            qty,
97            px,
98            venue_order_id,
99        }
100    }
101
102    /// Return signed direction multiplier: +1 for BUY, -1 for SELL.
103    #[must_use]
104    pub fn direction(&self) -> i8 {
105        match self.side {
106            OrderSide::Buy => 1,
107            OrderSide::Sell => -1,
108            _ => 0,
109        }
110    }
111}
112
113/// Simulate position from chronologically ordered fills using netting logic.
114///
115/// # Returns
116///
117/// Returns a tuple of (quantity, value) after applying all fills.
118#[must_use]
119pub fn simulate_position(fills: &[FillSnapshot]) -> (Decimal, Decimal) {
120    let mut qty = Decimal::ZERO;
121    let mut value = Decimal::ZERO;
122
123    for fill in fills {
124        let direction = Decimal::from(fill.direction());
125        let new_qty = qty + (direction * fill.qty);
126
127        // Check if we're accumulating or crossing zero (flip/close)
128        if (qty >= Decimal::ZERO && direction > Decimal::ZERO)
129            || (qty <= Decimal::ZERO && direction < Decimal::ZERO)
130        {
131            // Accumulating in same direction
132            value += fill.qty * fill.px;
133            qty = new_qty;
134        } else {
135            // Closing or flipping position
136            if qty.abs() >= fill.qty {
137                // Partial close - maintain average price by reducing value proportionally
138                let close_ratio = fill.qty / qty.abs();
139                value *= Decimal::ONE - close_ratio;
140                qty = new_qty;
141            } else {
142                // Close and flip - reset value to opening position
143                let remaining = fill.qty - qty.abs();
144                qty = direction * remaining;
145                value = remaining * fill.px;
146            }
147        }
148    }
149
150    (qty, value)
151}
152
153/// Detect zero-crossing timestamps in a sequence of fills.
154///
155/// A zero-crossing occurs when position quantity crosses through zero (FLAT).
156/// This includes both landing exactly on zero and flipping from long to short or vice versa.
157///
158/// # Returns
159///
160/// Returns a list of timestamps where position crosses through zero.
161#[must_use]
162pub fn detect_zero_crossings(fills: &[FillSnapshot]) -> Vec<u64> {
163    let mut running_qty = Decimal::ZERO;
164    let mut zero_crossings = Vec::new();
165
166    for fill in fills {
167        let prev_qty = running_qty;
168        running_qty += Decimal::from(fill.direction()) * fill.qty;
169
170        // Detect when position crosses zero
171        if prev_qty != Decimal::ZERO {
172            if running_qty == Decimal::ZERO {
173                // Landed exactly on zero
174                zero_crossings.push(fill.ts_event);
175            } else if (prev_qty > Decimal::ZERO) != (running_qty > Decimal::ZERO) {
176                // Sign changed - crossed through zero (flip)
177                zero_crossings.push(fill.ts_event);
178            }
179        }
180    }
181
182    zero_crossings
183}
184
185/// Check if simulated position matches venue position within tolerance.
186///
187/// # Returns
188///
189/// Returns true if quantities and average prices match within tolerance.
190#[must_use]
191pub fn check_position_match(
192    simulated_qty: Decimal,
193    simulated_value: Decimal,
194    venue_qty: Decimal,
195    venue_avg_px: Decimal,
196    tolerance: Decimal,
197) -> bool {
198    if simulated_qty != venue_qty {
199        return false;
200    }
201
202    if simulated_qty == Decimal::ZERO {
203        return true; // Both FLAT
204    }
205
206    // Guard against division by zero
207    let abs_qty = simulated_qty.abs();
208    if abs_qty == Decimal::ZERO {
209        return false;
210    }
211
212    let simulated_avg_px = simulated_value / abs_qty;
213
214    // If venue avg px is zero, we cannot calculate relative difference
215    if venue_avg_px == Decimal::ZERO {
216        return false;
217    }
218
219    let relative_diff = (simulated_avg_px - venue_avg_px).abs() / venue_avg_px;
220
221    relative_diff <= tolerance
222}
223
224/// Calculate the price needed for a reconciliation order to achieve target position.
225///
226/// This is a pure function that calculates what price a fill would need to have
227/// to move from the current position state to the target position state with the
228/// correct average price, accounting for the netting simulation logic.
229///
230/// # Returns
231///
232/// Returns `Some(Decimal)` if a valid reconciliation price can be calculated, `None` otherwise.
233///
234/// # Notes
235///
236/// The function handles four scenarios:
237/// 1. Position to flat: reconciliation_px = current_avg_px (close at current average)
238/// 2. Flat to position: reconciliation_px = target_avg_px
239/// 3. Position flip (sign change): reconciliation_px = target_avg_px (due to value reset in simulation)
240/// 4. Accumulation/reduction: weighted average formula
241pub fn calculate_reconciliation_price(
242    current_position_qty: Decimal,
243    current_position_avg_px: Option<Decimal>,
244    target_position_qty: Decimal,
245    target_position_avg_px: Option<Decimal>,
246) -> Option<Decimal> {
247    // Calculate the difference in quantity
248    let qty_diff = target_position_qty - current_position_qty;
249
250    if qty_diff == Decimal::ZERO {
251        return None; // No reconciliation needed
252    }
253
254    // Special case: closing to flat (target_position_qty == 0)
255    // When flattening, the reconciliation price equals the current position's average price
256    if target_position_qty == Decimal::ZERO {
257        return current_position_avg_px;
258    }
259
260    // If target average price is not provided or zero, we cannot calculate
261    let target_avg_px = target_position_avg_px?;
262    if target_avg_px == Decimal::ZERO {
263        return None;
264    }
265
266    // If current position is flat, the reconciliation price equals target avg price
267    if current_position_qty == Decimal::ZERO || current_position_avg_px.is_none() {
268        return Some(target_avg_px);
269    }
270
271    let current_avg_px = current_position_avg_px?;
272
273    // Check if this is a flip scenario (sign change)
274    // In simulation, flips reset value to remaining * px, so reconciliation_px = target_avg_px
275    let is_flip = (current_position_qty > Decimal::ZERO) != (target_position_qty > Decimal::ZERO)
276        && target_position_qty != Decimal::ZERO;
277
278    if is_flip {
279        return Some(target_avg_px);
280    }
281
282    // For accumulation or reduction (same side), use weighted average formula
283    // Formula: (target_qty * target_avg_px) = (current_qty * current_avg_px) + (qty_diff * reconciliation_px)
284    let target_value = target_position_qty * target_avg_px;
285    let current_value = current_position_qty * current_avg_px;
286    let diff_value = target_value - current_value;
287
288    // qty_diff is guaranteed to be non-zero here due to early return at line 270
289    let reconciliation_px = diff_value / qty_diff;
290
291    // Ensure price is positive
292    if reconciliation_px > Decimal::ZERO {
293        return Some(reconciliation_px);
294    }
295
296    None
297}
298
299/// Adjust fills for partial reconciliation window to handle incomplete position lifecycles.
300///
301/// This function analyzes fills and determines if adjustments are needed when the reconciliation
302/// window doesn't capture the complete position history (missing opening fills).
303///
304/// # Returns
305///
306/// Returns `FillAdjustmentResult` indicating what adjustments (if any) are needed.
307///
308/// # Panics
309///
310/// This function does not panic under normal circumstances as all unwrap calls are guarded by prior checks.
311#[must_use]
312pub fn adjust_fills_for_partial_window(
313    fills: &[FillSnapshot],
314    venue_position: &VenuePositionSnapshot,
315    _instrument: &InstrumentAny,
316    tolerance: Decimal,
317) -> FillAdjustmentResult {
318    // If no fills, nothing to adjust
319    if fills.is_empty() {
320        return FillAdjustmentResult::NoAdjustment;
321    }
322
323    // If venue position is FLAT, return unchanged
324    if venue_position.qty == Decimal::ZERO {
325        return FillAdjustmentResult::NoAdjustment;
326    }
327
328    // Detect zero-crossings
329    let zero_crossings = detect_zero_crossings(fills);
330
331    // Convert venue position to signed quantity
332    let venue_qty_signed = match venue_position.side {
333        OrderSide::Buy => venue_position.qty,
334        OrderSide::Sell => -venue_position.qty,
335        _ => Decimal::ZERO,
336    };
337
338    // Case 1: Has zero-crossings - focus on current lifecycle after last zero-crossing
339    if !zero_crossings.is_empty() {
340        // Find the last zero-crossing that lands on FLAT (qty==0)
341        // This separates lifecycles; flips within a lifecycle don't count
342        let mut last_flat_crossing_ts = None;
343        let mut running_qty = Decimal::ZERO;
344
345        for fill in fills {
346            let prev_qty = running_qty;
347            running_qty += Decimal::from(fill.direction()) * fill.qty;
348
349            if prev_qty != Decimal::ZERO && running_qty == Decimal::ZERO {
350                last_flat_crossing_ts = Some(fill.ts_event);
351            }
352        }
353
354        let lifecycle_boundary_ts =
355            last_flat_crossing_ts.unwrap_or(*zero_crossings.last().unwrap());
356
357        // Get fills from current lifecycle (after lifecycle boundary)
358        let current_lifecycle_fills: Vec<FillSnapshot> = fills
359            .iter()
360            .filter(|f| f.ts_event > lifecycle_boundary_ts)
361            .cloned()
362            .collect();
363
364        if current_lifecycle_fills.is_empty() {
365            return FillAdjustmentResult::NoAdjustment;
366        }
367
368        // Simulate current lifecycle
369        let (current_qty, current_value) = simulate_position(&current_lifecycle_fills);
370
371        // Check if current lifecycle matches venue
372        if check_position_match(
373            current_qty,
374            current_value,
375            venue_qty_signed,
376            venue_position.avg_px,
377            tolerance,
378        ) {
379            // Current lifecycle matches - filter out old lifecycles
380            return FillAdjustmentResult::FilterToCurrentLifecycle {
381                last_zero_crossing_ts: lifecycle_boundary_ts,
382                current_lifecycle_fills,
383            };
384        }
385
386        // Current lifecycle doesn't match - replace with synthetic fill
387        if let Some(first_fill) = current_lifecycle_fills.first() {
388            let synthetic_fill = FillSnapshot::new(
389                first_fill.ts_event.saturating_sub(1), // Timestamp before first fill
390                venue_position.side,
391                venue_position.qty,
392                venue_position.avg_px,
393                first_fill.venue_order_id,
394            );
395
396            return FillAdjustmentResult::ReplaceCurrentLifecycle {
397                synthetic_fill,
398                first_venue_order_id: first_fill.venue_order_id,
399            };
400        }
401
402        return FillAdjustmentResult::NoAdjustment;
403    }
404
405    // Case 2: Single lifecycle or one zero-crossing
406    // Determine which fills to analyze
407    let oldest_lifecycle_fills: Vec<FillSnapshot> =
408        if let Some(&first_zero_crossing_ts) = zero_crossings.first() {
409            // Get fills before first zero-crossing
410            fills
411                .iter()
412                .filter(|f| f.ts_event <= first_zero_crossing_ts)
413                .cloned()
414                .collect()
415        } else {
416            // No zero-crossings - all fills are in single lifecycle
417            fills.to_vec()
418        };
419
420    if oldest_lifecycle_fills.is_empty() {
421        return FillAdjustmentResult::NoAdjustment;
422    }
423
424    // Simulate oldest lifecycle
425    let (oldest_qty, oldest_value) = simulate_position(&oldest_lifecycle_fills);
426
427    // If single lifecycle (no zero-crossings)
428    if zero_crossings.is_empty() {
429        // Check if simulated position matches venue
430        if check_position_match(
431            oldest_qty,
432            oldest_value,
433            venue_qty_signed,
434            venue_position.avg_px,
435            tolerance,
436        ) {
437            return FillAdjustmentResult::NoAdjustment;
438        }
439
440        // Doesn't match - need to add synthetic opening fill
441        if let Some(first_fill) = oldest_lifecycle_fills.first() {
442            // Calculate what opening fill is needed
443            // Use simulated position as current, venue position as target
444            let oldest_avg_px = if oldest_qty != Decimal::ZERO {
445                Some(oldest_value / oldest_qty.abs())
446            } else {
447                None
448            };
449
450            let reconciliation_price = calculate_reconciliation_price(
451                oldest_qty,
452                oldest_avg_px,
453                venue_qty_signed,
454                Some(venue_position.avg_px),
455            );
456
457            if let Some(opening_px) = reconciliation_price {
458                // Calculate opening quantity needed
459                let opening_qty = if oldest_qty != Decimal::ZERO {
460                    // Work backwards: venue = opening + current fills
461                    venue_qty_signed - oldest_qty
462                } else {
463                    venue_qty_signed
464                };
465
466                if opening_qty.abs() > Decimal::ZERO {
467                    let synthetic_side = if opening_qty > Decimal::ZERO {
468                        OrderSide::Buy
469                    } else {
470                        OrderSide::Sell
471                    };
472
473                    let synthetic_fill = FillSnapshot::new(
474                        first_fill.ts_event.saturating_sub(1),
475                        synthetic_side,
476                        opening_qty.abs(),
477                        opening_px,
478                        first_fill.venue_order_id,
479                    );
480
481                    return FillAdjustmentResult::AddSyntheticOpening {
482                        synthetic_fill,
483                        existing_fills: oldest_lifecycle_fills,
484                    };
485                }
486            }
487        }
488
489        return FillAdjustmentResult::NoAdjustment;
490    }
491
492    // Has one zero-crossing - check if oldest lifecycle closes at zero
493    if oldest_qty == Decimal::ZERO {
494        // Lifecycle closes correctly - no adjustment needed
495        return FillAdjustmentResult::NoAdjustment;
496    }
497
498    // Oldest lifecycle doesn't close at zero - add synthetic opening fill
499    if !oldest_lifecycle_fills.is_empty()
500        && let Some(&first_zero_crossing_ts) = zero_crossings.first()
501    {
502        // Need to add opening fill that makes position close at zero-crossing
503        let current_lifecycle_fills: Vec<FillSnapshot> = fills
504            .iter()
505            .filter(|f| f.ts_event > first_zero_crossing_ts)
506            .cloned()
507            .collect();
508
509        if !current_lifecycle_fills.is_empty()
510            && let Some(first_current_fill) = current_lifecycle_fills.first()
511        {
512            let synthetic_fill = FillSnapshot::new(
513                first_current_fill.ts_event.saturating_sub(1),
514                venue_position.side,
515                venue_position.qty,
516                venue_position.avg_px,
517                first_current_fill.venue_order_id,
518            );
519
520            return FillAdjustmentResult::AddSyntheticOpening {
521                synthetic_fill,
522                existing_fills: oldest_lifecycle_fills,
523            };
524        }
525    }
526
527    FillAdjustmentResult::NoAdjustment
528}
529
530/// Create a synthetic `VenueOrderId` using timestamp and UUID suffix.
531///
532/// Format: `S-{hex_timestamp}-{uuid_prefix}`
533#[must_use]
534pub fn create_synthetic_venue_order_id(ts_event: u64) -> VenueOrderId {
535    let uuid = UUID4::new();
536    let uuid_str = uuid.to_string();
537    let uuid_suffix = &uuid_str[..8];
538    let venue_order_id_value = format!("S-{ts_event:x}-{uuid_suffix}");
539    VenueOrderId::new(&venue_order_id_value)
540}
541
542/// Create a synthetic `TradeId` using timestamp and UUID suffix.
543///
544/// Format: `S-{hex_timestamp}-{uuid_prefix}`
545#[must_use]
546pub fn create_synthetic_trade_id(ts_event: u64) -> TradeId {
547    let uuid = UUID4::new();
548    let uuid_str = uuid.to_string();
549    let uuid_suffix = &uuid_str[..8];
550    let trade_id_value = format!("S-{ts_event:x}-{uuid_suffix}");
551    TradeId::new(&trade_id_value)
552}
553
554/// Create a synthetic `OrderStatusReport` from a `FillSnapshot`.
555///
556/// # Errors
557///
558/// Returns an error if the fill quantity cannot be converted to f64.
559pub fn create_synthetic_order_report(
560    fill: &FillSnapshot,
561    account_id: AccountId,
562    instrument_id: InstrumentId,
563    instrument: &InstrumentAny,
564    venue_order_id: VenueOrderId,
565) -> anyhow::Result<OrderStatusReport> {
566    let qty_f64 = fill
567        .qty
568        .to_f64()
569        .ok_or_else(|| anyhow::anyhow!("Failed to convert quantity to f64"))?;
570    let order_qty = Quantity::new(qty_f64, instrument.size_precision());
571
572    Ok(OrderStatusReport::new(
573        account_id,
574        instrument_id,
575        None, // client_order_id
576        venue_order_id,
577        fill.side,
578        OrderType::Market,
579        TimeInForce::Gtc,
580        OrderStatus::Filled,
581        order_qty,
582        order_qty, // filled_qty = order_qty (fully filled)
583        UnixNanos::from(fill.ts_event),
584        UnixNanos::from(fill.ts_event),
585        UnixNanos::from(fill.ts_event),
586        None, // report_id
587    ))
588}
589
590/// Create a synthetic `FillReport` from a `FillSnapshot`.
591///
592/// # Errors
593///
594/// Returns an error if the fill quantity or price cannot be converted to f64.
595pub fn create_synthetic_fill_report(
596    fill: &FillSnapshot,
597    account_id: AccountId,
598    instrument_id: InstrumentId,
599    instrument: &InstrumentAny,
600    venue_order_id: VenueOrderId,
601) -> anyhow::Result<FillReport> {
602    let trade_id = create_synthetic_trade_id(fill.ts_event);
603
604    let qty_f64 = fill
605        .qty
606        .to_f64()
607        .ok_or_else(|| anyhow::anyhow!("Failed to convert quantity to f64"))?;
608    let px_f64 = fill
609        .px
610        .to_f64()
611        .ok_or_else(|| anyhow::anyhow!("Failed to convert price to f64"))?;
612
613    Ok(FillReport::new(
614        account_id,
615        instrument_id,
616        venue_order_id,
617        trade_id,
618        fill.side,
619        Quantity::new(qty_f64, instrument.size_precision()),
620        Price::new(px_f64, instrument.price_precision()),
621        Money::new(0.0, instrument.quote_currency()),
622        LiquiditySide::NoLiquiditySide,
623        None, // client_order_id
624        None, // venue_position_id
625        fill.ts_event.into(),
626        fill.ts_event.into(),
627        None, // report_id
628    ))
629}
630
631/// Result of processing fill reports for reconciliation.
632#[derive(Debug, Clone)]
633pub struct ReconciliationResult {
634    /// Order status reports keyed by venue order ID.
635    pub orders: AHashMap<VenueOrderId, OrderStatusReport>,
636    /// Fill reports keyed by venue order ID.
637    pub fills: AHashMap<VenueOrderId, Vec<FillReport>>,
638}
639
640const DEFAULT_TOLERANCE: Decimal = Decimal::from_parts(1, 0, 0, false, 4); // 0.0001
641
642/// Process fill reports from a mass status for position reconciliation.
643///
644/// This is the main entry point for position reconciliation. It:
645/// 1. Extracts fills and position for the given instrument
646/// 2. Detects position discrepancies
647/// 3. Returns adjusted order/fill reports ready for processing
648///
649/// # Errors
650///
651/// Returns an error if synthetic report creation fails.
652pub fn process_mass_status_for_reconciliation(
653    mass_status: &ExecutionMassStatus,
654    instrument: &InstrumentAny,
655    tolerance: Option<Decimal>,
656) -> anyhow::Result<ReconciliationResult> {
657    let instrument_id = instrument.id();
658    let account_id = mass_status.account_id;
659    let tol = tolerance.unwrap_or(DEFAULT_TOLERANCE);
660
661    // Get position report for this instrument
662    let position_reports = mass_status.position_reports();
663    let venue_position = match position_reports.get(&instrument_id).and_then(|r| r.first()) {
664        Some(report) => position_report_to_snapshot(report),
665        None => {
666            // No position report - return orders/fills unchanged
667            return Ok(extract_instrument_reports(mass_status, instrument_id));
668        }
669    };
670
671    // Extract and convert fills to snapshots
672    let extracted = extract_fills_for_instrument(mass_status, instrument_id);
673    let fill_snapshots = extracted.snapshots;
674    let mut order_map = extracted.orders;
675    let mut fill_map = extracted.fills;
676
677    if fill_snapshots.is_empty() {
678        return Ok(ReconciliationResult {
679            orders: order_map,
680            fills: fill_map,
681        });
682    }
683
684    // Run adjustment logic
685    let result = adjust_fills_for_partial_window(&fill_snapshots, &venue_position, instrument, tol);
686
687    // Apply adjustments
688    match result {
689        FillAdjustmentResult::NoAdjustment => {}
690
691        FillAdjustmentResult::AddSyntheticOpening {
692            synthetic_fill,
693            existing_fills: _,
694        } => {
695            let venue_order_id = create_synthetic_venue_order_id(synthetic_fill.ts_event);
696            let order = create_synthetic_order_report(
697                &synthetic_fill,
698                account_id,
699                instrument_id,
700                instrument,
701                venue_order_id,
702            )?;
703            let fill = create_synthetic_fill_report(
704                &synthetic_fill,
705                account_id,
706                instrument_id,
707                instrument,
708                venue_order_id,
709            )?;
710
711            order_map.insert(venue_order_id, order);
712            fill_map.entry(venue_order_id).or_default().insert(0, fill);
713        }
714
715        FillAdjustmentResult::ReplaceCurrentLifecycle {
716            synthetic_fill,
717            first_venue_order_id,
718        } => {
719            let order = create_synthetic_order_report(
720                &synthetic_fill,
721                account_id,
722                instrument_id,
723                instrument,
724                first_venue_order_id,
725            )?;
726            let fill = create_synthetic_fill_report(
727                &synthetic_fill,
728                account_id,
729                instrument_id,
730                instrument,
731                first_venue_order_id,
732            )?;
733
734            // Replace with only synthetic
735            order_map.clear();
736            fill_map.clear();
737            order_map.insert(first_venue_order_id, order);
738            fill_map.insert(first_venue_order_id, vec![fill]);
739        }
740
741        FillAdjustmentResult::FilterToCurrentLifecycle {
742            last_zero_crossing_ts,
743            current_lifecycle_fills: _,
744        } => {
745            // Filter fills to current lifecycle
746            for fills in fill_map.values_mut() {
747                fills.retain(|f| f.ts_event.as_u64() > last_zero_crossing_ts);
748            }
749            fill_map.retain(|_, fills| !fills.is_empty());
750
751            // Keep only orders that have fills or are still working
752            let orders_with_fills: ahash::AHashSet<VenueOrderId> =
753                fill_map.keys().copied().collect();
754            order_map.retain(|id, order| {
755                orders_with_fills.contains(id)
756                    || !matches!(
757                        order.order_status,
758                        OrderStatus::Denied
759                            | OrderStatus::Rejected
760                            | OrderStatus::Canceled
761                            | OrderStatus::Expired
762                            | OrderStatus::Filled
763                    )
764            });
765        }
766    }
767
768    Ok(ReconciliationResult {
769        orders: order_map,
770        fills: fill_map,
771    })
772}
773
774/// Convert a position status report to a venue position snapshot.
775fn position_report_to_snapshot(report: &PositionStatusReport) -> VenuePositionSnapshot {
776    let side = match report.position_side {
777        PositionSideSpecified::Long => OrderSide::Buy,
778        PositionSideSpecified::Short => OrderSide::Sell,
779        PositionSideSpecified::Flat => OrderSide::Buy,
780    };
781
782    VenuePositionSnapshot {
783        side,
784        qty: report.quantity.into(),
785        avg_px: report.avg_px_open.unwrap_or(Decimal::ZERO),
786    }
787}
788
789/// Extract orders and fills for a specific instrument from mass status.
790fn extract_instrument_reports(
791    mass_status: &ExecutionMassStatus,
792    instrument_id: InstrumentId,
793) -> ReconciliationResult {
794    let mut orders = AHashMap::new();
795    let mut fills = AHashMap::new();
796
797    for (id, order) in mass_status.order_reports() {
798        if order.instrument_id == instrument_id {
799            orders.insert(id, order.clone());
800        }
801    }
802
803    for (id, fill_list) in mass_status.fill_reports() {
804        let filtered: Vec<_> = fill_list
805            .iter()
806            .filter(|f| f.instrument_id == instrument_id)
807            .cloned()
808            .collect();
809        if !filtered.is_empty() {
810            fills.insert(id, filtered);
811        }
812    }
813
814    ReconciliationResult { orders, fills }
815}
816
817/// Extracted fills and reports for an instrument.
818struct ExtractedFills {
819    snapshots: Vec<FillSnapshot>,
820    orders: AHashMap<VenueOrderId, OrderStatusReport>,
821    fills: AHashMap<VenueOrderId, Vec<FillReport>>,
822}
823
824/// Extract fills for an instrument and convert to snapshots.
825fn extract_fills_for_instrument(
826    mass_status: &ExecutionMassStatus,
827    instrument_id: InstrumentId,
828) -> ExtractedFills {
829    let mut snapshots = Vec::new();
830    let mut order_map = AHashMap::new();
831    let mut fill_map = AHashMap::new();
832
833    // Seed order_map
834    for (id, order) in mass_status.order_reports() {
835        if order.instrument_id == instrument_id {
836            order_map.insert(id, order.clone());
837        }
838    }
839
840    // Extract fills
841    for (venue_order_id, fill_reports) in mass_status.fill_reports() {
842        for fill in fill_reports {
843            if fill.instrument_id == instrument_id {
844                let side = mass_status
845                    .order_reports()
846                    .get(&venue_order_id)
847                    .map_or(fill.order_side, |o| o.order_side);
848
849                snapshots.push(FillSnapshot::new(
850                    fill.ts_event.as_u64(),
851                    side,
852                    fill.last_qty.into(),
853                    fill.last_px.into(),
854                    venue_order_id,
855                ));
856
857                fill_map
858                    .entry(venue_order_id)
859                    .or_insert_with(Vec::new)
860                    .push(fill.clone());
861            }
862        }
863    }
864
865    // Sort chronologically
866    snapshots.sort_by_key(|f| f.ts_event);
867
868    ExtractedFills {
869        snapshots,
870        orders: order_map,
871        fills: fill_map,
872    }
873}
874
875#[cfg(test)]
876mod tests {
877    use nautilus_model::instruments::stubs::audusd_sim;
878    use rstest::{fixture, rstest};
879    use rust_decimal_macros::dec;
880
881    use super::*;
882
883    #[fixture]
884    fn instrument() -> InstrumentAny {
885        InstrumentAny::CurrencyPair(audusd_sim())
886    }
887
888    fn create_test_venue_order_id(value: &str) -> VenueOrderId {
889        VenueOrderId::new(value)
890    }
891
892    #[rstest]
893    fn test_fill_snapshot_direction() {
894        let venue_order_id = create_test_venue_order_id("ORDER1");
895        let buy_fill = FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id);
896        assert_eq!(buy_fill.direction(), 1);
897
898        let sell_fill =
899            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id);
900        assert_eq!(sell_fill.direction(), -1);
901    }
902
903    #[rstest]
904    fn test_simulate_position_accumulate_long() {
905        let venue_order_id = create_test_venue_order_id("ORDER1");
906        let fills = vec![
907            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
908            FillSnapshot::new(2000, OrderSide::Buy, dec!(5), dec!(102), venue_order_id),
909        ];
910
911        let (qty, value) = simulate_position(&fills);
912        assert_eq!(qty, dec!(15));
913        assert_eq!(value, dec!(1510)); // 10*100 + 5*102
914    }
915
916    #[rstest]
917    fn test_simulate_position_close_and_flip() {
918        let venue_order_id = create_test_venue_order_id("ORDER1");
919        let fills = vec![
920            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
921            FillSnapshot::new(2000, OrderSide::Sell, dec!(15), dec!(102), venue_order_id),
922        ];
923
924        let (qty, value) = simulate_position(&fills);
925        assert_eq!(qty, dec!(-5)); // Flipped from +10 to -5
926        assert_eq!(value, dec!(510)); // Remaining 5 @ 102
927    }
928
929    #[rstest]
930    fn test_simulate_position_partial_close() {
931        let venue_order_id = create_test_venue_order_id("ORDER1");
932        let fills = vec![
933            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
934            FillSnapshot::new(2000, OrderSide::Sell, dec!(5), dec!(102), venue_order_id),
935        ];
936
937        let (qty, value) = simulate_position(&fills);
938        assert_eq!(qty, dec!(5));
939        assert_eq!(value, dec!(500)); // Reduced proportionally: 1000 * (1 - 5/10) = 500
940
941        // Verify average price is maintained
942        let avg_px = value / qty;
943        assert_eq!(avg_px, dec!(100));
944    }
945
946    #[rstest]
947    fn test_simulate_position_multiple_partial_closes() {
948        let venue_order_id = create_test_venue_order_id("ORDER1");
949        let fills = vec![
950            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(10.0), venue_order_id),
951            FillSnapshot::new(2000, OrderSide::Sell, dec!(25), dec!(11.0), venue_order_id), // Close 25%
952            FillSnapshot::new(3000, OrderSide::Sell, dec!(25), dec!(12.0), venue_order_id), // Close another 25%
953        ];
954
955        let (qty, value) = simulate_position(&fills);
956        assert_eq!(qty, dec!(50));
957        // After first close: value = 1000 * (1 - 25/100) = 1000 * 0.75 = 750
958        // After second close: value = 750 * (1 - 25/75) = 750 * (50/75) = 500
959        // Due to decimal precision, we check it's close to 500
960        assert!((value - dec!(500)).abs() < dec!(0.01));
961
962        // Verify average price is maintained at 10.0
963        let avg_px = value / qty;
964        assert!((avg_px - dec!(10.0)).abs() < dec!(0.01));
965    }
966
967    #[rstest]
968    fn test_simulate_position_short_partial_close() {
969        let venue_order_id = create_test_venue_order_id("ORDER1");
970        let fills = vec![
971            FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
972            FillSnapshot::new(2000, OrderSide::Buy, dec!(5), dec!(98), venue_order_id), // Partial close
973        ];
974
975        let (qty, value) = simulate_position(&fills);
976        assert_eq!(qty, dec!(-5));
977        assert_eq!(value, dec!(500)); // Reduced proportionally: 1000 * (1 - 5/10) = 500
978
979        // Verify average price is maintained
980        let avg_px = value / qty.abs();
981        assert_eq!(avg_px, dec!(100));
982    }
983
984    #[rstest]
985    fn test_detect_zero_crossings() {
986        let venue_order_id = create_test_venue_order_id("ORDER1");
987        let fills = vec![
988            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
989            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), venue_order_id), // Close to zero
990            FillSnapshot::new(3000, OrderSide::Buy, dec!(5), dec!(103), venue_order_id),
991            FillSnapshot::new(4000, OrderSide::Sell, dec!(5), dec!(104), venue_order_id), // Close to zero again
992        ];
993
994        let crossings = detect_zero_crossings(&fills);
995        assert_eq!(crossings.len(), 2);
996        assert_eq!(crossings[0], 2000);
997        assert_eq!(crossings[1], 4000);
998    }
999
1000    #[rstest]
1001    fn test_check_position_match_exact() {
1002        let result = check_position_match(dec!(10), dec!(1000), dec!(10), dec!(100), dec!(0.0001));
1003        assert!(result);
1004    }
1005
1006    #[rstest]
1007    fn test_check_position_match_within_tolerance() {
1008        // Simulated avg px = 1000/10 = 100, venue = 100.005
1009        // Relative diff = 0.005 / 100.005 = 0.00004999 < 0.0001
1010        let result =
1011            check_position_match(dec!(10), dec!(1000), dec!(10), dec!(100.005), dec!(0.0001));
1012        assert!(result);
1013    }
1014
1015    #[rstest]
1016    fn test_check_position_match_qty_mismatch() {
1017        let result = check_position_match(dec!(10), dec!(1000), dec!(11), dec!(100), dec!(0.0001));
1018        assert!(!result);
1019    }
1020
1021    #[rstest]
1022    fn test_check_position_match_both_flat() {
1023        let result = check_position_match(dec!(0), dec!(0), dec!(0), dec!(0), dec!(0.0001));
1024        assert!(result);
1025    }
1026
1027    #[rstest]
1028    fn test_reconciliation_price_flat_to_long(_instrument: InstrumentAny) {
1029        let result = calculate_reconciliation_price(dec!(0), None, dec!(10), Some(dec!(100)));
1030        assert!(result.is_some());
1031        assert_eq!(result.unwrap(), dec!(100));
1032    }
1033
1034    #[rstest]
1035    fn test_reconciliation_price_no_target_avg_px(_instrument: InstrumentAny) {
1036        let result = calculate_reconciliation_price(dec!(5), Some(dec!(100)), dec!(10), None);
1037        assert!(result.is_none());
1038    }
1039
1040    #[rstest]
1041    fn test_reconciliation_price_no_quantity_change(_instrument: InstrumentAny) {
1042        let result =
1043            calculate_reconciliation_price(dec!(10), Some(dec!(100)), dec!(10), Some(dec!(105)));
1044        assert!(result.is_none());
1045    }
1046
1047    #[rstest]
1048    fn test_reconciliation_price_long_position_increase(_instrument: InstrumentAny) {
1049        let result =
1050            calculate_reconciliation_price(dec!(10), Some(dec!(100)), dec!(15), Some(dec!(102)));
1051        assert!(result.is_some());
1052        // Expected: (15 * 102 - 10 * 100) / 5 = (1530 - 1000) / 5 = 106
1053        assert_eq!(result.unwrap(), dec!(106));
1054    }
1055
1056    #[rstest]
1057    fn test_reconciliation_price_flat_to_short(_instrument: InstrumentAny) {
1058        let result = calculate_reconciliation_price(dec!(0), None, dec!(-10), Some(dec!(100)));
1059        assert!(result.is_some());
1060        assert_eq!(result.unwrap(), dec!(100));
1061    }
1062
1063    #[rstest]
1064    fn test_reconciliation_price_long_to_flat(_instrument: InstrumentAny) {
1065        // Close long position to flat: 100 @ 1.20 to 0
1066        // When closing to flat, reconciliation price equals current average price
1067        let result =
1068            calculate_reconciliation_price(dec!(100), Some(dec!(1.20)), dec!(0), Some(dec!(0)));
1069        assert!(result.is_some());
1070        assert_eq!(result.unwrap(), dec!(1.20));
1071    }
1072
1073    #[rstest]
1074    fn test_reconciliation_price_short_to_flat(_instrument: InstrumentAny) {
1075        // Close short position to flat: -50 @ 2.50 to 0
1076        // When closing to flat, reconciliation price equals current average price
1077        let result = calculate_reconciliation_price(dec!(-50), Some(dec!(2.50)), dec!(0), None);
1078        assert!(result.is_some());
1079        assert_eq!(result.unwrap(), dec!(2.50));
1080    }
1081
1082    #[rstest]
1083    fn test_reconciliation_price_short_position_increase(_instrument: InstrumentAny) {
1084        // Short position increase: -100 @ 1.30 to -200 @ 1.28
1085        // (−200 × 1.28) = (−100 × 1.30) + (−100 × reconciliation_px)
1086        // −256 = −130 + (−100 × reconciliation_px)
1087        // reconciliation_px = 1.26
1088        let result = calculate_reconciliation_price(
1089            dec!(-100),
1090            Some(dec!(1.30)),
1091            dec!(-200),
1092            Some(dec!(1.28)),
1093        );
1094        assert!(result.is_some());
1095        assert_eq!(result.unwrap(), dec!(1.26));
1096    }
1097
1098    #[rstest]
1099    fn test_reconciliation_price_long_position_decrease(_instrument: InstrumentAny) {
1100        // Long position decrease: 200 @ 1.20 to 100 @ 1.20
1101        let result = calculate_reconciliation_price(
1102            dec!(200),
1103            Some(dec!(1.20)),
1104            dec!(100),
1105            Some(dec!(1.20)),
1106        );
1107        assert!(result.is_some());
1108        assert_eq!(result.unwrap(), dec!(1.20));
1109    }
1110
1111    #[rstest]
1112    fn test_reconciliation_price_long_to_short_flip(_instrument: InstrumentAny) {
1113        // Long to short flip: 100 @ 1.20 to -100 @ 1.25
1114        // Due to netting simulation resetting value on flip, reconciliation_px = target_avg_px
1115        let result = calculate_reconciliation_price(
1116            dec!(100),
1117            Some(dec!(1.20)),
1118            dec!(-100),
1119            Some(dec!(1.25)),
1120        );
1121        assert!(result.is_some());
1122        assert_eq!(result.unwrap(), dec!(1.25));
1123    }
1124
1125    #[rstest]
1126    fn test_reconciliation_price_short_to_long_flip(_instrument: InstrumentAny) {
1127        // Short to long flip: -100 @ 1.30 to 100 @ 1.25
1128        // Due to netting simulation resetting value on flip, reconciliation_px = target_avg_px
1129        let result = calculate_reconciliation_price(
1130            dec!(-100),
1131            Some(dec!(1.30)),
1132            dec!(100),
1133            Some(dec!(1.25)),
1134        );
1135        assert!(result.is_some());
1136        assert_eq!(result.unwrap(), dec!(1.25));
1137    }
1138
1139    #[rstest]
1140    fn test_reconciliation_price_complex_scenario(_instrument: InstrumentAny) {
1141        // Complex: 150 @ 1.23456 to 250 @ 1.24567
1142        // (250 × 1.24567) = (150 × 1.23456) + (100 × reconciliation_px)
1143        // 311.4175 = 185.184 + (100 × reconciliation_px)
1144        // reconciliation_px = 1.262335
1145        let result = calculate_reconciliation_price(
1146            dec!(150),
1147            Some(dec!(1.23456)),
1148            dec!(250),
1149            Some(dec!(1.24567)),
1150        );
1151        assert!(result.is_some());
1152        assert_eq!(result.unwrap(), dec!(1.262335));
1153    }
1154
1155    #[rstest]
1156    fn test_reconciliation_price_zero_target_avg_px(_instrument: InstrumentAny) {
1157        let result =
1158            calculate_reconciliation_price(dec!(100), Some(dec!(1.20)), dec!(200), Some(dec!(0)));
1159        assert!(result.is_none());
1160    }
1161
1162    #[rstest]
1163    fn test_reconciliation_price_negative_price(_instrument: InstrumentAny) {
1164        // Negative price calculation: 100 @ 2.00 to 200 @ 1.00
1165        // (200 × 1.00) = (100 × 2.00) + (100 × reconciliation_px)
1166        // 200 = 200 + (100 × reconciliation_px)
1167        // reconciliation_px = 0 (should return None as price must be positive)
1168        let result = calculate_reconciliation_price(
1169            dec!(100),
1170            Some(dec!(2.00)),
1171            dec!(200),
1172            Some(dec!(1.00)),
1173        );
1174        assert!(result.is_none());
1175    }
1176
1177    #[rstest]
1178    fn test_reconciliation_price_flip_simulation_compatibility() {
1179        let venue_order_id = create_test_venue_order_id("ORDER1");
1180        // Start with long position: 100 @ 1.20
1181        // Target: -100 @ 1.25
1182        // Calculate reconciliation price
1183        let recon_px = calculate_reconciliation_price(
1184            dec!(100),
1185            Some(dec!(1.20)),
1186            dec!(-100),
1187            Some(dec!(1.25)),
1188        )
1189        .expect("reconciliation price");
1190
1191        assert_eq!(recon_px, dec!(1.25));
1192
1193        // Simulate the flip with reconciliation fill (sell 200 to go from +100 to -100)
1194        let fills = vec![
1195            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
1196            FillSnapshot::new(2000, OrderSide::Sell, dec!(200), recon_px, venue_order_id),
1197        ];
1198
1199        let (final_qty, final_value) = simulate_position(&fills);
1200        assert_eq!(final_qty, dec!(-100));
1201        let final_avg = final_value / final_qty.abs();
1202        assert_eq!(final_avg, dec!(1.25), "Final average should match target");
1203    }
1204
1205    #[rstest]
1206    fn test_reconciliation_price_accumulation_simulation_compatibility() {
1207        let venue_order_id = create_test_venue_order_id("ORDER1");
1208        // Start with long position: 100 @ 1.20
1209        // Target: 200 @ 1.22
1210        let recon_px = calculate_reconciliation_price(
1211            dec!(100),
1212            Some(dec!(1.20)),
1213            dec!(200),
1214            Some(dec!(1.22)),
1215        )
1216        .expect("reconciliation price");
1217
1218        // Simulate accumulation with reconciliation fill
1219        let fills = vec![
1220            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
1221            FillSnapshot::new(2000, OrderSide::Buy, dec!(100), recon_px, venue_order_id),
1222        ];
1223
1224        let (final_qty, final_value) = simulate_position(&fills);
1225        assert_eq!(final_qty, dec!(200));
1226        let final_avg = final_value / final_qty.abs();
1227        assert_eq!(final_avg, dec!(1.22), "Final average should match target");
1228    }
1229
1230    #[rstest]
1231    fn test_simulate_position_accumulate_short() {
1232        let venue_order_id = create_test_venue_order_id("ORDER1");
1233        let fills = vec![
1234            FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
1235            FillSnapshot::new(2000, OrderSide::Sell, dec!(5), dec!(98), venue_order_id),
1236        ];
1237
1238        let (qty, value) = simulate_position(&fills);
1239        assert_eq!(qty, dec!(-15));
1240        assert_eq!(value, dec!(1490)); // 10*100 + 5*98
1241    }
1242
1243    #[rstest]
1244    fn test_simulate_position_short_to_long_flip() {
1245        let venue_order_id = create_test_venue_order_id("ORDER1");
1246        let fills = vec![
1247            FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
1248            FillSnapshot::new(2000, OrderSide::Buy, dec!(15), dec!(102), venue_order_id),
1249        ];
1250
1251        let (qty, value) = simulate_position(&fills);
1252        assert_eq!(qty, dec!(5)); // Flipped from -10 to +5
1253        assert_eq!(value, dec!(510)); // Remaining 5 @ 102
1254    }
1255
1256    #[rstest]
1257    fn test_simulate_position_multiple_flips() {
1258        let venue_order_id = create_test_venue_order_id("ORDER1");
1259        let fills = vec![
1260            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1261            FillSnapshot::new(2000, OrderSide::Sell, dec!(15), dec!(105), venue_order_id), // Flip to -5
1262            FillSnapshot::new(3000, OrderSide::Buy, dec!(10), dec!(110), venue_order_id), // Flip to +5
1263        ];
1264
1265        let (qty, value) = simulate_position(&fills);
1266        assert_eq!(qty, dec!(5)); // Final position: +5
1267        assert_eq!(value, dec!(550)); // 5 @ 110
1268    }
1269
1270    #[rstest]
1271    fn test_simulate_position_empty_fills() {
1272        let fills: Vec<FillSnapshot> = vec![];
1273        let (qty, value) = simulate_position(&fills);
1274        assert_eq!(qty, dec!(0));
1275        assert_eq!(value, dec!(0));
1276    }
1277
1278    #[rstest]
1279    fn test_detect_zero_crossings_no_crossings() {
1280        let venue_order_id = create_test_venue_order_id("ORDER1");
1281        let fills = vec![
1282            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1283            FillSnapshot::new(2000, OrderSide::Buy, dec!(5), dec!(102), venue_order_id),
1284        ];
1285
1286        let crossings = detect_zero_crossings(&fills);
1287        assert_eq!(crossings.len(), 0);
1288    }
1289
1290    #[rstest]
1291    fn test_detect_zero_crossings_single_crossing() {
1292        let venue_order_id = create_test_venue_order_id("ORDER1");
1293        let fills = vec![
1294            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1295            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), venue_order_id), // Close to zero
1296        ];
1297
1298        let crossings = detect_zero_crossings(&fills);
1299        assert_eq!(crossings.len(), 1);
1300        assert_eq!(crossings[0], 2000);
1301    }
1302
1303    #[rstest]
1304    fn test_detect_zero_crossings_empty_fills() {
1305        let fills: Vec<FillSnapshot> = vec![];
1306        let crossings = detect_zero_crossings(&fills);
1307        assert_eq!(crossings.len(), 0);
1308    }
1309
1310    #[rstest]
1311    fn test_detect_zero_crossings_long_to_short_flip() {
1312        let venue_order_id = create_test_venue_order_id("ORDER1");
1313        // Buy 10, then Sell 15 -> flip from +10 to -5
1314        let fills = vec![
1315            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1316            FillSnapshot::new(2000, OrderSide::Sell, dec!(15), dec!(102), venue_order_id), // Flip
1317        ];
1318
1319        let crossings = detect_zero_crossings(&fills);
1320        assert_eq!(crossings.len(), 1);
1321        assert_eq!(crossings[0], 2000); // Detected the flip
1322    }
1323
1324    #[rstest]
1325    fn test_detect_zero_crossings_short_to_long_flip() {
1326        let venue_order_id = create_test_venue_order_id("ORDER1");
1327        // Sell 10, then Buy 20 -> flip from -10 to +10
1328        let fills = vec![
1329            FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
1330            FillSnapshot::new(2000, OrderSide::Buy, dec!(20), dec!(102), venue_order_id), // Flip
1331        ];
1332
1333        let crossings = detect_zero_crossings(&fills);
1334        assert_eq!(crossings.len(), 1);
1335        assert_eq!(crossings[0], 2000);
1336    }
1337
1338    #[rstest]
1339    fn test_detect_zero_crossings_multiple_flips() {
1340        let venue_order_id = create_test_venue_order_id("ORDER1");
1341        let fills = vec![
1342            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1343            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), venue_order_id), // Land on zero
1344            FillSnapshot::new(3000, OrderSide::Sell, dec!(5), dec!(103), venue_order_id), // Go short
1345            FillSnapshot::new(4000, OrderSide::Buy, dec!(15), dec!(104), venue_order_id), // Flip to long
1346        ];
1347
1348        let crossings = detect_zero_crossings(&fills);
1349        assert_eq!(crossings.len(), 2);
1350        assert_eq!(crossings[0], 2000); // First zero-crossing (land on zero)
1351        assert_eq!(crossings[1], 4000); // Second zero-crossing (flip)
1352    }
1353
1354    #[rstest]
1355    fn test_check_position_match_outside_tolerance() {
1356        // Simulated avg px = 1000/10 = 100, venue = 101
1357        // Relative diff = 1 / 101 = 0.0099 > 0.0001
1358        let result = check_position_match(dec!(10), dec!(1000), dec!(10), dec!(101), dec!(0.0001));
1359        assert!(!result);
1360    }
1361
1362    #[rstest]
1363    fn test_check_position_match_edge_of_tolerance() {
1364        // Simulated avg px = 1000/10 = 100, venue = 100.01
1365        // Relative diff = 0.01 / 100.01 = 0.00009999 < 0.0001
1366        let result =
1367            check_position_match(dec!(10), dec!(1000), dec!(10), dec!(100.01), dec!(0.0001));
1368        assert!(result);
1369    }
1370
1371    #[rstest]
1372    fn test_check_position_match_zero_venue_avg_px() {
1373        let result = check_position_match(dec!(10), dec!(1000), dec!(10), dec!(0), dec!(0.0001));
1374        assert!(!result); // Should fail because relative diff calculation with zero denominator
1375    }
1376
1377    #[rstest]
1378    fn test_adjust_fills_no_fills() {
1379        let venue_position = VenuePositionSnapshot {
1380            side: OrderSide::Buy,
1381            qty: dec!(0.02),
1382            avg_px: dec!(4100.00),
1383        };
1384        let instrument = instrument();
1385        let result =
1386            adjust_fills_for_partial_window(&[], &venue_position, &instrument, dec!(0.0001));
1387        assert!(matches!(result, FillAdjustmentResult::NoAdjustment));
1388    }
1389
1390    #[rstest]
1391    fn test_adjust_fills_flat_position() {
1392        let venue_order_id = create_test_venue_order_id("ORDER1");
1393        let fills = vec![FillSnapshot::new(
1394            1000,
1395            OrderSide::Buy,
1396            dec!(0.01),
1397            dec!(4100.00),
1398            venue_order_id,
1399        )];
1400        let venue_position = VenuePositionSnapshot {
1401            side: OrderSide::Buy,
1402            qty: dec!(0),
1403            avg_px: dec!(0),
1404        };
1405        let instrument = instrument();
1406        let result =
1407            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1408        assert!(matches!(result, FillAdjustmentResult::NoAdjustment));
1409    }
1410
1411    #[rstest]
1412    fn test_adjust_fills_complete_lifecycle_no_adjustment() {
1413        let venue_order_id = create_test_venue_order_id("ORDER1");
1414        let venue_order_id2 = create_test_venue_order_id("ORDER2");
1415        let fills = vec![
1416            FillSnapshot::new(
1417                1000,
1418                OrderSide::Buy,
1419                dec!(0.01),
1420                dec!(4100.00),
1421                venue_order_id,
1422            ),
1423            FillSnapshot::new(
1424                2000,
1425                OrderSide::Buy,
1426                dec!(0.01),
1427                dec!(4100.00),
1428                venue_order_id2,
1429            ),
1430        ];
1431        let venue_position = VenuePositionSnapshot {
1432            side: OrderSide::Buy,
1433            qty: dec!(0.02),
1434            avg_px: dec!(4100.00),
1435        };
1436        let instrument = instrument();
1437        let result =
1438            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1439        assert!(matches!(result, FillAdjustmentResult::NoAdjustment));
1440    }
1441
1442    #[rstest]
1443    fn test_adjust_fills_incomplete_lifecycle_adds_synthetic() {
1444        let venue_order_id = create_test_venue_order_id("ORDER1");
1445        // Window only sees +0.02 @ 4200, but venue has 0.04 @ 4100
1446        let fills = vec![FillSnapshot::new(
1447            2000,
1448            OrderSide::Buy,
1449            dec!(0.02),
1450            dec!(4200.00),
1451            venue_order_id,
1452        )];
1453        let venue_position = VenuePositionSnapshot {
1454            side: OrderSide::Buy,
1455            qty: dec!(0.04),
1456            avg_px: dec!(4100.00),
1457        };
1458        let instrument = instrument();
1459        let result =
1460            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1461
1462        match result {
1463            FillAdjustmentResult::AddSyntheticOpening {
1464                synthetic_fill,
1465                existing_fills,
1466            } => {
1467                assert_eq!(synthetic_fill.side, OrderSide::Buy);
1468                assert_eq!(synthetic_fill.qty, dec!(0.02)); // Missing 0.02
1469                assert_eq!(existing_fills.len(), 1);
1470            }
1471            _ => panic!("Expected AddSyntheticOpening"),
1472        }
1473    }
1474
1475    #[rstest]
1476    fn test_adjust_fills_with_zero_crossings() {
1477        let venue_order_id1 = create_test_venue_order_id("ORDER1");
1478        let venue_order_id2 = create_test_venue_order_id("ORDER2");
1479        let venue_order_id3 = create_test_venue_order_id("ORDER3");
1480
1481        // Lifecycle 1: LONG 0.02 -> FLAT (zero-crossing at 2000)
1482        // Lifecycle 2: LONG 0.03 (current)
1483        let fills = vec![
1484            FillSnapshot::new(
1485                1000,
1486                OrderSide::Buy,
1487                dec!(0.02),
1488                dec!(4100.00),
1489                venue_order_id1,
1490            ),
1491            FillSnapshot::new(
1492                2000,
1493                OrderSide::Sell,
1494                dec!(0.02),
1495                dec!(4150.00),
1496                venue_order_id2,
1497            ), // Zero-crossing
1498            FillSnapshot::new(
1499                3000,
1500                OrderSide::Buy,
1501                dec!(0.03),
1502                dec!(4200.00),
1503                venue_order_id3,
1504            ), // Current lifecycle
1505        ];
1506
1507        let venue_position = VenuePositionSnapshot {
1508            side: OrderSide::Buy,
1509            qty: dec!(0.03),
1510            avg_px: dec!(4200.00),
1511        };
1512
1513        let instrument = instrument();
1514        let result =
1515            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1516
1517        // Should filter to current lifecycle only
1518        match result {
1519            FillAdjustmentResult::FilterToCurrentLifecycle {
1520                last_zero_crossing_ts,
1521                current_lifecycle_fills,
1522            } => {
1523                assert_eq!(last_zero_crossing_ts, 2000);
1524                assert_eq!(current_lifecycle_fills.len(), 1);
1525                assert_eq!(current_lifecycle_fills[0].venue_order_id, venue_order_id3);
1526            }
1527            _ => panic!("Expected FilterToCurrentLifecycle, was {result:?}"),
1528        }
1529    }
1530
1531    #[rstest]
1532    fn test_adjust_fills_multiple_zero_crossings_mismatch() {
1533        let venue_order_id1 = create_test_venue_order_id("ORDER1");
1534        let venue_order_id2 = create_test_venue_order_id("ORDER2");
1535        let _venue_order_id3 = create_test_venue_order_id("ORDER3");
1536        let venue_order_id4 = create_test_venue_order_id("ORDER4");
1537        let venue_order_id5 = create_test_venue_order_id("ORDER5");
1538
1539        // Lifecycle 1: LONG 0.05 -> FLAT
1540        // Lifecycle 2: Current fills produce 0.10 @ 4050, but venue has 0.05 @ 4142.04
1541        let fills = vec![
1542            FillSnapshot::new(
1543                1000,
1544                OrderSide::Buy,
1545                dec!(0.05),
1546                dec!(4000.00),
1547                venue_order_id1,
1548            ),
1549            FillSnapshot::new(
1550                2000,
1551                OrderSide::Sell,
1552                dec!(0.05),
1553                dec!(4050.00),
1554                venue_order_id2,
1555            ), // Zero-crossing
1556            FillSnapshot::new(
1557                3000,
1558                OrderSide::Buy,
1559                dec!(0.05),
1560                dec!(4000.00),
1561                venue_order_id4,
1562            ), // Current lifecycle
1563            FillSnapshot::new(
1564                4000,
1565                OrderSide::Buy,
1566                dec!(0.05),
1567                dec!(4100.00),
1568                venue_order_id5,
1569            ), // Current lifecycle
1570        ];
1571
1572        let venue_position = VenuePositionSnapshot {
1573            side: OrderSide::Buy,
1574            qty: dec!(0.05),
1575            avg_px: dec!(4142.04),
1576        };
1577
1578        let instrument = instrument();
1579        let result =
1580            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1581
1582        // Should replace current lifecycle with synthetic
1583        match result {
1584            FillAdjustmentResult::ReplaceCurrentLifecycle {
1585                synthetic_fill,
1586                first_venue_order_id,
1587            } => {
1588                assert_eq!(synthetic_fill.qty, dec!(0.05));
1589                assert_eq!(synthetic_fill.px, dec!(4142.04));
1590                assert_eq!(synthetic_fill.side, OrderSide::Buy);
1591                assert_eq!(first_venue_order_id, venue_order_id4);
1592            }
1593            _ => panic!("Expected ReplaceCurrentLifecycle, was {result:?}"),
1594        }
1595    }
1596
1597    #[rstest]
1598    fn test_adjust_fills_short_position() {
1599        let venue_order_id = create_test_venue_order_id("ORDER1");
1600
1601        // Window only sees SELL 0.02 @ 4120, but venue has -0.05 @ 4100
1602        let fills = vec![FillSnapshot::new(
1603            1000,
1604            OrderSide::Sell,
1605            dec!(0.02),
1606            dec!(4120.00),
1607            venue_order_id,
1608        )];
1609
1610        let venue_position = VenuePositionSnapshot {
1611            side: OrderSide::Sell,
1612            qty: dec!(0.05),
1613            avg_px: dec!(4100.00),
1614        };
1615
1616        let instrument = instrument();
1617        let result =
1618            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1619
1620        // Should add synthetic opening SHORT fill
1621        match result {
1622            FillAdjustmentResult::AddSyntheticOpening {
1623                synthetic_fill,
1624                existing_fills,
1625            } => {
1626                assert_eq!(synthetic_fill.side, OrderSide::Sell);
1627                assert_eq!(synthetic_fill.qty, dec!(0.03)); // Missing 0.03
1628                assert_eq!(existing_fills.len(), 1);
1629            }
1630            _ => panic!("Expected AddSyntheticOpening, was {result:?}"),
1631        }
1632    }
1633
1634    #[rstest]
1635    fn test_adjust_fills_timestamp_underflow_protection() {
1636        let venue_order_id = create_test_venue_order_id("ORDER1");
1637
1638        // First fill at timestamp 0 - saturating_sub should prevent underflow
1639        let fills = vec![FillSnapshot::new(
1640            0,
1641            OrderSide::Buy,
1642            dec!(0.01),
1643            dec!(4100.00),
1644            venue_order_id,
1645        )];
1646
1647        let venue_position = VenuePositionSnapshot {
1648            side: OrderSide::Buy,
1649            qty: dec!(0.02),
1650            avg_px: dec!(4100.00),
1651        };
1652
1653        let instrument = instrument();
1654        let result =
1655            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1656
1657        // Should add synthetic fill with timestamp 0 (not u64::MAX)
1658        match result {
1659            FillAdjustmentResult::AddSyntheticOpening { synthetic_fill, .. } => {
1660                assert_eq!(synthetic_fill.ts_event, 0); // saturating_sub(1) from 0 = 0
1661            }
1662            _ => panic!("Expected AddSyntheticOpening, was {result:?}"),
1663        }
1664    }
1665
1666    #[rstest]
1667    fn test_adjust_fills_with_flip_scenario() {
1668        let venue_order_id1 = create_test_venue_order_id("ORDER1");
1669        let venue_order_id2 = create_test_venue_order_id("ORDER2");
1670
1671        // Long 10 @ 100, then Sell 20 @ 105 -> flip to Short 10 @ 105
1672        let fills = vec![
1673            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id1),
1674            FillSnapshot::new(2000, OrderSide::Sell, dec!(20), dec!(105), venue_order_id2), // Flip
1675        ];
1676
1677        let venue_position = VenuePositionSnapshot {
1678            side: OrderSide::Sell,
1679            qty: dec!(10),
1680            avg_px: dec!(105),
1681        };
1682
1683        let instrument = instrument();
1684        let result =
1685            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1686
1687        // Should recognize the flip and match correctly
1688        match result {
1689            FillAdjustmentResult::NoAdjustment => {
1690                // Verify simulation matches
1691                let (qty, value) = simulate_position(&fills);
1692                assert_eq!(qty, dec!(-10));
1693                let avg = value / qty.abs();
1694                assert_eq!(avg, dec!(105));
1695            }
1696            _ => panic!("Expected NoAdjustment for matching flip, was {result:?}"),
1697        }
1698    }
1699
1700    #[rstest]
1701    fn test_detect_zero_crossings_complex_lifecycle() {
1702        let venue_order_id = create_test_venue_order_id("ORDER1");
1703        // Complex scenario with multiple lifecycles
1704        let fills = vec![
1705            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
1706            FillSnapshot::new(2000, OrderSide::Sell, dec!(50), dec!(1.25), venue_order_id), // Reduce
1707            FillSnapshot::new(3000, OrderSide::Sell, dec!(100), dec!(1.30), venue_order_id), // Flip to -50
1708            FillSnapshot::new(4000, OrderSide::Buy, dec!(50), dec!(1.28), venue_order_id), // Close to zero
1709            FillSnapshot::new(5000, OrderSide::Buy, dec!(75), dec!(1.22), venue_order_id), // Open long
1710            FillSnapshot::new(6000, OrderSide::Sell, dec!(150), dec!(1.24), venue_order_id), // Flip to -75
1711        ];
1712
1713        let crossings = detect_zero_crossings(&fills);
1714        assert_eq!(crossings.len(), 3);
1715        assert_eq!(crossings[0], 3000); // First flip
1716        assert_eq!(crossings[1], 4000); // Close to zero
1717        assert_eq!(crossings[2], 6000); // Second flip
1718    }
1719
1720    #[rstest]
1721    fn test_reconciliation_price_partial_close() {
1722        let venue_order_id = create_test_venue_order_id("ORDER1");
1723        // Partial close scenario: 100 @ 1.20 to 50 @ 1.20
1724        let recon_px =
1725            calculate_reconciliation_price(dec!(100), Some(dec!(1.20)), dec!(50), Some(dec!(1.20)))
1726                .expect("reconciliation price");
1727
1728        // Simulate partial close
1729        let fills = vec![
1730            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
1731            FillSnapshot::new(2000, OrderSide::Sell, dec!(50), recon_px, venue_order_id),
1732        ];
1733
1734        let (final_qty, final_value) = simulate_position(&fills);
1735        assert_eq!(final_qty, dec!(50));
1736        let final_avg = final_value / final_qty.abs();
1737        assert_eq!(final_avg, dec!(1.20), "Average should be maintained");
1738    }
1739
1740    #[rstest]
1741    fn test_detect_zero_crossings_identical_timestamps() {
1742        let venue_order_id1 = create_test_venue_order_id("ORDER1");
1743        let venue_order_id2 = create_test_venue_order_id("ORDER2");
1744
1745        // Two fills with identical timestamps - should process deterministically
1746        let fills = vec![
1747            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id1),
1748            FillSnapshot::new(2000, OrderSide::Sell, dec!(5), dec!(102), venue_order_id1),
1749            FillSnapshot::new(2000, OrderSide::Sell, dec!(5), dec!(103), venue_order_id2), // Same ts
1750        ];
1751
1752        let crossings = detect_zero_crossings(&fills);
1753
1754        // Position: +10 -> +5 -> 0 (zero crossing at last fill)
1755        assert_eq!(crossings.len(), 1);
1756        assert_eq!(crossings[0], 2000);
1757
1758        // Verify final position is flat
1759        let (qty, _) = simulate_position(&fills);
1760        assert_eq!(qty, dec!(0));
1761    }
1762
1763    #[rstest]
1764    fn test_detect_zero_crossings_five_lifecycles() {
1765        let venue_order_id = create_test_venue_order_id("ORDER1");
1766
1767        // Five complete position lifecycles: open->close repeated 5 times
1768        let fills = vec![
1769            // Lifecycle 1: Long
1770            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1771            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(101), venue_order_id),
1772            // Lifecycle 2: Short
1773            FillSnapshot::new(3000, OrderSide::Sell, dec!(20), dec!(102), venue_order_id),
1774            FillSnapshot::new(4000, OrderSide::Buy, dec!(20), dec!(101), venue_order_id),
1775            // Lifecycle 3: Long
1776            FillSnapshot::new(5000, OrderSide::Buy, dec!(15), dec!(103), venue_order_id),
1777            FillSnapshot::new(6000, OrderSide::Sell, dec!(15), dec!(104), venue_order_id),
1778            // Lifecycle 4: Short
1779            FillSnapshot::new(7000, OrderSide::Sell, dec!(25), dec!(105), venue_order_id),
1780            FillSnapshot::new(8000, OrderSide::Buy, dec!(25), dec!(104), venue_order_id),
1781            // Lifecycle 5: Long (still open)
1782            FillSnapshot::new(9000, OrderSide::Buy, dec!(30), dec!(106), venue_order_id),
1783        ];
1784
1785        let crossings = detect_zero_crossings(&fills);
1786
1787        // Should detect 4 zero-crossings (positions closing to flat)
1788        assert_eq!(crossings.len(), 4);
1789        assert_eq!(crossings[0], 2000);
1790        assert_eq!(crossings[1], 4000);
1791        assert_eq!(crossings[2], 6000);
1792        assert_eq!(crossings[3], 8000);
1793
1794        // Final position should be +30
1795        let (qty, _) = simulate_position(&fills);
1796        assert_eq!(qty, dec!(30));
1797    }
1798
1799    #[rstest]
1800    fn test_adjust_fills_five_zero_crossings(instrument: InstrumentAny) {
1801        let venue_order_id = create_test_venue_order_id("ORDER1");
1802
1803        // Complex scenario: 4 complete lifecycles + current open position
1804        let fills = vec![
1805            // Old lifecycles (should be filtered out)
1806            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1807            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(101), venue_order_id),
1808            FillSnapshot::new(3000, OrderSide::Sell, dec!(20), dec!(102), venue_order_id),
1809            FillSnapshot::new(4000, OrderSide::Buy, dec!(20), dec!(101), venue_order_id),
1810            FillSnapshot::new(5000, OrderSide::Buy, dec!(15), dec!(103), venue_order_id),
1811            FillSnapshot::new(6000, OrderSide::Sell, dec!(15), dec!(104), venue_order_id),
1812            FillSnapshot::new(7000, OrderSide::Sell, dec!(25), dec!(105), venue_order_id),
1813            FillSnapshot::new(8000, OrderSide::Buy, dec!(25), dec!(104), venue_order_id),
1814            // Current lifecycle (should be kept)
1815            FillSnapshot::new(9000, OrderSide::Buy, dec!(30), dec!(106), venue_order_id),
1816        ];
1817
1818        let venue_position = VenuePositionSnapshot {
1819            side: OrderSide::Buy,
1820            qty: dec!(30),
1821            avg_px: dec!(106),
1822        };
1823
1824        let result =
1825            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1826
1827        // Should filter to current lifecycle only (after last zero-crossing at 8000)
1828        match result {
1829            FillAdjustmentResult::FilterToCurrentLifecycle {
1830                last_zero_crossing_ts,
1831                current_lifecycle_fills,
1832            } => {
1833                assert_eq!(last_zero_crossing_ts, 8000);
1834                assert_eq!(current_lifecycle_fills.len(), 1);
1835                assert_eq!(current_lifecycle_fills[0].ts_event, 9000);
1836                assert_eq!(current_lifecycle_fills[0].qty, dec!(30));
1837            }
1838            _ => panic!("Expected FilterToCurrentLifecycle, was {result:?}"),
1839        }
1840    }
1841
1842    #[rstest]
1843    fn test_adjust_fills_alternating_long_short_positions(instrument: InstrumentAny) {
1844        let venue_order_id = create_test_venue_order_id("ORDER1");
1845
1846        // Alternating: Long -> Short -> Long -> Short -> Long
1847        // These are flips (sign changes) but never go to exactly zero
1848        let fills = vec![
1849            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1850            FillSnapshot::new(2000, OrderSide::Sell, dec!(20), dec!(102), venue_order_id), // Flip to -10
1851            FillSnapshot::new(3000, OrderSide::Buy, dec!(20), dec!(101), venue_order_id), // Flip to +10
1852            FillSnapshot::new(4000, OrderSide::Sell, dec!(20), dec!(103), venue_order_id), // Flip to -10
1853            FillSnapshot::new(5000, OrderSide::Buy, dec!(20), dec!(102), venue_order_id), // Flip to +10
1854        ];
1855
1856        // Current position: +10 @ 102
1857        let venue_position = VenuePositionSnapshot {
1858            side: OrderSide::Buy,
1859            qty: dec!(10),
1860            avg_px: dec!(102),
1861        };
1862
1863        let result =
1864            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1865
1866        // Position never went flat (0), just flipped sides. This is treated as one
1867        // continuous lifecycle since no explicit close occurred. The final position
1868        // matches so no adjustment needed.
1869        assert!(
1870            matches!(result, FillAdjustmentResult::NoAdjustment),
1871            "Expected NoAdjustment (continuous lifecycle with matching position), was {result:?}"
1872        );
1873    }
1874
1875    #[rstest]
1876    fn test_adjust_fills_with_flat_crossings(instrument: InstrumentAny) {
1877        let venue_order_id = create_test_venue_order_id("ORDER1");
1878
1879        // Proper lifecycle boundaries with flat crossings (position goes to exactly 0)
1880        let fills = vec![
1881            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1882            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), venue_order_id), // Close to 0
1883            FillSnapshot::new(3000, OrderSide::Sell, dec!(10), dec!(101), venue_order_id), // New short
1884            FillSnapshot::new(4000, OrderSide::Buy, dec!(10), dec!(99), venue_order_id), // Close to 0
1885            FillSnapshot::new(5000, OrderSide::Buy, dec!(10), dec!(98), venue_order_id), // New long
1886        ];
1887
1888        // Current position: +10 @ 98
1889        let venue_position = VenuePositionSnapshot {
1890            side: OrderSide::Buy,
1891            qty: dec!(10),
1892            avg_px: dec!(98),
1893        };
1894
1895        let result =
1896            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1897
1898        // Position went flat at ts=2000 and ts=4000
1899        // Current lifecycle starts after last flat (4000)
1900        match result {
1901            FillAdjustmentResult::FilterToCurrentLifecycle {
1902                last_zero_crossing_ts,
1903                current_lifecycle_fills,
1904            } => {
1905                assert_eq!(last_zero_crossing_ts, 4000);
1906                assert_eq!(current_lifecycle_fills.len(), 1);
1907                assert_eq!(current_lifecycle_fills[0].ts_event, 5000);
1908                assert_eq!(current_lifecycle_fills[0].qty, dec!(10));
1909            }
1910            _ => panic!("Expected FilterToCurrentLifecycle, was {result:?}"),
1911        }
1912    }
1913
1914    #[rstest]
1915    fn test_replace_current_lifecycle_uses_first_venue_order_id(instrument: InstrumentAny) {
1916        let order_id_1 = create_test_venue_order_id("ORDER1");
1917        let order_id_2 = create_test_venue_order_id("ORDER2");
1918        let order_id_3 = create_test_venue_order_id("ORDER3");
1919
1920        // Previous lifecycle closes, then current lifecycle has fills from multiple orders
1921        let fills = vec![
1922            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), order_id_1),
1923            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), order_id_1), // Close to 0
1924            // Current lifecycle: fills from different venue order IDs
1925            FillSnapshot::new(3000, OrderSide::Buy, dec!(5), dec!(103), order_id_2),
1926            FillSnapshot::new(4000, OrderSide::Buy, dec!(5), dec!(104), order_id_3),
1927        ];
1928
1929        // Venue position differs from simulated (+10 @ 103.5) to trigger replacement
1930        let venue_position = VenuePositionSnapshot {
1931            side: OrderSide::Buy,
1932            qty: dec!(15),
1933            avg_px: dec!(105),
1934        };
1935
1936        let result =
1937            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1938
1939        // Should replace with synthetic fill using first fill's venue_order_id (order_id_2)
1940        match result {
1941            FillAdjustmentResult::ReplaceCurrentLifecycle {
1942                synthetic_fill,
1943                first_venue_order_id,
1944            } => {
1945                assert_eq!(first_venue_order_id, order_id_2);
1946                assert_eq!(synthetic_fill.venue_order_id, order_id_2);
1947                assert_eq!(synthetic_fill.qty, dec!(15));
1948                assert_eq!(synthetic_fill.px, dec!(105));
1949            }
1950            _ => panic!("Expected ReplaceCurrentLifecycle, was {result:?}"),
1951        }
1952    }
1953}