Skip to main content

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//! Execution state reconciliation functions.
17//!
18//! Pure functions for reconciling orders and positions between local state and venue reports.
19
20use std::str::FromStr;
21
22use ahash::AHashMap;
23use nautilus_common::enums::LogColor;
24use nautilus_core::{UUID4, UnixNanos};
25use nautilus_model::{
26    enums::{LiquiditySide, OrderSide, OrderStatus, OrderType, PositionSideSpecified, TimeInForce},
27    events::{
28        OrderAccepted, OrderCanceled, OrderEventAny, OrderExpired, OrderFilled, OrderRejected,
29        OrderTriggered, OrderUpdated,
30    },
31    identifiers::{AccountId, InstrumentId, TradeId, VenueOrderId},
32    instruments::{Instrument, InstrumentAny},
33    orders::{Order, OrderAny},
34    reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
35    types::{Money, Price, Quantity},
36};
37use rust_decimal::Decimal;
38use ustr::Ustr;
39
40/// Immutable snapshot of fill data for position simulation.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct FillSnapshot {
43    /// The event timestamp (nanoseconds).
44    pub ts_event: u64,
45    /// The order side (BUY or SELL).
46    pub side: OrderSide,
47    /// The fill quantity.
48    pub qty: Decimal,
49    /// The fill price.
50    pub px: Decimal,
51    /// The venue order ID.
52    pub venue_order_id: VenueOrderId,
53}
54
55/// Represents a position snapshot from the venue.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct VenuePositionSnapshot {
58    /// The position side (LONG, SHORT, or FLAT).
59    pub side: OrderSide, // Using OrderSide to represent position side for simplicity
60    /// The position quantity (always positive, even for SHORT).
61    pub qty: Decimal,
62    /// The average entry price (can be zero for FLAT positions).
63    pub avg_px: Decimal,
64}
65
66/// Result of the fill adjustment process.
67#[derive(Debug, Clone, PartialEq)]
68pub enum FillAdjustmentResult {
69    /// No adjustment needed - return fills unchanged.
70    NoAdjustment,
71    /// Add synthetic opening fill to oldest lifecycle.
72    AddSyntheticOpening {
73        /// The synthetic fill to add at the beginning.
74        synthetic_fill: FillSnapshot,
75        /// All existing fills to keep.
76        existing_fills: Vec<FillSnapshot>,
77    },
78    /// Replace entire current lifecycle with single synthetic fill.
79    ReplaceCurrentLifecycle {
80        /// The single synthetic fill representing the entire position.
81        synthetic_fill: FillSnapshot,
82        /// The first venue order ID to use.
83        first_venue_order_id: VenueOrderId,
84    },
85    /// Filter fills to current lifecycle only (after last zero-crossing).
86    FilterToCurrentLifecycle {
87        /// Timestamp of the last zero-crossing.
88        last_zero_crossing_ts: u64,
89        /// Fills from current lifecycle.
90        current_lifecycle_fills: Vec<FillSnapshot>,
91    },
92}
93
94impl FillSnapshot {
95    /// Create a new fill snapshot.
96    #[must_use]
97    pub fn new(
98        ts_event: u64,
99        side: OrderSide,
100        qty: Decimal,
101        px: Decimal,
102        venue_order_id: VenueOrderId,
103    ) -> Self {
104        Self {
105            ts_event,
106            side,
107            qty,
108            px,
109            venue_order_id,
110        }
111    }
112
113    /// Return signed direction multiplier: +1 for BUY, -1 for SELL.
114    #[must_use]
115    pub fn direction(&self) -> i8 {
116        match self.side {
117            OrderSide::Buy => 1,
118            OrderSide::Sell => -1,
119            _ => 0,
120        }
121    }
122}
123
124/// Simulate position from chronologically ordered fills using netting logic.
125///
126/// # Returns
127///
128/// Returns a tuple of (quantity, value) after applying all fills.
129#[must_use]
130pub fn simulate_position(fills: &[FillSnapshot]) -> (Decimal, Decimal) {
131    let mut qty = Decimal::ZERO;
132    let mut value = Decimal::ZERO;
133
134    for fill in fills {
135        let direction = Decimal::from(fill.direction());
136        let new_qty = qty + (direction * fill.qty);
137
138        // Check if we're accumulating or crossing zero (flip/close)
139        if (qty >= Decimal::ZERO && direction > Decimal::ZERO)
140            || (qty <= Decimal::ZERO && direction < Decimal::ZERO)
141        {
142            // Accumulating in same direction
143            value += fill.qty * fill.px;
144            qty = new_qty;
145        } else {
146            // Closing or flipping position
147            if qty.abs() >= fill.qty {
148                // Partial close - maintain average price by reducing value proportionally
149                let close_ratio = fill.qty / qty.abs();
150                value *= Decimal::ONE - close_ratio;
151                qty = new_qty;
152            } else {
153                // Close and flip - reset value to opening position
154                let remaining = fill.qty - qty.abs();
155                qty = direction * remaining;
156                value = remaining * fill.px;
157            }
158        }
159    }
160
161    (qty, value)
162}
163
164/// Detect zero-crossing timestamps in a sequence of fills.
165///
166/// A zero-crossing occurs when position quantity crosses through zero (FLAT).
167/// This includes both landing exactly on zero and flipping from long to short or vice versa.
168///
169/// # Returns
170///
171/// Returns a list of timestamps where position crosses through zero.
172#[must_use]
173pub fn detect_zero_crossings(fills: &[FillSnapshot]) -> Vec<u64> {
174    let mut running_qty = Decimal::ZERO;
175    let mut zero_crossings = Vec::new();
176
177    for fill in fills {
178        let prev_qty = running_qty;
179        running_qty += Decimal::from(fill.direction()) * fill.qty;
180
181        // Detect when position crosses zero
182        if prev_qty != Decimal::ZERO {
183            if running_qty == Decimal::ZERO {
184                // Landed exactly on zero
185                zero_crossings.push(fill.ts_event);
186            } else if (prev_qty > Decimal::ZERO) != (running_qty > Decimal::ZERO) {
187                // Sign changed - crossed through zero (flip)
188                zero_crossings.push(fill.ts_event);
189            }
190        }
191    }
192
193    zero_crossings
194}
195
196/// Check if simulated position matches venue position within tolerance.
197///
198/// # Returns
199///
200/// Returns true if quantities and average prices match within tolerance.
201#[must_use]
202pub fn check_position_match(
203    simulated_qty: Decimal,
204    simulated_value: Decimal,
205    venue_qty: Decimal,
206    venue_avg_px: Decimal,
207    tolerance: Decimal,
208) -> bool {
209    if simulated_qty != venue_qty {
210        return false;
211    }
212
213    if simulated_qty == Decimal::ZERO {
214        return true; // Both FLAT
215    }
216
217    // Guard against division by zero
218    let abs_qty = simulated_qty.abs();
219    if abs_qty == Decimal::ZERO {
220        return false;
221    }
222
223    let simulated_avg_px = simulated_value / abs_qty;
224
225    // If venue avg px is zero, we cannot calculate relative difference
226    if venue_avg_px == Decimal::ZERO {
227        return false;
228    }
229
230    let relative_diff = (simulated_avg_px - venue_avg_px).abs() / venue_avg_px;
231
232    relative_diff <= tolerance
233}
234
235/// Calculate the price needed for a reconciliation order to achieve target position.
236///
237/// This is a pure function that calculates what price a fill would need to have
238/// to move from the current position state to the target position state with the
239/// correct average price, accounting for the netting simulation logic.
240///
241/// # Returns
242///
243/// Returns `Some(Decimal)` if a valid reconciliation price can be calculated, `None` otherwise.
244///
245/// # Notes
246///
247/// The function handles four scenarios:
248/// 1. Position to flat: reconciliation_px = current_avg_px (close at current average)
249/// 2. Flat to position: reconciliation_px = target_avg_px
250/// 3. Position flip (sign change): reconciliation_px = target_avg_px (due to value reset in simulation)
251/// 4. Accumulation/reduction: weighted average formula
252pub fn calculate_reconciliation_price(
253    current_position_qty: Decimal,
254    current_position_avg_px: Option<Decimal>,
255    target_position_qty: Decimal,
256    target_position_avg_px: Option<Decimal>,
257) -> Option<Decimal> {
258    let qty_diff = target_position_qty - current_position_qty;
259
260    if qty_diff == Decimal::ZERO {
261        return None; // No reconciliation needed
262    }
263
264    // Special case: closing to flat (target_position_qty == 0)
265    // When flattening, the reconciliation price equals the current position's average price
266    if target_position_qty == Decimal::ZERO {
267        return current_position_avg_px;
268    }
269
270    // If target average price is not provided or zero, we cannot calculate
271    let target_avg_px = target_position_avg_px?;
272    if target_avg_px == Decimal::ZERO {
273        return None;
274    }
275
276    // If current position is flat, the reconciliation price equals target avg price
277    if current_position_qty == Decimal::ZERO || current_position_avg_px.is_none() {
278        return Some(target_avg_px);
279    }
280
281    let current_avg_px = current_position_avg_px?;
282
283    // Check if this is a flip scenario (sign change)
284    // In simulation, flips reset value to remaining * px, so reconciliation_px = target_avg_px
285    let is_flip = (current_position_qty > Decimal::ZERO) != (target_position_qty > Decimal::ZERO)
286        && target_position_qty != Decimal::ZERO;
287
288    if is_flip {
289        return Some(target_avg_px);
290    }
291
292    // For accumulation or reduction (same side), use weighted average formula
293    // Formula: (target_qty * target_avg_px) = (current_qty * current_avg_px) + (qty_diff * reconciliation_px)
294    let target_value = target_position_qty * target_avg_px;
295    let current_value = current_position_qty * current_avg_px;
296    let diff_value = target_value - current_value;
297
298    // qty_diff is guaranteed to be non-zero here due to early return at line 270
299    let reconciliation_px = diff_value / qty_diff;
300
301    // Ensure price is positive
302    if reconciliation_px > Decimal::ZERO {
303        return Some(reconciliation_px);
304    }
305
306    None
307}
308
309/// Adjust fills for partial reconciliation window to handle incomplete position lifecycles.
310///
311/// This function analyzes fills and determines if adjustments are needed when the reconciliation
312/// window doesn't capture the complete position history (missing opening fills).
313///
314/// # Returns
315///
316/// Returns `FillAdjustmentResult` indicating what adjustments (if any) are needed.
317///
318/// # Panics
319///
320/// This function does not panic under normal circumstances as all unwrap calls are guarded by prior checks.
321#[must_use]
322pub fn adjust_fills_for_partial_window(
323    fills: &[FillSnapshot],
324    venue_position: &VenuePositionSnapshot,
325    _instrument: &InstrumentAny,
326    tolerance: Decimal,
327) -> FillAdjustmentResult {
328    // If no fills, nothing to adjust
329    if fills.is_empty() {
330        return FillAdjustmentResult::NoAdjustment;
331    }
332
333    // If venue position is FLAT, return unchanged
334    if venue_position.qty == Decimal::ZERO {
335        return FillAdjustmentResult::NoAdjustment;
336    }
337
338    // Detect zero-crossings
339    let zero_crossings = detect_zero_crossings(fills);
340
341    // Convert venue position to signed quantity
342    let venue_qty_signed = match venue_position.side {
343        OrderSide::Buy => venue_position.qty,
344        OrderSide::Sell => -venue_position.qty,
345        _ => Decimal::ZERO,
346    };
347
348    // Case 1: Has zero-crossings - focus on current lifecycle after last zero-crossing
349    if !zero_crossings.is_empty() {
350        // Find the last zero-crossing that lands on FLAT (qty==0)
351        // This separates lifecycles; flips within a lifecycle don't count
352        let mut last_flat_crossing_ts = None;
353        let mut running_qty = Decimal::ZERO;
354
355        for fill in fills {
356            let prev_qty = running_qty;
357            running_qty += Decimal::from(fill.direction()) * fill.qty;
358
359            if prev_qty != Decimal::ZERO && running_qty == Decimal::ZERO {
360                last_flat_crossing_ts = Some(fill.ts_event);
361            }
362        }
363
364        let lifecycle_boundary_ts =
365            last_flat_crossing_ts.unwrap_or(*zero_crossings.last().unwrap());
366
367        // Get fills from current lifecycle (after lifecycle boundary)
368        let current_lifecycle_fills: Vec<FillSnapshot> = fills
369            .iter()
370            .filter(|f| f.ts_event > lifecycle_boundary_ts)
371            .cloned()
372            .collect();
373
374        if current_lifecycle_fills.is_empty() {
375            return FillAdjustmentResult::NoAdjustment;
376        }
377
378        // Simulate current lifecycle
379        let (current_qty, current_value) = simulate_position(&current_lifecycle_fills);
380
381        // Check if current lifecycle matches venue
382        if check_position_match(
383            current_qty,
384            current_value,
385            venue_qty_signed,
386            venue_position.avg_px,
387            tolerance,
388        ) {
389            // Current lifecycle matches - filter out old lifecycles
390            return FillAdjustmentResult::FilterToCurrentLifecycle {
391                last_zero_crossing_ts: lifecycle_boundary_ts,
392                current_lifecycle_fills,
393            };
394        }
395
396        // Current lifecycle doesn't match - replace with synthetic fill
397        if let Some(first_fill) = current_lifecycle_fills.first() {
398            let synthetic_fill = FillSnapshot::new(
399                first_fill.ts_event.saturating_sub(1), // Timestamp before first fill
400                venue_position.side,
401                venue_position.qty,
402                venue_position.avg_px,
403                first_fill.venue_order_id,
404            );
405
406            return FillAdjustmentResult::ReplaceCurrentLifecycle {
407                synthetic_fill,
408                first_venue_order_id: first_fill.venue_order_id,
409            };
410        }
411
412        return FillAdjustmentResult::NoAdjustment;
413    }
414
415    // Case 2: Single lifecycle or one zero-crossing
416    // Determine which fills to analyze
417    let oldest_lifecycle_fills: Vec<FillSnapshot> =
418        if let Some(&first_zero_crossing_ts) = zero_crossings.first() {
419            // Get fills before first zero-crossing
420            fills
421                .iter()
422                .filter(|f| f.ts_event <= first_zero_crossing_ts)
423                .cloned()
424                .collect()
425        } else {
426            // No zero-crossings - all fills are in single lifecycle
427            fills.to_vec()
428        };
429
430    if oldest_lifecycle_fills.is_empty() {
431        return FillAdjustmentResult::NoAdjustment;
432    }
433
434    // Simulate oldest lifecycle
435    let (oldest_qty, oldest_value) = simulate_position(&oldest_lifecycle_fills);
436
437    // If single lifecycle (no zero-crossings)
438    if zero_crossings.is_empty() {
439        // Check if simulated position matches venue
440        if check_position_match(
441            oldest_qty,
442            oldest_value,
443            venue_qty_signed,
444            venue_position.avg_px,
445            tolerance,
446        ) {
447            return FillAdjustmentResult::NoAdjustment;
448        }
449
450        // Doesn't match - need to add synthetic opening fill
451        if let Some(first_fill) = oldest_lifecycle_fills.first() {
452            // Calculate what opening fill is needed
453            // Use simulated position as current, venue position as target
454            let oldest_avg_px = if oldest_qty == Decimal::ZERO {
455                None
456            } else {
457                Some(oldest_value / oldest_qty.abs())
458            };
459
460            let reconciliation_price = calculate_reconciliation_price(
461                oldest_qty,
462                oldest_avg_px,
463                venue_qty_signed,
464                Some(venue_position.avg_px),
465            );
466
467            if let Some(opening_px) = reconciliation_price {
468                // Calculate opening quantity needed
469                let opening_qty = if oldest_qty == Decimal::ZERO {
470                    venue_qty_signed
471                } else {
472                    // Work backwards: venue = opening + current fills
473                    venue_qty_signed - oldest_qty
474                };
475
476                if opening_qty.abs() > Decimal::ZERO {
477                    let synthetic_side = if opening_qty > Decimal::ZERO {
478                        OrderSide::Buy
479                    } else {
480                        OrderSide::Sell
481                    };
482
483                    let synthetic_fill = FillSnapshot::new(
484                        first_fill.ts_event.saturating_sub(1),
485                        synthetic_side,
486                        opening_qty.abs(),
487                        opening_px,
488                        first_fill.venue_order_id,
489                    );
490
491                    return FillAdjustmentResult::AddSyntheticOpening {
492                        synthetic_fill,
493                        existing_fills: oldest_lifecycle_fills,
494                    };
495                }
496            }
497        }
498
499        return FillAdjustmentResult::NoAdjustment;
500    }
501
502    // Has one zero-crossing - check if oldest lifecycle closes at zero
503    if oldest_qty == Decimal::ZERO {
504        // Lifecycle closes correctly - no adjustment needed
505        return FillAdjustmentResult::NoAdjustment;
506    }
507
508    // Oldest lifecycle doesn't close at zero - add synthetic opening fill
509    if !oldest_lifecycle_fills.is_empty()
510        && let Some(&first_zero_crossing_ts) = zero_crossings.first()
511    {
512        // Need to add opening fill that makes position close at zero-crossing
513        let current_lifecycle_fills: Vec<FillSnapshot> = fills
514            .iter()
515            .filter(|f| f.ts_event > first_zero_crossing_ts)
516            .cloned()
517            .collect();
518
519        if !current_lifecycle_fills.is_empty()
520            && let Some(first_current_fill) = current_lifecycle_fills.first()
521        {
522            let synthetic_fill = FillSnapshot::new(
523                first_current_fill.ts_event.saturating_sub(1),
524                venue_position.side,
525                venue_position.qty,
526                venue_position.avg_px,
527                first_current_fill.venue_order_id,
528            );
529
530            return FillAdjustmentResult::AddSyntheticOpening {
531                synthetic_fill,
532                existing_fills: oldest_lifecycle_fills,
533            };
534        }
535    }
536
537    FillAdjustmentResult::NoAdjustment
538}
539
540/// Create a synthetic `VenueOrderId` using timestamp and UUID suffix.
541///
542/// Format: `S-{hex_timestamp}-{uuid_prefix}`
543#[must_use]
544pub fn create_synthetic_venue_order_id(ts_event: u64) -> VenueOrderId {
545    let uuid = UUID4::new();
546    let uuid_str = uuid.to_string();
547    let uuid_suffix = &uuid_str[..8];
548    let venue_order_id_value = format!("S-{ts_event:x}-{uuid_suffix}");
549    VenueOrderId::new(&venue_order_id_value)
550}
551
552/// Create a synthetic `TradeId` using timestamp and UUID suffix.
553///
554/// Format: `S-{hex_timestamp}-{uuid_prefix}`
555#[must_use]
556pub fn create_synthetic_trade_id(ts_event: u64) -> TradeId {
557    let uuid = UUID4::new();
558    let uuid_str = uuid.to_string();
559    let uuid_suffix = &uuid_str[..8];
560    let trade_id_value = format!("S-{ts_event:x}-{uuid_suffix}");
561    TradeId::new(&trade_id_value)
562}
563
564/// Create a synthetic `OrderStatusReport` from a `FillSnapshot`.
565///
566/// # Errors
567///
568/// Returns an error if the fill quantity cannot be converted to f64.
569pub fn create_synthetic_order_report(
570    fill: &FillSnapshot,
571    account_id: AccountId,
572    instrument_id: InstrumentId,
573    instrument: &InstrumentAny,
574    venue_order_id: VenueOrderId,
575) -> anyhow::Result<OrderStatusReport> {
576    let order_qty = Quantity::from_decimal_dp(fill.qty, instrument.size_precision())?;
577
578    Ok(OrderStatusReport::new(
579        account_id,
580        instrument_id,
581        None, // client_order_id
582        venue_order_id,
583        fill.side,
584        OrderType::Market,
585        TimeInForce::Gtc,
586        OrderStatus::Filled,
587        order_qty,
588        order_qty, // filled_qty = order_qty (fully filled)
589        UnixNanos::from(fill.ts_event),
590        UnixNanos::from(fill.ts_event),
591        UnixNanos::from(fill.ts_event),
592        None, // report_id
593    ))
594}
595
596/// Create a synthetic `FillReport` from a `FillSnapshot`.
597///
598/// # Errors
599///
600/// Returns an error if the fill quantity or price cannot be converted.
601pub fn create_synthetic_fill_report(
602    fill: &FillSnapshot,
603    account_id: AccountId,
604    instrument_id: InstrumentId,
605    instrument: &InstrumentAny,
606    venue_order_id: VenueOrderId,
607) -> anyhow::Result<FillReport> {
608    let trade_id = create_synthetic_trade_id(fill.ts_event);
609    let qty = Quantity::from_decimal_dp(fill.qty, instrument.size_precision())?;
610    let px = Price::from_decimal_dp(fill.px, instrument.price_precision())?;
611
612    Ok(FillReport::new(
613        account_id,
614        instrument_id,
615        venue_order_id,
616        trade_id,
617        fill.side,
618        qty,
619        px,
620        Money::new(0.0, instrument.quote_currency()),
621        LiquiditySide::NoLiquiditySide,
622        None, // client_order_id
623        None, // venue_position_id
624        fill.ts_event.into(),
625        fill.ts_event.into(),
626        None, // report_id
627    ))
628}
629
630/// Result of processing fill reports for reconciliation.
631#[derive(Debug, Clone)]
632pub struct ReconciliationResult {
633    /// Order status reports keyed by venue order ID.
634    pub orders: AHashMap<VenueOrderId, OrderStatusReport>,
635    /// Fill reports keyed by venue order ID.
636    pub fills: AHashMap<VenueOrderId, Vec<FillReport>>,
637}
638
639const DEFAULT_TOLERANCE: Decimal = Decimal::from_parts(1, 0, 0, false, 4); // 0.0001
640
641/// Process fill reports from a mass status for position reconciliation.
642///
643/// This is the main entry point for position reconciliation. It:
644/// 1. Extracts fills and position for the given instrument
645/// 2. Detects position discrepancies
646/// 3. Returns adjusted order/fill reports ready for processing
647///
648/// # Errors
649///
650/// Returns an error if synthetic report creation fails.
651pub fn process_mass_status_for_reconciliation(
652    mass_status: &ExecutionMassStatus,
653    instrument: &InstrumentAny,
654    tolerance: Option<Decimal>,
655) -> anyhow::Result<ReconciliationResult> {
656    let instrument_id = instrument.id();
657    let account_id = mass_status.account_id;
658    let tol = tolerance.unwrap_or(DEFAULT_TOLERANCE);
659
660    // Get position report for this instrument
661    let position_reports = mass_status.position_reports();
662    let venue_position = match position_reports.get(&instrument_id).and_then(|r| r.first()) {
663        Some(report) => position_report_to_snapshot(report),
664        None => {
665            // No position report - return orders/fills unchanged
666            return Ok(extract_instrument_reports(mass_status, instrument_id));
667        }
668    };
669
670    // Extract and convert fills to snapshots
671    let extracted = extract_fills_for_instrument(mass_status, instrument_id);
672    let fill_snapshots = extracted.snapshots;
673    let mut order_map = extracted.orders;
674    let mut fill_map = extracted.fills;
675
676    if fill_snapshots.is_empty() {
677        return Ok(ReconciliationResult {
678            orders: order_map,
679            fills: fill_map,
680        });
681    }
682
683    // Run adjustment logic
684    let result = adjust_fills_for_partial_window(&fill_snapshots, &venue_position, instrument, tol);
685
686    // Apply adjustments
687    match result {
688        FillAdjustmentResult::NoAdjustment => {}
689
690        FillAdjustmentResult::AddSyntheticOpening {
691            synthetic_fill,
692            existing_fills: _,
693        } => {
694            let venue_order_id = create_synthetic_venue_order_id(synthetic_fill.ts_event);
695            let order = create_synthetic_order_report(
696                &synthetic_fill,
697                account_id,
698                instrument_id,
699                instrument,
700                venue_order_id,
701            )?;
702            let fill = create_synthetic_fill_report(
703                &synthetic_fill,
704                account_id,
705                instrument_id,
706                instrument,
707                venue_order_id,
708            )?;
709
710            order_map.insert(venue_order_id, order);
711            fill_map.entry(venue_order_id).or_default().insert(0, fill);
712        }
713
714        FillAdjustmentResult::ReplaceCurrentLifecycle {
715            synthetic_fill,
716            first_venue_order_id,
717        } => {
718            let order = create_synthetic_order_report(
719                &synthetic_fill,
720                account_id,
721                instrument_id,
722                instrument,
723                first_venue_order_id,
724            )?;
725            let fill = create_synthetic_fill_report(
726                &synthetic_fill,
727                account_id,
728                instrument_id,
729                instrument,
730                first_venue_order_id,
731            )?;
732
733            // Replace with only synthetic
734            order_map.clear();
735            fill_map.clear();
736            order_map.insert(first_venue_order_id, order);
737            fill_map.insert(first_venue_order_id, vec![fill]);
738        }
739
740        FillAdjustmentResult::FilterToCurrentLifecycle {
741            last_zero_crossing_ts,
742            current_lifecycle_fills: _,
743        } => {
744            // Filter fills to current lifecycle
745            for fills in fill_map.values_mut() {
746                fills.retain(|f| f.ts_event.as_u64() > last_zero_crossing_ts);
747            }
748            fill_map.retain(|_, fills| !fills.is_empty());
749
750            // Keep only orders that have fills or are still working
751            let orders_with_fills: ahash::AHashSet<VenueOrderId> =
752                fill_map.keys().copied().collect();
753            order_map.retain(|id, order| {
754                orders_with_fills.contains(id)
755                    || !matches!(
756                        order.order_status,
757                        OrderStatus::Denied
758                            | OrderStatus::Rejected
759                            | OrderStatus::Canceled
760                            | OrderStatus::Expired
761                            | OrderStatus::Filled
762                    )
763            });
764        }
765    }
766
767    Ok(ReconciliationResult {
768        orders: order_map,
769        fills: fill_map,
770    })
771}
772
773/// Convert a position status report to a venue position snapshot.
774fn position_report_to_snapshot(report: &PositionStatusReport) -> VenuePositionSnapshot {
775    let side = match report.position_side {
776        PositionSideSpecified::Long => OrderSide::Buy,
777        PositionSideSpecified::Short => OrderSide::Sell,
778        PositionSideSpecified::Flat => OrderSide::Buy,
779    };
780
781    VenuePositionSnapshot {
782        side,
783        qty: report.quantity.into(),
784        avg_px: report.avg_px_open.unwrap_or(Decimal::ZERO),
785    }
786}
787
788/// Extract orders and fills for a specific instrument from mass status.
789fn extract_instrument_reports(
790    mass_status: &ExecutionMassStatus,
791    instrument_id: InstrumentId,
792) -> ReconciliationResult {
793    let mut orders = AHashMap::new();
794    let mut fills = AHashMap::new();
795
796    for (id, order) in mass_status.order_reports() {
797        if order.instrument_id == instrument_id {
798            orders.insert(id, order.clone());
799        }
800    }
801
802    for (id, fill_list) in mass_status.fill_reports() {
803        let filtered: Vec<_> = fill_list
804            .iter()
805            .filter(|f| f.instrument_id == instrument_id)
806            .cloned()
807            .collect();
808        if !filtered.is_empty() {
809            fills.insert(id, filtered);
810        }
811    }
812
813    ReconciliationResult { orders, fills }
814}
815
816/// Extracted fills and reports for an instrument.
817struct ExtractedFills {
818    snapshots: Vec<FillSnapshot>,
819    orders: AHashMap<VenueOrderId, OrderStatusReport>,
820    fills: AHashMap<VenueOrderId, Vec<FillReport>>,
821}
822
823/// Extract fills for an instrument and convert to snapshots.
824fn extract_fills_for_instrument(
825    mass_status: &ExecutionMassStatus,
826    instrument_id: InstrumentId,
827) -> ExtractedFills {
828    let mut snapshots = Vec::new();
829    let mut order_map = AHashMap::new();
830    let mut fill_map = AHashMap::new();
831
832    // Seed order_map
833    for (id, order) in mass_status.order_reports() {
834        if order.instrument_id == instrument_id {
835            order_map.insert(id, order.clone());
836        }
837    }
838
839    // Extract fills
840    for (venue_order_id, fill_reports) in mass_status.fill_reports() {
841        for fill in fill_reports {
842            if fill.instrument_id == instrument_id {
843                let side = mass_status
844                    .order_reports()
845                    .get(&venue_order_id)
846                    .map_or(fill.order_side, |o| o.order_side);
847
848                snapshots.push(FillSnapshot::new(
849                    fill.ts_event.as_u64(),
850                    side,
851                    fill.last_qty.into(),
852                    fill.last_px.into(),
853                    venue_order_id,
854                ));
855
856                fill_map
857                    .entry(venue_order_id)
858                    .or_insert_with(Vec::new)
859                    .push(fill.clone());
860            }
861        }
862    }
863
864    // Sort chronologically
865    snapshots.sort_by_key(|f| f.ts_event);
866
867    ExtractedFills {
868        snapshots,
869        orders: order_map,
870        fills: fill_map,
871    }
872}
873
874/// Generates the appropriate order events for an external order and order status report.
875///
876/// After creating an external order, we need to transition it to its actual state
877/// based on the order status report from the venue. For terminal states like
878/// Canceled/Expired/Filled, we return multiple events to properly transition
879/// through states.
880#[must_use]
881pub fn generate_external_order_status_events(
882    order: &OrderAny,
883    report: &OrderStatusReport,
884    account_id: &AccountId,
885    instrument: &InstrumentAny,
886    ts_now: UnixNanos,
887) -> Vec<OrderEventAny> {
888    let accepted = OrderEventAny::Accepted(OrderAccepted::new(
889        order.trader_id(),
890        order.strategy_id(),
891        order.instrument_id(),
892        order.client_order_id(),
893        report.venue_order_id,
894        *account_id,
895        UUID4::new(),
896        report.ts_accepted,
897        ts_now,
898        true, // reconciliation
899    ));
900
901    match report.order_status {
902        OrderStatus::Accepted | OrderStatus::Triggered => vec![accepted],
903        OrderStatus::PartiallyFilled | OrderStatus::Filled => {
904            let mut events = vec![accepted];
905
906            if !report.filled_qty.is_zero()
907                && let Some(filled) =
908                    create_inferred_fill(order, report, account_id, instrument, ts_now)
909            {
910                events.push(filled);
911            }
912
913            events
914        }
915        OrderStatus::Canceled => {
916            let canceled = OrderEventAny::Canceled(OrderCanceled::new(
917                order.trader_id(),
918                order.strategy_id(),
919                order.instrument_id(),
920                order.client_order_id(),
921                UUID4::new(),
922                report.ts_last,
923                ts_now,
924                true, // reconciliation
925                Some(report.venue_order_id),
926                Some(*account_id),
927            ));
928            vec![accepted, canceled]
929        }
930        OrderStatus::Expired => {
931            let expired = OrderEventAny::Expired(OrderExpired::new(
932                order.trader_id(),
933                order.strategy_id(),
934                order.instrument_id(),
935                order.client_order_id(),
936                UUID4::new(),
937                report.ts_last,
938                ts_now,
939                true, // reconciliation
940                Some(report.venue_order_id),
941                Some(*account_id),
942            ));
943            vec![accepted, expired]
944        }
945        OrderStatus::Rejected => {
946            // Rejected goes directly to terminal state without acceptance
947            vec![OrderEventAny::Rejected(OrderRejected::new(
948                order.trader_id(),
949                order.strategy_id(),
950                order.instrument_id(),
951                order.client_order_id(),
952                *account_id,
953                Ustr::from(report.cancel_reason.as_deref().unwrap_or("UNKNOWN")),
954                UUID4::new(),
955                report.ts_last,
956                ts_now,
957                true, // reconciliation
958                false,
959            ))]
960        }
961        _ => {
962            log::warn!(
963                "Unhandled order status {} for external order {}",
964                report.order_status,
965                order.client_order_id()
966            );
967            Vec::new()
968        }
969    }
970}
971
972/// Creates an inferred fill event for reconciliation when fill reports are missing.
973#[must_use]
974pub fn create_inferred_fill(
975    order: &OrderAny,
976    report: &OrderStatusReport,
977    account_id: &AccountId,
978    instrument: &InstrumentAny,
979    ts_now: UnixNanos,
980) -> Option<OrderEventAny> {
981    let liquidity_side = match order.order_type() {
982        OrderType::Market | OrderType::StopMarket | OrderType::TrailingStopMarket => {
983            LiquiditySide::Taker
984        }
985        _ if report.post_only => LiquiditySide::Maker,
986        _ => LiquiditySide::NoLiquiditySide,
987    };
988
989    let last_px = if let Some(avg_px) = report.avg_px {
990        match Price::from_decimal_dp(avg_px, instrument.price_precision()) {
991            Ok(px) => px,
992            Err(e) => {
993                log::warn!("Failed to create price from avg_px for inferred fill: {e}");
994                return None;
995            }
996        }
997    } else if let Some(price) = report.price {
998        price
999    } else {
1000        log::warn!(
1001            "Cannot create inferred fill for {}: no avg_px or price available",
1002            order.client_order_id()
1003        );
1004        return None;
1005    };
1006
1007    let trade_id = TradeId::from(UUID4::new().as_str());
1008
1009    log::info!(
1010        "Generated inferred fill for {} ({}) qty={} px={}",
1011        order.client_order_id(),
1012        report.venue_order_id,
1013        report.filled_qty,
1014        last_px,
1015    );
1016
1017    Some(OrderEventAny::Filled(OrderFilled::new(
1018        order.trader_id(),
1019        order.strategy_id(),
1020        order.instrument_id(),
1021        order.client_order_id(),
1022        report.venue_order_id,
1023        *account_id,
1024        trade_id,
1025        report.order_side,
1026        order.order_type(),
1027        report.filled_qty,
1028        last_px,
1029        instrument.quote_currency(),
1030        liquidity_side,
1031        UUID4::new(),
1032        report.ts_last,
1033        ts_now,
1034        true, // reconciliation
1035        report.venue_position_id,
1036        None, // commission - not available for inferred fills
1037    )))
1038}
1039
1040/// Creates an OrderAccepted event for reconciliation.
1041///
1042/// # Panics
1043///
1044/// Panics if the order does not have an `account_id` set.
1045#[must_use]
1046pub fn create_reconciliation_accepted(
1047    order: &OrderAny,
1048    report: &OrderStatusReport,
1049    ts_now: UnixNanos,
1050) -> OrderEventAny {
1051    OrderEventAny::Accepted(OrderAccepted::new(
1052        order.trader_id(),
1053        order.strategy_id(),
1054        order.instrument_id(),
1055        order.client_order_id(),
1056        order.venue_order_id().unwrap_or(report.venue_order_id),
1057        order
1058            .account_id()
1059            .expect("Order should have account_id for reconciliation"),
1060        UUID4::new(),
1061        report.ts_accepted,
1062        ts_now,
1063        true, // reconciliation
1064    ))
1065}
1066
1067/// Creates an OrderRejected event for reconciliation.
1068#[must_use]
1069pub fn create_reconciliation_rejected(
1070    order: &OrderAny,
1071    reason: Option<&str>,
1072    ts_now: UnixNanos,
1073) -> Option<OrderEventAny> {
1074    let account_id = order.account_id()?;
1075    let reason = reason.unwrap_or("UNKNOWN");
1076
1077    Some(OrderEventAny::Rejected(OrderRejected::new(
1078        order.trader_id(),
1079        order.strategy_id(),
1080        order.instrument_id(),
1081        order.client_order_id(),
1082        account_id,
1083        Ustr::from(reason),
1084        UUID4::new(),
1085        ts_now,
1086        ts_now,
1087        true,  // reconciliation
1088        false, // due_post_only
1089    )))
1090}
1091
1092/// Creates an OrderTriggered event for reconciliation.
1093#[must_use]
1094pub fn create_reconciliation_triggered(
1095    order: &OrderAny,
1096    report: &OrderStatusReport,
1097    ts_now: UnixNanos,
1098) -> OrderEventAny {
1099    OrderEventAny::Triggered(OrderTriggered::new(
1100        order.trader_id(),
1101        order.strategy_id(),
1102        order.instrument_id(),
1103        order.client_order_id(),
1104        UUID4::new(),
1105        report.ts_triggered.unwrap_or(ts_now),
1106        ts_now,
1107        true, // reconciliation
1108        order.venue_order_id(),
1109        order.account_id(),
1110    ))
1111}
1112
1113/// Creates an OrderCanceled event for reconciliation.
1114#[must_use]
1115pub fn create_reconciliation_canceled(
1116    order: &OrderAny,
1117    report: &OrderStatusReport,
1118    ts_now: UnixNanos,
1119) -> OrderEventAny {
1120    OrderEventAny::Canceled(OrderCanceled::new(
1121        order.trader_id(),
1122        order.strategy_id(),
1123        order.instrument_id(),
1124        order.client_order_id(),
1125        UUID4::new(),
1126        report.ts_last,
1127        ts_now,
1128        true, // reconciliation
1129        order.venue_order_id(),
1130        order.account_id(),
1131    ))
1132}
1133
1134/// Creates an OrderExpired event for reconciliation.
1135#[must_use]
1136pub fn create_reconciliation_expired(
1137    order: &OrderAny,
1138    report: &OrderStatusReport,
1139    ts_now: UnixNanos,
1140) -> OrderEventAny {
1141    OrderEventAny::Expired(OrderExpired::new(
1142        order.trader_id(),
1143        order.strategy_id(),
1144        order.instrument_id(),
1145        order.client_order_id(),
1146        UUID4::new(),
1147        report.ts_last,
1148        ts_now,
1149        true, // reconciliation
1150        order.venue_order_id(),
1151        order.account_id(),
1152    ))
1153}
1154
1155/// Creates an OrderUpdated event for reconciliation.
1156#[must_use]
1157pub fn create_reconciliation_updated(
1158    order: &OrderAny,
1159    report: &OrderStatusReport,
1160    ts_now: UnixNanos,
1161) -> OrderEventAny {
1162    OrderEventAny::Updated(OrderUpdated::new(
1163        order.trader_id(),
1164        order.strategy_id(),
1165        order.instrument_id(),
1166        order.client_order_id(),
1167        report.quantity,
1168        UUID4::new(),
1169        report.ts_last,
1170        ts_now,
1171        true, // reconciliation
1172        order.venue_order_id(),
1173        order.account_id(),
1174        report.price,
1175        report.trigger_price,
1176        None, // protection_price
1177    ))
1178}
1179
1180/// Checks if the order should be updated based on quantity, price, or trigger price
1181/// differences from the venue report.
1182#[must_use]
1183pub fn should_reconciliation_update(order: &OrderAny, report: &OrderStatusReport) -> bool {
1184    // Quantity change only valid if new qty >= filled qty
1185    if report.quantity != order.quantity() && report.quantity >= order.filled_qty() {
1186        return true;
1187    }
1188
1189    match order.order_type() {
1190        OrderType::Limit => report.price != order.price(),
1191        OrderType::StopMarket | OrderType::TrailingStopMarket => {
1192            report.trigger_price != order.trigger_price()
1193        }
1194        OrderType::StopLimit | OrderType::TrailingStopLimit => {
1195            report.trigger_price != order.trigger_price() || report.price != order.price()
1196        }
1197        _ => false,
1198    }
1199}
1200
1201/// Reconciles an order with a venue status report, generating appropriate events.
1202///
1203/// This is the core reconciliation logic that handles all order status transitions.
1204/// For fill reconciliation with inferred fills, use `reconcile_order_with_fills`.
1205#[must_use]
1206pub fn reconcile_order_report(
1207    order: &OrderAny,
1208    report: &OrderStatusReport,
1209    instrument: Option<&InstrumentAny>,
1210    ts_now: UnixNanos,
1211) -> Option<OrderEventAny> {
1212    if order.status() == report.order_status && order.filled_qty() == report.filled_qty {
1213        if should_reconciliation_update(order, report) {
1214            log::info!(
1215                "Order {} has been updated at venue: qty={}->{}, price={:?}->{:?}",
1216                order.client_order_id(),
1217                order.quantity(),
1218                report.quantity,
1219                order.price(),
1220                report.price
1221            );
1222            return Some(create_reconciliation_updated(order, report, ts_now));
1223        }
1224        return None; // Already in sync
1225    }
1226
1227    match report.order_status {
1228        OrderStatus::Accepted => {
1229            if order.status() == OrderStatus::Accepted
1230                && should_reconciliation_update(order, report)
1231            {
1232                return Some(create_reconciliation_updated(order, report, ts_now));
1233            }
1234            Some(create_reconciliation_accepted(order, report, ts_now))
1235        }
1236        OrderStatus::Rejected => {
1237            create_reconciliation_rejected(order, report.cancel_reason.as_deref(), ts_now)
1238        }
1239        OrderStatus::Triggered => Some(create_reconciliation_triggered(order, report, ts_now)),
1240        OrderStatus::Canceled => Some(create_reconciliation_canceled(order, report, ts_now)),
1241        OrderStatus::Expired => Some(create_reconciliation_expired(order, report, ts_now)),
1242
1243        OrderStatus::PartiallyFilled | OrderStatus::Filled => {
1244            reconcile_fill_quantity_mismatch(order, report, instrument, ts_now)
1245        }
1246
1247        // Pending states - venue will confirm, just log
1248        OrderStatus::PendingUpdate | OrderStatus::PendingCancel => {
1249            log::debug!(
1250                "Order {} in pending state: {:?}",
1251                order.client_order_id(),
1252                report.order_status
1253            );
1254            None
1255        }
1256
1257        // Internal states - should not appear in venue reports
1258        OrderStatus::Initialized
1259        | OrderStatus::Submitted
1260        | OrderStatus::Denied
1261        | OrderStatus::Emulated
1262        | OrderStatus::Released => {
1263            log::warn!(
1264                "Unexpected order status in venue report for {}: {:?}",
1265                order.client_order_id(),
1266                report.order_status
1267            );
1268            None
1269        }
1270    }
1271}
1272
1273/// Handles fill quantity mismatch between cached order and venue report.
1274///
1275/// Returns an inferred fill event if the venue reports more filled quantity than we have.
1276fn reconcile_fill_quantity_mismatch(
1277    order: &OrderAny,
1278    report: &OrderStatusReport,
1279    instrument: Option<&InstrumentAny>,
1280    ts_now: UnixNanos,
1281) -> Option<OrderEventAny> {
1282    let order_filled_qty = order.filled_qty();
1283    let report_filled_qty = report.filled_qty;
1284
1285    if report_filled_qty < order_filled_qty {
1286        // Venue reports less filled than we have - potential state corruption
1287        log::error!(
1288            "Fill qty mismatch for {}: cached={}, venue={} (venue < cached)",
1289            order.client_order_id(),
1290            order_filled_qty,
1291            report_filled_qty
1292        );
1293        return None;
1294    }
1295
1296    if report_filled_qty > order_filled_qty {
1297        // Check if order is already closed - skip inferred fill to avoid invalid state
1298        // (matching Python behavior in _handle_fill_quantity_mismatch)
1299        if order.is_closed() {
1300            let precision = order_filled_qty.precision.max(report_filled_qty.precision);
1301            if is_within_single_unit_tolerance(
1302                report_filled_qty.as_decimal(),
1303                order_filled_qty.as_decimal(),
1304                precision,
1305            ) {
1306                return None;
1307            }
1308
1309            log::debug!(
1310                "{} {} already closed but reported difference in filled_qty: \
1311                report={}, cached={}, skipping inferred fill generation for closed order",
1312                order.instrument_id(),
1313                order.client_order_id(),
1314                report_filled_qty,
1315                order_filled_qty,
1316            );
1317            return None;
1318        }
1319
1320        // Venue has more fills - generate inferred fill for the difference
1321        let Some(instrument) = instrument else {
1322            log::warn!(
1323                "Cannot generate inferred fill for {}: instrument not available",
1324                order.client_order_id()
1325            );
1326            return None;
1327        };
1328
1329        let account_id = order.account_id()?;
1330        return create_incremental_inferred_fill(order, report, &account_id, instrument, ts_now);
1331    }
1332
1333    // Quantities match but status differs - potential state inconsistency
1334    if order.status() != report.order_status {
1335        log::warn!(
1336            "Status mismatch with matching fill qty for {}: local={:?}, venue={:?}, filled_qty={}",
1337            order.client_order_id(),
1338            order.status(),
1339            report.order_status,
1340            report.filled_qty
1341        );
1342    }
1343
1344    None
1345}
1346
1347/// Creates an inferred fill for the quantity difference between order and report.
1348///
1349/// This handles incremental fills where the order already has some filled quantity.
1350pub fn create_incremental_inferred_fill(
1351    order: &OrderAny,
1352    report: &OrderStatusReport,
1353    account_id: &AccountId,
1354    instrument: &InstrumentAny,
1355    ts_now: UnixNanos,
1356) -> Option<OrderEventAny> {
1357    let order_filled_qty = order.filled_qty();
1358    let last_qty = report.filled_qty - order_filled_qty;
1359
1360    if last_qty <= Quantity::zero(instrument.size_precision()) {
1361        return None;
1362    }
1363
1364    let liquidity_side = match order.order_type() {
1365        OrderType::Market
1366        | OrderType::StopMarket
1367        | OrderType::MarketToLimit
1368        | OrderType::TrailingStopMarket => LiquiditySide::Taker,
1369        _ if order.is_post_only() => LiquiditySide::Maker,
1370        _ => LiquiditySide::NoLiquiditySide,
1371    };
1372
1373    let last_px = calculate_incremental_fill_price(order, report, instrument)?;
1374
1375    let trade_id = TradeId::new(UUID4::new().to_string());
1376
1377    let venue_order_id = order.venue_order_id().unwrap_or(report.venue_order_id);
1378
1379    log::info!(
1380        color = LogColor::Blue as u8;
1381        "Generated inferred fill for {}: qty={}, px={}",
1382        order.client_order_id(),
1383        last_qty,
1384        last_px,
1385    );
1386
1387    Some(OrderEventAny::Filled(OrderFilled::new(
1388        order.trader_id(),
1389        order.strategy_id(),
1390        order.instrument_id(),
1391        order.client_order_id(),
1392        venue_order_id,
1393        *account_id,
1394        trade_id,
1395        order.order_side(),
1396        order.order_type(),
1397        last_qty,
1398        last_px,
1399        instrument.quote_currency(),
1400        liquidity_side,
1401        UUID4::new(),
1402        report.ts_last,
1403        ts_now,
1404        true, // reconciliation
1405        None, // venue_position_id
1406        None, // commission - unknown for inferred fills
1407    )))
1408}
1409
1410/// Creates an inferred fill with a specific quantity.
1411///
1412/// Unlike `create_incremental_inferred_fill`, this takes the fill quantity directly
1413/// rather than calculating it from order state. Useful when order state hasn't been
1414/// updated yet (e.g., during external order processing).
1415pub fn create_inferred_fill_for_qty(
1416    order: &OrderAny,
1417    report: &OrderStatusReport,
1418    account_id: &AccountId,
1419    instrument: &InstrumentAny,
1420    fill_qty: Quantity,
1421    ts_now: UnixNanos,
1422) -> Option<OrderEventAny> {
1423    if fill_qty.is_zero() {
1424        return None;
1425    }
1426
1427    let liquidity_side = match order.order_type() {
1428        OrderType::Market
1429        | OrderType::StopMarket
1430        | OrderType::MarketToLimit
1431        | OrderType::TrailingStopMarket => LiquiditySide::Taker,
1432        _ if order.is_post_only() => LiquiditySide::Maker,
1433        _ => LiquiditySide::NoLiquiditySide,
1434    };
1435
1436    let last_px = if let Some(avg_px) = report.avg_px {
1437        Price::from_decimal_dp(avg_px, instrument.price_precision()).ok()?
1438    } else if let Some(price) = report.price {
1439        price
1440    } else if let Some(price) = order.price() {
1441        price
1442    } else {
1443        log::warn!(
1444            "Cannot determine fill price for {}: no avg_px or price available",
1445            order.client_order_id()
1446        );
1447        return None;
1448    };
1449
1450    let trade_id = TradeId::new(UUID4::new().to_string());
1451
1452    let venue_order_id = order.venue_order_id().unwrap_or(report.venue_order_id);
1453
1454    log::info!(
1455        color = LogColor::Blue as u8;
1456        "Generated inferred fill for {}: qty={}, px={}",
1457        order.client_order_id(),
1458        fill_qty,
1459        last_px,
1460    );
1461
1462    Some(OrderEventAny::Filled(OrderFilled::new(
1463        order.trader_id(),
1464        order.strategy_id(),
1465        order.instrument_id(),
1466        order.client_order_id(),
1467        venue_order_id,
1468        *account_id,
1469        trade_id,
1470        order.order_side(),
1471        order.order_type(),
1472        fill_qty,
1473        last_px,
1474        instrument.quote_currency(),
1475        liquidity_side,
1476        UUID4::new(),
1477        report.ts_last,
1478        ts_now,
1479        true, // reconciliation
1480        None, // venue_position_id
1481        None, // commission - unknown for inferred fills
1482    )))
1483}
1484
1485/// Calculates the fill price for an incremental inferred fill.
1486fn calculate_incremental_fill_price(
1487    order: &OrderAny,
1488    report: &OrderStatusReport,
1489    instrument: &InstrumentAny,
1490) -> Option<Price> {
1491    let order_filled_qty = order.filled_qty();
1492
1493    // First fill - use avg_px from report or order price
1494    if order_filled_qty.is_zero() {
1495        if let Some(avg_px) = report.avg_px {
1496            return Price::from_decimal_dp(avg_px, instrument.price_precision()).ok();
1497        }
1498        if let Some(price) = report.price {
1499            return Some(price);
1500        }
1501        if let Some(price) = order.price() {
1502            return Some(price);
1503        }
1504        log::warn!(
1505            "Cannot determine fill price for {}: no avg_px, report price, or order price",
1506            order.client_order_id()
1507        );
1508        return None;
1509    }
1510
1511    // Incremental fill - calculate price using weighted average
1512    if let Some(report_avg_px) = report.avg_px {
1513        let Some(order_avg_px) = order.avg_px() else {
1514            // No previous avg_px, use report avg_px
1515            return Price::from_decimal_dp(report_avg_px, instrument.price_precision()).ok();
1516        };
1517        let report_filled_qty = report.filled_qty;
1518        let last_qty = report_filled_qty - order_filled_qty;
1519
1520        let report_notional = report_avg_px * report_filled_qty.as_decimal();
1521        let order_notional = Decimal::from_str(&order_avg_px.to_string()).unwrap_or_default()
1522            * order_filled_qty.as_decimal();
1523        let last_notional = report_notional - order_notional;
1524        let last_px_decimal = last_notional / last_qty.as_decimal();
1525
1526        return Price::from_decimal_dp(last_px_decimal, instrument.price_precision()).ok();
1527    }
1528
1529    // Fallback to report price or order price
1530    if let Some(price) = report.price {
1531        return Some(price);
1532    }
1533
1534    order.price()
1535}
1536
1537/// Creates an OrderFilled event from a FillReport.
1538///
1539/// This is used during reconciliation when a fill report is received from the venue.
1540/// Returns `None` if the fill is a duplicate or would cause an overfill.
1541#[must_use]
1542pub fn reconcile_fill_report(
1543    order: &OrderAny,
1544    report: &FillReport,
1545    instrument: &InstrumentAny,
1546    ts_now: UnixNanos,
1547    allow_overfills: bool,
1548) -> Option<OrderEventAny> {
1549    if order.trade_ids().iter().any(|id| **id == report.trade_id) {
1550        log::debug!(
1551            "Duplicate fill detected: trade_id {} already exists for order {}",
1552            report.trade_id,
1553            order.client_order_id()
1554        );
1555        return None;
1556    }
1557
1558    let potential_filled_qty = order.filled_qty() + report.last_qty;
1559    if potential_filled_qty > order.quantity() {
1560        if !allow_overfills {
1561            log::warn!(
1562                "Rejecting fill that would cause overfill for {}: order.quantity={}, order.filled_qty={}, fill.last_qty={}, would result in filled_qty={}",
1563                order.client_order_id(),
1564                order.quantity(),
1565                order.filled_qty(),
1566                report.last_qty,
1567                potential_filled_qty
1568            );
1569            return None;
1570        }
1571        log::warn!(
1572            "Allowing overfill during reconciliation for {}: order.quantity={}, order.filled_qty={}, fill.last_qty={}, will result in filled_qty={}",
1573            order.client_order_id(),
1574            order.quantity(),
1575            order.filled_qty(),
1576            report.last_qty,
1577            potential_filled_qty
1578        );
1579    }
1580
1581    // Use order's account_id if available, fallback to report's account_id
1582    let account_id = order.account_id().unwrap_or(report.account_id);
1583    let venue_order_id = order.venue_order_id().unwrap_or(report.venue_order_id);
1584
1585    log::info!(
1586        color = LogColor::Blue as u8;
1587        "Reconciling fill for {}: qty={}, px={}, trade_id={}",
1588        order.client_order_id(),
1589        report.last_qty,
1590        report.last_px,
1591        report.trade_id,
1592    );
1593
1594    Some(OrderEventAny::Filled(OrderFilled::new(
1595        order.trader_id(),
1596        order.strategy_id(),
1597        order.instrument_id(),
1598        order.client_order_id(),
1599        venue_order_id,
1600        account_id,
1601        report.trade_id,
1602        order.order_side(),
1603        order.order_type(),
1604        report.last_qty,
1605        report.last_px,
1606        instrument.quote_currency(),
1607        report.liquidity_side,
1608        UUID4::new(),
1609        report.ts_event,
1610        ts_now,
1611        true, // reconciliation
1612        report.venue_position_id,
1613        Some(report.commission),
1614    )))
1615}
1616
1617/// Reconciles a position status report, comparing venue state with local state.
1618///
1619/// Returns true if positions are reconciled (match or discrepancy handled),
1620/// false if there's an error that couldn't be resolved.
1621#[must_use]
1622pub fn check_position_reconciliation(
1623    report: &PositionStatusReport,
1624    cached_signed_qty: Decimal,
1625    size_precision: Option<u8>,
1626) -> bool {
1627    let venue_signed_qty = report.signed_decimal_qty;
1628
1629    if venue_signed_qty == Decimal::ZERO && cached_signed_qty == Decimal::ZERO {
1630        return true;
1631    }
1632
1633    if let Some(precision) = size_precision
1634        && is_within_single_unit_tolerance(cached_signed_qty, venue_signed_qty, precision)
1635    {
1636        log::debug!(
1637            "Position for {} within tolerance: cached={}, venue={}",
1638            report.instrument_id,
1639            cached_signed_qty,
1640            venue_signed_qty
1641        );
1642        return true;
1643    }
1644
1645    if cached_signed_qty == venue_signed_qty {
1646        return true;
1647    }
1648
1649    log::warn!(
1650        "Position discrepancy for {}: cached={}, venue={}",
1651        report.instrument_id,
1652        cached_signed_qty,
1653        venue_signed_qty
1654    );
1655
1656    false
1657}
1658
1659/// Checks if two decimal values are within a single unit of tolerance for the given precision.
1660///
1661/// For integer precision (0), requires exact match.
1662/// For fractional precision, allows difference of 1 unit at that precision.
1663#[must_use]
1664pub fn is_within_single_unit_tolerance(value1: Decimal, value2: Decimal, precision: u8) -> bool {
1665    if precision == 0 {
1666        return value1 == value2;
1667    }
1668
1669    let tolerance = Decimal::new(1, u32::from(precision));
1670    let difference = (value1 - value2).abs();
1671    difference <= tolerance
1672}
1673
1674#[cfg(test)]
1675#[allow(clippy::too_many_arguments)]
1676mod tests {
1677    use nautilus_model::{
1678        enums::TimeInForce,
1679        events::{OrderAccepted, OrderSubmitted},
1680        identifiers::{AccountId, ClientOrderId, VenueOrderId},
1681        instruments::stubs::{audusd_sim, crypto_perpetual_ethusdt},
1682        orders::OrderTestBuilder,
1683        reports::OrderStatusReport,
1684        types::Currency,
1685    };
1686    use rstest::{fixture, rstest};
1687    use rust_decimal_macros::dec;
1688
1689    use super::*;
1690
1691    #[fixture]
1692    fn instrument() -> InstrumentAny {
1693        InstrumentAny::CurrencyPair(audusd_sim())
1694    }
1695
1696    fn create_test_venue_order_id(value: &str) -> VenueOrderId {
1697        VenueOrderId::new(value)
1698    }
1699
1700    #[rstest]
1701    fn test_fill_snapshot_direction() {
1702        let venue_order_id = create_test_venue_order_id("ORDER1");
1703        let buy_fill = FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id);
1704        assert_eq!(buy_fill.direction(), 1);
1705
1706        let sell_fill =
1707            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id);
1708        assert_eq!(sell_fill.direction(), -1);
1709    }
1710
1711    #[rstest]
1712    fn test_simulate_position_accumulate_long() {
1713        let venue_order_id = create_test_venue_order_id("ORDER1");
1714        let fills = vec![
1715            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1716            FillSnapshot::new(2000, OrderSide::Buy, dec!(5), dec!(102), venue_order_id),
1717        ];
1718
1719        let (qty, value) = simulate_position(&fills);
1720        assert_eq!(qty, dec!(15));
1721        assert_eq!(value, dec!(1510)); // 10*100 + 5*102
1722    }
1723
1724    #[rstest]
1725    fn test_simulate_position_close_and_flip() {
1726        let venue_order_id = create_test_venue_order_id("ORDER1");
1727        let fills = vec![
1728            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1729            FillSnapshot::new(2000, OrderSide::Sell, dec!(15), dec!(102), venue_order_id),
1730        ];
1731
1732        let (qty, value) = simulate_position(&fills);
1733        assert_eq!(qty, dec!(-5)); // Flipped from +10 to -5
1734        assert_eq!(value, dec!(510)); // Remaining 5 @ 102
1735    }
1736
1737    #[rstest]
1738    fn test_simulate_position_partial_close() {
1739        let venue_order_id = create_test_venue_order_id("ORDER1");
1740        let fills = vec![
1741            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1742            FillSnapshot::new(2000, OrderSide::Sell, dec!(5), dec!(102), venue_order_id),
1743        ];
1744
1745        let (qty, value) = simulate_position(&fills);
1746        assert_eq!(qty, dec!(5));
1747        assert_eq!(value, dec!(500)); // Reduced proportionally: 1000 * (1 - 5/10) = 500
1748
1749        // Verify average price is maintained
1750        let avg_px = value / qty;
1751        assert_eq!(avg_px, dec!(100));
1752    }
1753
1754    #[rstest]
1755    fn test_simulate_position_multiple_partial_closes() {
1756        let venue_order_id = create_test_venue_order_id("ORDER1");
1757        let fills = vec![
1758            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(10.0), venue_order_id),
1759            FillSnapshot::new(2000, OrderSide::Sell, dec!(25), dec!(11.0), venue_order_id), // Close 25%
1760            FillSnapshot::new(3000, OrderSide::Sell, dec!(25), dec!(12.0), venue_order_id), // Close another 25%
1761        ];
1762
1763        let (qty, value) = simulate_position(&fills);
1764        assert_eq!(qty, dec!(50));
1765        // After first close: value = 1000 * (1 - 25/100) = 1000 * 0.75 = 750
1766        // After second close: value = 750 * (1 - 25/75) = 750 * (50/75) = 500
1767        // Due to decimal precision, we check it's close to 500
1768        assert!((value - dec!(500)).abs() < dec!(0.01));
1769
1770        // Verify average price is maintained at 10.0
1771        let avg_px = value / qty;
1772        assert!((avg_px - dec!(10.0)).abs() < dec!(0.01));
1773    }
1774
1775    #[rstest]
1776    fn test_simulate_position_short_partial_close() {
1777        let venue_order_id = create_test_venue_order_id("ORDER1");
1778        let fills = vec![
1779            FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
1780            FillSnapshot::new(2000, OrderSide::Buy, dec!(5), dec!(98), venue_order_id), // Partial close
1781        ];
1782
1783        let (qty, value) = simulate_position(&fills);
1784        assert_eq!(qty, dec!(-5));
1785        assert_eq!(value, dec!(500)); // Reduced proportionally: 1000 * (1 - 5/10) = 500
1786
1787        // Verify average price is maintained
1788        let avg_px = value / qty.abs();
1789        assert_eq!(avg_px, dec!(100));
1790    }
1791
1792    #[rstest]
1793    fn test_detect_zero_crossings() {
1794        let venue_order_id = create_test_venue_order_id("ORDER1");
1795        let fills = vec![
1796            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1797            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), venue_order_id), // Close to zero
1798            FillSnapshot::new(3000, OrderSide::Buy, dec!(5), dec!(103), venue_order_id),
1799            FillSnapshot::new(4000, OrderSide::Sell, dec!(5), dec!(104), venue_order_id), // Close to zero again
1800        ];
1801
1802        let crossings = detect_zero_crossings(&fills);
1803        assert_eq!(crossings.len(), 2);
1804        assert_eq!(crossings[0], 2000);
1805        assert_eq!(crossings[1], 4000);
1806    }
1807
1808    #[rstest]
1809    fn test_check_position_match_exact() {
1810        let result = check_position_match(dec!(10), dec!(1000), dec!(10), dec!(100), dec!(0.0001));
1811        assert!(result);
1812    }
1813
1814    #[rstest]
1815    fn test_check_position_match_within_tolerance() {
1816        // Simulated avg px = 1000/10 = 100, venue = 100.005
1817        // Relative diff = 0.005 / 100.005 = 0.00004999 < 0.0001
1818        let result =
1819            check_position_match(dec!(10), dec!(1000), dec!(10), dec!(100.005), dec!(0.0001));
1820        assert!(result);
1821    }
1822
1823    #[rstest]
1824    fn test_check_position_match_qty_mismatch() {
1825        let result = check_position_match(dec!(10), dec!(1000), dec!(11), dec!(100), dec!(0.0001));
1826        assert!(!result);
1827    }
1828
1829    #[rstest]
1830    fn test_check_position_match_both_flat() {
1831        let result = check_position_match(dec!(0), dec!(0), dec!(0), dec!(0), dec!(0.0001));
1832        assert!(result);
1833    }
1834
1835    #[rstest]
1836    fn test_reconciliation_price_flat_to_long(_instrument: InstrumentAny) {
1837        let result = calculate_reconciliation_price(dec!(0), None, dec!(10), Some(dec!(100)));
1838        assert!(result.is_some());
1839        assert_eq!(result.unwrap(), dec!(100));
1840    }
1841
1842    #[rstest]
1843    fn test_reconciliation_price_no_target_avg_px(_instrument: InstrumentAny) {
1844        let result = calculate_reconciliation_price(dec!(5), Some(dec!(100)), dec!(10), None);
1845        assert!(result.is_none());
1846    }
1847
1848    #[rstest]
1849    fn test_reconciliation_price_no_quantity_change(_instrument: InstrumentAny) {
1850        let result =
1851            calculate_reconciliation_price(dec!(10), Some(dec!(100)), dec!(10), Some(dec!(105)));
1852        assert!(result.is_none());
1853    }
1854
1855    #[rstest]
1856    fn test_reconciliation_price_long_position_increase(_instrument: InstrumentAny) {
1857        let result =
1858            calculate_reconciliation_price(dec!(10), Some(dec!(100)), dec!(15), Some(dec!(102)));
1859        assert!(result.is_some());
1860        // Expected: (15 * 102 - 10 * 100) / 5 = (1530 - 1000) / 5 = 106
1861        assert_eq!(result.unwrap(), dec!(106));
1862    }
1863
1864    #[rstest]
1865    fn test_reconciliation_price_flat_to_short(_instrument: InstrumentAny) {
1866        let result = calculate_reconciliation_price(dec!(0), None, dec!(-10), Some(dec!(100)));
1867        assert!(result.is_some());
1868        assert_eq!(result.unwrap(), dec!(100));
1869    }
1870
1871    #[rstest]
1872    fn test_reconciliation_price_long_to_flat(_instrument: InstrumentAny) {
1873        // Close long position to flat: 100 @ 1.20 to 0
1874        // When closing to flat, reconciliation price equals current average price
1875        let result =
1876            calculate_reconciliation_price(dec!(100), Some(dec!(1.20)), dec!(0), Some(dec!(0)));
1877        assert!(result.is_some());
1878        assert_eq!(result.unwrap(), dec!(1.20));
1879    }
1880
1881    #[rstest]
1882    fn test_reconciliation_price_short_to_flat(_instrument: InstrumentAny) {
1883        // Close short position to flat: -50 @ 2.50 to 0
1884        // When closing to flat, reconciliation price equals current average price
1885        let result = calculate_reconciliation_price(dec!(-50), Some(dec!(2.50)), dec!(0), None);
1886        assert!(result.is_some());
1887        assert_eq!(result.unwrap(), dec!(2.50));
1888    }
1889
1890    #[rstest]
1891    fn test_reconciliation_price_short_position_increase(_instrument: InstrumentAny) {
1892        // Short position increase: -100 @ 1.30 to -200 @ 1.28
1893        // (−200 × 1.28) = (−100 × 1.30) + (−100 × reconciliation_px)
1894        // −256 = −130 + (−100 × reconciliation_px)
1895        // reconciliation_px = 1.26
1896        let result = calculate_reconciliation_price(
1897            dec!(-100),
1898            Some(dec!(1.30)),
1899            dec!(-200),
1900            Some(dec!(1.28)),
1901        );
1902        assert!(result.is_some());
1903        assert_eq!(result.unwrap(), dec!(1.26));
1904    }
1905
1906    #[rstest]
1907    fn test_reconciliation_price_long_position_decrease(_instrument: InstrumentAny) {
1908        // Long position decrease: 200 @ 1.20 to 100 @ 1.20
1909        let result = calculate_reconciliation_price(
1910            dec!(200),
1911            Some(dec!(1.20)),
1912            dec!(100),
1913            Some(dec!(1.20)),
1914        );
1915        assert!(result.is_some());
1916        assert_eq!(result.unwrap(), dec!(1.20));
1917    }
1918
1919    #[rstest]
1920    fn test_reconciliation_price_long_to_short_flip(_instrument: InstrumentAny) {
1921        // Long to short flip: 100 @ 1.20 to -100 @ 1.25
1922        // Due to netting simulation resetting value on flip, reconciliation_px = target_avg_px
1923        let result = calculate_reconciliation_price(
1924            dec!(100),
1925            Some(dec!(1.20)),
1926            dec!(-100),
1927            Some(dec!(1.25)),
1928        );
1929        assert!(result.is_some());
1930        assert_eq!(result.unwrap(), dec!(1.25));
1931    }
1932
1933    #[rstest]
1934    fn test_reconciliation_price_short_to_long_flip(_instrument: InstrumentAny) {
1935        // Short to long flip: -100 @ 1.30 to 100 @ 1.25
1936        // Due to netting simulation resetting value on flip, reconciliation_px = target_avg_px
1937        let result = calculate_reconciliation_price(
1938            dec!(-100),
1939            Some(dec!(1.30)),
1940            dec!(100),
1941            Some(dec!(1.25)),
1942        );
1943        assert!(result.is_some());
1944        assert_eq!(result.unwrap(), dec!(1.25));
1945    }
1946
1947    #[rstest]
1948    fn test_reconciliation_price_complex_scenario(_instrument: InstrumentAny) {
1949        // Complex: 150 @ 1.23456 to 250 @ 1.24567
1950        // (250 × 1.24567) = (150 × 1.23456) + (100 × reconciliation_px)
1951        // 311.4175 = 185.184 + (100 × reconciliation_px)
1952        // reconciliation_px = 1.262335
1953        let result = calculate_reconciliation_price(
1954            dec!(150),
1955            Some(dec!(1.23456)),
1956            dec!(250),
1957            Some(dec!(1.24567)),
1958        );
1959        assert!(result.is_some());
1960        assert_eq!(result.unwrap(), dec!(1.262335));
1961    }
1962
1963    #[rstest]
1964    fn test_reconciliation_price_zero_target_avg_px(_instrument: InstrumentAny) {
1965        let result =
1966            calculate_reconciliation_price(dec!(100), Some(dec!(1.20)), dec!(200), Some(dec!(0)));
1967        assert!(result.is_none());
1968    }
1969
1970    #[rstest]
1971    fn test_reconciliation_price_negative_price(_instrument: InstrumentAny) {
1972        // Negative price calculation: 100 @ 2.00 to 200 @ 1.00
1973        // (200 × 1.00) = (100 × 2.00) + (100 × reconciliation_px)
1974        // 200 = 200 + (100 × reconciliation_px)
1975        // reconciliation_px = 0 (should return None as price must be positive)
1976        let result = calculate_reconciliation_price(
1977            dec!(100),
1978            Some(dec!(2.00)),
1979            dec!(200),
1980            Some(dec!(1.00)),
1981        );
1982        assert!(result.is_none());
1983    }
1984
1985    #[rstest]
1986    fn test_reconciliation_price_flip_simulation_compatibility() {
1987        let venue_order_id = create_test_venue_order_id("ORDER1");
1988        // Start with long position: 100 @ 1.20
1989        // Target: -100 @ 1.25
1990        // Calculate reconciliation price
1991        let recon_px = calculate_reconciliation_price(
1992            dec!(100),
1993            Some(dec!(1.20)),
1994            dec!(-100),
1995            Some(dec!(1.25)),
1996        )
1997        .expect("reconciliation price");
1998
1999        assert_eq!(recon_px, dec!(1.25));
2000
2001        // Simulate the flip with reconciliation fill (sell 200 to go from +100 to -100)
2002        let fills = vec![
2003            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
2004            FillSnapshot::new(2000, OrderSide::Sell, dec!(200), recon_px, venue_order_id),
2005        ];
2006
2007        let (final_qty, final_value) = simulate_position(&fills);
2008        assert_eq!(final_qty, dec!(-100));
2009        let final_avg = final_value / final_qty.abs();
2010        assert_eq!(final_avg, dec!(1.25), "Final average should match target");
2011    }
2012
2013    #[rstest]
2014    fn test_reconciliation_price_accumulation_simulation_compatibility() {
2015        let venue_order_id = create_test_venue_order_id("ORDER1");
2016        // Start with long position: 100 @ 1.20
2017        // Target: 200 @ 1.22
2018        let recon_px = calculate_reconciliation_price(
2019            dec!(100),
2020            Some(dec!(1.20)),
2021            dec!(200),
2022            Some(dec!(1.22)),
2023        )
2024        .expect("reconciliation price");
2025
2026        // Simulate accumulation with reconciliation fill
2027        let fills = vec![
2028            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
2029            FillSnapshot::new(2000, OrderSide::Buy, dec!(100), recon_px, venue_order_id),
2030        ];
2031
2032        let (final_qty, final_value) = simulate_position(&fills);
2033        assert_eq!(final_qty, dec!(200));
2034        let final_avg = final_value / final_qty.abs();
2035        assert_eq!(final_avg, dec!(1.22), "Final average should match target");
2036    }
2037
2038    #[rstest]
2039    fn test_simulate_position_accumulate_short() {
2040        let venue_order_id = create_test_venue_order_id("ORDER1");
2041        let fills = vec![
2042            FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
2043            FillSnapshot::new(2000, OrderSide::Sell, dec!(5), dec!(98), venue_order_id),
2044        ];
2045
2046        let (qty, value) = simulate_position(&fills);
2047        assert_eq!(qty, dec!(-15));
2048        assert_eq!(value, dec!(1490)); // 10*100 + 5*98
2049    }
2050
2051    #[rstest]
2052    fn test_simulate_position_short_to_long_flip() {
2053        let venue_order_id = create_test_venue_order_id("ORDER1");
2054        let fills = vec![
2055            FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
2056            FillSnapshot::new(2000, OrderSide::Buy, dec!(15), dec!(102), venue_order_id),
2057        ];
2058
2059        let (qty, value) = simulate_position(&fills);
2060        assert_eq!(qty, dec!(5)); // Flipped from -10 to +5
2061        assert_eq!(value, dec!(510)); // Remaining 5 @ 102
2062    }
2063
2064    #[rstest]
2065    fn test_simulate_position_multiple_flips() {
2066        let venue_order_id = create_test_venue_order_id("ORDER1");
2067        let fills = vec![
2068            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2069            FillSnapshot::new(2000, OrderSide::Sell, dec!(15), dec!(105), venue_order_id), // Flip to -5
2070            FillSnapshot::new(3000, OrderSide::Buy, dec!(10), dec!(110), venue_order_id), // Flip to +5
2071        ];
2072
2073        let (qty, value) = simulate_position(&fills);
2074        assert_eq!(qty, dec!(5)); // Final position: +5
2075        assert_eq!(value, dec!(550)); // 5 @ 110
2076    }
2077
2078    #[rstest]
2079    fn test_simulate_position_empty_fills() {
2080        let fills: Vec<FillSnapshot> = vec![];
2081        let (qty, value) = simulate_position(&fills);
2082        assert_eq!(qty, dec!(0));
2083        assert_eq!(value, dec!(0));
2084    }
2085
2086    #[rstest]
2087    fn test_detect_zero_crossings_no_crossings() {
2088        let venue_order_id = create_test_venue_order_id("ORDER1");
2089        let fills = vec![
2090            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2091            FillSnapshot::new(2000, OrderSide::Buy, dec!(5), dec!(102), venue_order_id),
2092        ];
2093
2094        let crossings = detect_zero_crossings(&fills);
2095        assert_eq!(crossings.len(), 0);
2096    }
2097
2098    #[rstest]
2099    fn test_detect_zero_crossings_single_crossing() {
2100        let venue_order_id = create_test_venue_order_id("ORDER1");
2101        let fills = vec![
2102            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2103            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), venue_order_id), // Close to zero
2104        ];
2105
2106        let crossings = detect_zero_crossings(&fills);
2107        assert_eq!(crossings.len(), 1);
2108        assert_eq!(crossings[0], 2000);
2109    }
2110
2111    #[rstest]
2112    fn test_detect_zero_crossings_empty_fills() {
2113        let fills: Vec<FillSnapshot> = vec![];
2114        let crossings = detect_zero_crossings(&fills);
2115        assert_eq!(crossings.len(), 0);
2116    }
2117
2118    #[rstest]
2119    fn test_detect_zero_crossings_long_to_short_flip() {
2120        let venue_order_id = create_test_venue_order_id("ORDER1");
2121        // Buy 10, then Sell 15 -> flip from +10 to -5
2122        let fills = vec![
2123            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2124            FillSnapshot::new(2000, OrderSide::Sell, dec!(15), dec!(102), venue_order_id), // Flip
2125        ];
2126
2127        let crossings = detect_zero_crossings(&fills);
2128        assert_eq!(crossings.len(), 1);
2129        assert_eq!(crossings[0], 2000); // Detected the flip
2130    }
2131
2132    #[rstest]
2133    fn test_detect_zero_crossings_short_to_long_flip() {
2134        let venue_order_id = create_test_venue_order_id("ORDER1");
2135        // Sell 10, then Buy 20 -> flip from -10 to +10
2136        let fills = vec![
2137            FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
2138            FillSnapshot::new(2000, OrderSide::Buy, dec!(20), dec!(102), venue_order_id), // Flip
2139        ];
2140
2141        let crossings = detect_zero_crossings(&fills);
2142        assert_eq!(crossings.len(), 1);
2143        assert_eq!(crossings[0], 2000);
2144    }
2145
2146    #[rstest]
2147    fn test_detect_zero_crossings_multiple_flips() {
2148        let venue_order_id = create_test_venue_order_id("ORDER1");
2149        let fills = vec![
2150            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2151            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), venue_order_id), // Land on zero
2152            FillSnapshot::new(3000, OrderSide::Sell, dec!(5), dec!(103), venue_order_id), // Go short
2153            FillSnapshot::new(4000, OrderSide::Buy, dec!(15), dec!(104), venue_order_id), // Flip to long
2154        ];
2155
2156        let crossings = detect_zero_crossings(&fills);
2157        assert_eq!(crossings.len(), 2);
2158        assert_eq!(crossings[0], 2000); // First zero-crossing (land on zero)
2159        assert_eq!(crossings[1], 4000); // Second zero-crossing (flip)
2160    }
2161
2162    #[rstest]
2163    fn test_check_position_match_outside_tolerance() {
2164        // Simulated avg px = 1000/10 = 100, venue = 101
2165        // Relative diff = 1 / 101 = 0.0099 > 0.0001
2166        let result = check_position_match(dec!(10), dec!(1000), dec!(10), dec!(101), dec!(0.0001));
2167        assert!(!result);
2168    }
2169
2170    #[rstest]
2171    fn test_check_position_match_edge_of_tolerance() {
2172        // Simulated avg px = 1000/10 = 100, venue = 100.01
2173        // Relative diff = 0.01 / 100.01 = 0.00009999 < 0.0001
2174        let result =
2175            check_position_match(dec!(10), dec!(1000), dec!(10), dec!(100.01), dec!(0.0001));
2176        assert!(result);
2177    }
2178
2179    #[rstest]
2180    fn test_check_position_match_zero_venue_avg_px() {
2181        let result = check_position_match(dec!(10), dec!(1000), dec!(10), dec!(0), dec!(0.0001));
2182        assert!(!result); // Should fail because relative diff calculation with zero denominator
2183    }
2184
2185    #[rstest]
2186    fn test_adjust_fills_no_fills() {
2187        let venue_position = VenuePositionSnapshot {
2188            side: OrderSide::Buy,
2189            qty: dec!(0.02),
2190            avg_px: dec!(4100.00),
2191        };
2192        let instrument = instrument();
2193        let result =
2194            adjust_fills_for_partial_window(&[], &venue_position, &instrument, dec!(0.0001));
2195        assert!(matches!(result, FillAdjustmentResult::NoAdjustment));
2196    }
2197
2198    #[rstest]
2199    fn test_adjust_fills_flat_position() {
2200        let venue_order_id = create_test_venue_order_id("ORDER1");
2201        let fills = vec![FillSnapshot::new(
2202            1000,
2203            OrderSide::Buy,
2204            dec!(0.01),
2205            dec!(4100.00),
2206            venue_order_id,
2207        )];
2208        let venue_position = VenuePositionSnapshot {
2209            side: OrderSide::Buy,
2210            qty: dec!(0),
2211            avg_px: dec!(0),
2212        };
2213        let instrument = instrument();
2214        let result =
2215            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2216        assert!(matches!(result, FillAdjustmentResult::NoAdjustment));
2217    }
2218
2219    #[rstest]
2220    fn test_adjust_fills_complete_lifecycle_no_adjustment() {
2221        let venue_order_id = create_test_venue_order_id("ORDER1");
2222        let venue_order_id2 = create_test_venue_order_id("ORDER2");
2223        let fills = vec![
2224            FillSnapshot::new(
2225                1000,
2226                OrderSide::Buy,
2227                dec!(0.01),
2228                dec!(4100.00),
2229                venue_order_id,
2230            ),
2231            FillSnapshot::new(
2232                2000,
2233                OrderSide::Buy,
2234                dec!(0.01),
2235                dec!(4100.00),
2236                venue_order_id2,
2237            ),
2238        ];
2239        let venue_position = VenuePositionSnapshot {
2240            side: OrderSide::Buy,
2241            qty: dec!(0.02),
2242            avg_px: dec!(4100.00),
2243        };
2244        let instrument = instrument();
2245        let result =
2246            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2247        assert!(matches!(result, FillAdjustmentResult::NoAdjustment));
2248    }
2249
2250    #[rstest]
2251    fn test_adjust_fills_incomplete_lifecycle_adds_synthetic() {
2252        let venue_order_id = create_test_venue_order_id("ORDER1");
2253        // Window only sees +0.02 @ 4200, but venue has 0.04 @ 4100
2254        let fills = vec![FillSnapshot::new(
2255            2000,
2256            OrderSide::Buy,
2257            dec!(0.02),
2258            dec!(4200.00),
2259            venue_order_id,
2260        )];
2261        let venue_position = VenuePositionSnapshot {
2262            side: OrderSide::Buy,
2263            qty: dec!(0.04),
2264            avg_px: dec!(4100.00),
2265        };
2266        let instrument = instrument();
2267        let result =
2268            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2269
2270        match result {
2271            FillAdjustmentResult::AddSyntheticOpening {
2272                synthetic_fill,
2273                existing_fills,
2274            } => {
2275                assert_eq!(synthetic_fill.side, OrderSide::Buy);
2276                assert_eq!(synthetic_fill.qty, dec!(0.02)); // Missing 0.02
2277                assert_eq!(existing_fills.len(), 1);
2278            }
2279            _ => panic!("Expected AddSyntheticOpening"),
2280        }
2281    }
2282
2283    #[rstest]
2284    fn test_adjust_fills_with_zero_crossings() {
2285        let venue_order_id1 = create_test_venue_order_id("ORDER1");
2286        let venue_order_id2 = create_test_venue_order_id("ORDER2");
2287        let venue_order_id3 = create_test_venue_order_id("ORDER3");
2288
2289        // Lifecycle 1: LONG 0.02 -> FLAT (zero-crossing at 2000)
2290        // Lifecycle 2: LONG 0.03 (current)
2291        let fills = vec![
2292            FillSnapshot::new(
2293                1000,
2294                OrderSide::Buy,
2295                dec!(0.02),
2296                dec!(4100.00),
2297                venue_order_id1,
2298            ),
2299            FillSnapshot::new(
2300                2000,
2301                OrderSide::Sell,
2302                dec!(0.02),
2303                dec!(4150.00),
2304                venue_order_id2,
2305            ), // Zero-crossing
2306            FillSnapshot::new(
2307                3000,
2308                OrderSide::Buy,
2309                dec!(0.03),
2310                dec!(4200.00),
2311                venue_order_id3,
2312            ), // Current lifecycle
2313        ];
2314
2315        let venue_position = VenuePositionSnapshot {
2316            side: OrderSide::Buy,
2317            qty: dec!(0.03),
2318            avg_px: dec!(4200.00),
2319        };
2320
2321        let instrument = instrument();
2322        let result =
2323            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2324
2325        // Should filter to current lifecycle only
2326        match result {
2327            FillAdjustmentResult::FilterToCurrentLifecycle {
2328                last_zero_crossing_ts,
2329                current_lifecycle_fills,
2330            } => {
2331                assert_eq!(last_zero_crossing_ts, 2000);
2332                assert_eq!(current_lifecycle_fills.len(), 1);
2333                assert_eq!(current_lifecycle_fills[0].venue_order_id, venue_order_id3);
2334            }
2335            _ => panic!("Expected FilterToCurrentLifecycle, was {result:?}"),
2336        }
2337    }
2338
2339    #[rstest]
2340    fn test_adjust_fills_multiple_zero_crossings_mismatch() {
2341        let venue_order_id1 = create_test_venue_order_id("ORDER1");
2342        let venue_order_id2 = create_test_venue_order_id("ORDER2");
2343        let _venue_order_id3 = create_test_venue_order_id("ORDER3");
2344        let venue_order_id4 = create_test_venue_order_id("ORDER4");
2345        let venue_order_id5 = create_test_venue_order_id("ORDER5");
2346
2347        // Lifecycle 1: LONG 0.05 -> FLAT
2348        // Lifecycle 2: Current fills produce 0.10 @ 4050, but venue has 0.05 @ 4142.04
2349        let fills = vec![
2350            FillSnapshot::new(
2351                1000,
2352                OrderSide::Buy,
2353                dec!(0.05),
2354                dec!(4000.00),
2355                venue_order_id1,
2356            ),
2357            FillSnapshot::new(
2358                2000,
2359                OrderSide::Sell,
2360                dec!(0.05),
2361                dec!(4050.00),
2362                venue_order_id2,
2363            ), // Zero-crossing
2364            FillSnapshot::new(
2365                3000,
2366                OrderSide::Buy,
2367                dec!(0.05),
2368                dec!(4000.00),
2369                venue_order_id4,
2370            ), // Current lifecycle
2371            FillSnapshot::new(
2372                4000,
2373                OrderSide::Buy,
2374                dec!(0.05),
2375                dec!(4100.00),
2376                venue_order_id5,
2377            ), // Current lifecycle
2378        ];
2379
2380        let venue_position = VenuePositionSnapshot {
2381            side: OrderSide::Buy,
2382            qty: dec!(0.05),
2383            avg_px: dec!(4142.04),
2384        };
2385
2386        let instrument = instrument();
2387        let result =
2388            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2389
2390        // Should replace current lifecycle with synthetic
2391        match result {
2392            FillAdjustmentResult::ReplaceCurrentLifecycle {
2393                synthetic_fill,
2394                first_venue_order_id,
2395            } => {
2396                assert_eq!(synthetic_fill.qty, dec!(0.05));
2397                assert_eq!(synthetic_fill.px, dec!(4142.04));
2398                assert_eq!(synthetic_fill.side, OrderSide::Buy);
2399                assert_eq!(first_venue_order_id, venue_order_id4);
2400            }
2401            _ => panic!("Expected ReplaceCurrentLifecycle, was {result:?}"),
2402        }
2403    }
2404
2405    #[rstest]
2406    fn test_adjust_fills_short_position() {
2407        let venue_order_id = create_test_venue_order_id("ORDER1");
2408
2409        // Window only sees SELL 0.02 @ 4120, but venue has -0.05 @ 4100
2410        let fills = vec![FillSnapshot::new(
2411            1000,
2412            OrderSide::Sell,
2413            dec!(0.02),
2414            dec!(4120.00),
2415            venue_order_id,
2416        )];
2417
2418        let venue_position = VenuePositionSnapshot {
2419            side: OrderSide::Sell,
2420            qty: dec!(0.05),
2421            avg_px: dec!(4100.00),
2422        };
2423
2424        let instrument = instrument();
2425        let result =
2426            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2427
2428        // Should add synthetic opening SHORT fill
2429        match result {
2430            FillAdjustmentResult::AddSyntheticOpening {
2431                synthetic_fill,
2432                existing_fills,
2433            } => {
2434                assert_eq!(synthetic_fill.side, OrderSide::Sell);
2435                assert_eq!(synthetic_fill.qty, dec!(0.03)); // Missing 0.03
2436                assert_eq!(existing_fills.len(), 1);
2437            }
2438            _ => panic!("Expected AddSyntheticOpening, was {result:?}"),
2439        }
2440    }
2441
2442    #[rstest]
2443    fn test_adjust_fills_timestamp_underflow_protection() {
2444        let venue_order_id = create_test_venue_order_id("ORDER1");
2445
2446        // First fill at timestamp 0 - saturating_sub should prevent underflow
2447        let fills = vec![FillSnapshot::new(
2448            0,
2449            OrderSide::Buy,
2450            dec!(0.01),
2451            dec!(4100.00),
2452            venue_order_id,
2453        )];
2454
2455        let venue_position = VenuePositionSnapshot {
2456            side: OrderSide::Buy,
2457            qty: dec!(0.02),
2458            avg_px: dec!(4100.00),
2459        };
2460
2461        let instrument = instrument();
2462        let result =
2463            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2464
2465        // Should add synthetic fill with timestamp 0 (not u64::MAX)
2466        match result {
2467            FillAdjustmentResult::AddSyntheticOpening { synthetic_fill, .. } => {
2468                assert_eq!(synthetic_fill.ts_event, 0); // saturating_sub(1) from 0 = 0
2469            }
2470            _ => panic!("Expected AddSyntheticOpening, was {result:?}"),
2471        }
2472    }
2473
2474    #[rstest]
2475    fn test_adjust_fills_with_flip_scenario() {
2476        let venue_order_id1 = create_test_venue_order_id("ORDER1");
2477        let venue_order_id2 = create_test_venue_order_id("ORDER2");
2478
2479        // Long 10 @ 100, then Sell 20 @ 105 -> flip to Short 10 @ 105
2480        let fills = vec![
2481            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id1),
2482            FillSnapshot::new(2000, OrderSide::Sell, dec!(20), dec!(105), venue_order_id2), // Flip
2483        ];
2484
2485        let venue_position = VenuePositionSnapshot {
2486            side: OrderSide::Sell,
2487            qty: dec!(10),
2488            avg_px: dec!(105),
2489        };
2490
2491        let instrument = instrument();
2492        let result =
2493            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2494
2495        // Should recognize the flip and match correctly
2496        match result {
2497            FillAdjustmentResult::NoAdjustment => {
2498                // Verify simulation matches
2499                let (qty, value) = simulate_position(&fills);
2500                assert_eq!(qty, dec!(-10));
2501                let avg = value / qty.abs();
2502                assert_eq!(avg, dec!(105));
2503            }
2504            _ => panic!("Expected NoAdjustment for matching flip, was {result:?}"),
2505        }
2506    }
2507
2508    #[rstest]
2509    fn test_detect_zero_crossings_complex_lifecycle() {
2510        let venue_order_id = create_test_venue_order_id("ORDER1");
2511        // Complex scenario with multiple lifecycles
2512        let fills = vec![
2513            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
2514            FillSnapshot::new(2000, OrderSide::Sell, dec!(50), dec!(1.25), venue_order_id), // Reduce
2515            FillSnapshot::new(3000, OrderSide::Sell, dec!(100), dec!(1.30), venue_order_id), // Flip to -50
2516            FillSnapshot::new(4000, OrderSide::Buy, dec!(50), dec!(1.28), venue_order_id), // Close to zero
2517            FillSnapshot::new(5000, OrderSide::Buy, dec!(75), dec!(1.22), venue_order_id), // Open long
2518            FillSnapshot::new(6000, OrderSide::Sell, dec!(150), dec!(1.24), venue_order_id), // Flip to -75
2519        ];
2520
2521        let crossings = detect_zero_crossings(&fills);
2522        assert_eq!(crossings.len(), 3);
2523        assert_eq!(crossings[0], 3000); // First flip
2524        assert_eq!(crossings[1], 4000); // Close to zero
2525        assert_eq!(crossings[2], 6000); // Second flip
2526    }
2527
2528    #[rstest]
2529    fn test_reconciliation_price_partial_close() {
2530        let venue_order_id = create_test_venue_order_id("ORDER1");
2531        // Partial close scenario: 100 @ 1.20 to 50 @ 1.20
2532        let recon_px =
2533            calculate_reconciliation_price(dec!(100), Some(dec!(1.20)), dec!(50), Some(dec!(1.20)))
2534                .expect("reconciliation price");
2535
2536        // Simulate partial close
2537        let fills = vec![
2538            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
2539            FillSnapshot::new(2000, OrderSide::Sell, dec!(50), recon_px, venue_order_id),
2540        ];
2541
2542        let (final_qty, final_value) = simulate_position(&fills);
2543        assert_eq!(final_qty, dec!(50));
2544        let final_avg = final_value / final_qty.abs();
2545        assert_eq!(final_avg, dec!(1.20), "Average should be maintained");
2546    }
2547
2548    #[rstest]
2549    fn test_detect_zero_crossings_identical_timestamps() {
2550        let venue_order_id1 = create_test_venue_order_id("ORDER1");
2551        let venue_order_id2 = create_test_venue_order_id("ORDER2");
2552
2553        // Two fills with identical timestamps - should process deterministically
2554        let fills = vec![
2555            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id1),
2556            FillSnapshot::new(2000, OrderSide::Sell, dec!(5), dec!(102), venue_order_id1),
2557            FillSnapshot::new(2000, OrderSide::Sell, dec!(5), dec!(103), venue_order_id2), // Same ts
2558        ];
2559
2560        let crossings = detect_zero_crossings(&fills);
2561
2562        // Position: +10 -> +5 -> 0 (zero crossing at last fill)
2563        assert_eq!(crossings.len(), 1);
2564        assert_eq!(crossings[0], 2000);
2565
2566        // Verify final position is flat
2567        let (qty, _) = simulate_position(&fills);
2568        assert_eq!(qty, dec!(0));
2569    }
2570
2571    #[rstest]
2572    fn test_detect_zero_crossings_five_lifecycles() {
2573        let venue_order_id = create_test_venue_order_id("ORDER1");
2574
2575        // Five complete position lifecycles: open->close repeated 5 times
2576        let fills = vec![
2577            // Lifecycle 1: Long
2578            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2579            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(101), venue_order_id),
2580            // Lifecycle 2: Short
2581            FillSnapshot::new(3000, OrderSide::Sell, dec!(20), dec!(102), venue_order_id),
2582            FillSnapshot::new(4000, OrderSide::Buy, dec!(20), dec!(101), venue_order_id),
2583            // Lifecycle 3: Long
2584            FillSnapshot::new(5000, OrderSide::Buy, dec!(15), dec!(103), venue_order_id),
2585            FillSnapshot::new(6000, OrderSide::Sell, dec!(15), dec!(104), venue_order_id),
2586            // Lifecycle 4: Short
2587            FillSnapshot::new(7000, OrderSide::Sell, dec!(25), dec!(105), venue_order_id),
2588            FillSnapshot::new(8000, OrderSide::Buy, dec!(25), dec!(104), venue_order_id),
2589            // Lifecycle 5: Long (still open)
2590            FillSnapshot::new(9000, OrderSide::Buy, dec!(30), dec!(106), venue_order_id),
2591        ];
2592
2593        let crossings = detect_zero_crossings(&fills);
2594
2595        // Should detect 4 zero-crossings (positions closing to flat)
2596        assert_eq!(crossings.len(), 4);
2597        assert_eq!(crossings[0], 2000);
2598        assert_eq!(crossings[1], 4000);
2599        assert_eq!(crossings[2], 6000);
2600        assert_eq!(crossings[3], 8000);
2601
2602        // Final position should be +30
2603        let (qty, _) = simulate_position(&fills);
2604        assert_eq!(qty, dec!(30));
2605    }
2606
2607    #[rstest]
2608    fn test_adjust_fills_five_zero_crossings(instrument: InstrumentAny) {
2609        let venue_order_id = create_test_venue_order_id("ORDER1");
2610
2611        // Complex scenario: 4 complete lifecycles + current open position
2612        let fills = vec![
2613            // Old lifecycles (should be filtered out)
2614            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2615            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(101), venue_order_id),
2616            FillSnapshot::new(3000, OrderSide::Sell, dec!(20), dec!(102), venue_order_id),
2617            FillSnapshot::new(4000, OrderSide::Buy, dec!(20), dec!(101), venue_order_id),
2618            FillSnapshot::new(5000, OrderSide::Buy, dec!(15), dec!(103), venue_order_id),
2619            FillSnapshot::new(6000, OrderSide::Sell, dec!(15), dec!(104), venue_order_id),
2620            FillSnapshot::new(7000, OrderSide::Sell, dec!(25), dec!(105), venue_order_id),
2621            FillSnapshot::new(8000, OrderSide::Buy, dec!(25), dec!(104), venue_order_id),
2622            // Current lifecycle (should be kept)
2623            FillSnapshot::new(9000, OrderSide::Buy, dec!(30), dec!(106), venue_order_id),
2624        ];
2625
2626        let venue_position = VenuePositionSnapshot {
2627            side: OrderSide::Buy,
2628            qty: dec!(30),
2629            avg_px: dec!(106),
2630        };
2631
2632        let result =
2633            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2634
2635        // Should filter to current lifecycle only (after last zero-crossing at 8000)
2636        match result {
2637            FillAdjustmentResult::FilterToCurrentLifecycle {
2638                last_zero_crossing_ts,
2639                current_lifecycle_fills,
2640            } => {
2641                assert_eq!(last_zero_crossing_ts, 8000);
2642                assert_eq!(current_lifecycle_fills.len(), 1);
2643                assert_eq!(current_lifecycle_fills[0].ts_event, 9000);
2644                assert_eq!(current_lifecycle_fills[0].qty, dec!(30));
2645            }
2646            _ => panic!("Expected FilterToCurrentLifecycle, was {result:?}"),
2647        }
2648    }
2649
2650    #[rstest]
2651    fn test_adjust_fills_alternating_long_short_positions(instrument: InstrumentAny) {
2652        let venue_order_id = create_test_venue_order_id("ORDER1");
2653
2654        // Alternating: Long -> Short -> Long -> Short -> Long
2655        // These are flips (sign changes) but never go to exactly zero
2656        let fills = vec![
2657            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2658            FillSnapshot::new(2000, OrderSide::Sell, dec!(20), dec!(102), venue_order_id), // Flip to -10
2659            FillSnapshot::new(3000, OrderSide::Buy, dec!(20), dec!(101), venue_order_id), // Flip to +10
2660            FillSnapshot::new(4000, OrderSide::Sell, dec!(20), dec!(103), venue_order_id), // Flip to -10
2661            FillSnapshot::new(5000, OrderSide::Buy, dec!(20), dec!(102), venue_order_id), // Flip to +10
2662        ];
2663
2664        // Current position: +10 @ 102
2665        let venue_position = VenuePositionSnapshot {
2666            side: OrderSide::Buy,
2667            qty: dec!(10),
2668            avg_px: dec!(102),
2669        };
2670
2671        let result =
2672            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2673
2674        // Position never went flat (0), just flipped sides. This is treated as one
2675        // continuous lifecycle since no explicit close occurred. The final position
2676        // matches so no adjustment needed.
2677        assert!(
2678            matches!(result, FillAdjustmentResult::NoAdjustment),
2679            "Expected NoAdjustment (continuous lifecycle with matching position), was {result:?}"
2680        );
2681    }
2682
2683    #[rstest]
2684    fn test_adjust_fills_with_flat_crossings(instrument: InstrumentAny) {
2685        let venue_order_id = create_test_venue_order_id("ORDER1");
2686
2687        // Proper lifecycle boundaries with flat crossings (position goes to exactly 0)
2688        let fills = vec![
2689            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2690            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), venue_order_id), // Close to 0
2691            FillSnapshot::new(3000, OrderSide::Sell, dec!(10), dec!(101), venue_order_id), // New short
2692            FillSnapshot::new(4000, OrderSide::Buy, dec!(10), dec!(99), venue_order_id), // Close to 0
2693            FillSnapshot::new(5000, OrderSide::Buy, dec!(10), dec!(98), venue_order_id), // New long
2694        ];
2695
2696        // Current position: +10 @ 98
2697        let venue_position = VenuePositionSnapshot {
2698            side: OrderSide::Buy,
2699            qty: dec!(10),
2700            avg_px: dec!(98),
2701        };
2702
2703        let result =
2704            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2705
2706        // Position went flat at ts=2000 and ts=4000
2707        // Current lifecycle starts after last flat (4000)
2708        match result {
2709            FillAdjustmentResult::FilterToCurrentLifecycle {
2710                last_zero_crossing_ts,
2711                current_lifecycle_fills,
2712            } => {
2713                assert_eq!(last_zero_crossing_ts, 4000);
2714                assert_eq!(current_lifecycle_fills.len(), 1);
2715                assert_eq!(current_lifecycle_fills[0].ts_event, 5000);
2716                assert_eq!(current_lifecycle_fills[0].qty, dec!(10));
2717            }
2718            _ => panic!("Expected FilterToCurrentLifecycle, was {result:?}"),
2719        }
2720    }
2721
2722    #[rstest]
2723    fn test_replace_current_lifecycle_uses_first_venue_order_id(instrument: InstrumentAny) {
2724        let order_id_1 = create_test_venue_order_id("ORDER1");
2725        let order_id_2 = create_test_venue_order_id("ORDER2");
2726        let order_id_3 = create_test_venue_order_id("ORDER3");
2727
2728        // Previous lifecycle closes, then current lifecycle has fills from multiple orders
2729        let fills = vec![
2730            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), order_id_1),
2731            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), order_id_1), // Close to 0
2732            // Current lifecycle: fills from different venue order IDs
2733            FillSnapshot::new(3000, OrderSide::Buy, dec!(5), dec!(103), order_id_2),
2734            FillSnapshot::new(4000, OrderSide::Buy, dec!(5), dec!(104), order_id_3),
2735        ];
2736
2737        // Venue position differs from simulated (+10 @ 103.5) to trigger replacement
2738        let venue_position = VenuePositionSnapshot {
2739            side: OrderSide::Buy,
2740            qty: dec!(15),
2741            avg_px: dec!(105),
2742        };
2743
2744        let result =
2745            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2746
2747        // Should replace with synthetic fill using first fill's venue_order_id (order_id_2)
2748        match result {
2749            FillAdjustmentResult::ReplaceCurrentLifecycle {
2750                synthetic_fill,
2751                first_venue_order_id,
2752            } => {
2753                assert_eq!(first_venue_order_id, order_id_2);
2754                assert_eq!(synthetic_fill.venue_order_id, order_id_2);
2755                assert_eq!(synthetic_fill.qty, dec!(15));
2756                assert_eq!(synthetic_fill.px, dec!(105));
2757            }
2758            _ => panic!("Expected ReplaceCurrentLifecycle, was {result:?}"),
2759        }
2760    }
2761
2762    fn make_test_report(
2763        instrument_id: InstrumentId,
2764        order_type: OrderType,
2765        status: OrderStatus,
2766        filled_qty: &str,
2767        post_only: bool,
2768    ) -> OrderStatusReport {
2769        let account_id = AccountId::from("TEST-001");
2770        let mut report = OrderStatusReport::new(
2771            account_id,
2772            instrument_id,
2773            None,
2774            VenueOrderId::from("V-001"),
2775            OrderSide::Buy,
2776            order_type,
2777            TimeInForce::Gtc,
2778            status,
2779            Quantity::from("1.0"),
2780            Quantity::from(filled_qty),
2781            UnixNanos::from(1_000_000),
2782            UnixNanos::from(1_000_000),
2783            UnixNanos::from(1_000_000),
2784            None,
2785        )
2786        .with_price(Price::from("100.00"))
2787        .with_avg_px(100.0)
2788        .unwrap();
2789        report.post_only = post_only;
2790        report
2791    }
2792
2793    #[rstest]
2794    #[case::accepted(OrderStatus::Accepted, "0", 1, "Accepted")]
2795    #[case::triggered(OrderStatus::Triggered, "0", 1, "Accepted")]
2796    #[case::canceled(OrderStatus::Canceled, "0", 2, "Canceled")]
2797    #[case::expired(OrderStatus::Expired, "0", 2, "Expired")]
2798    #[case::filled(OrderStatus::Filled, "1.0", 2, "Filled")]
2799    #[case::partially_filled(OrderStatus::PartiallyFilled, "0.5", 2, "Filled")]
2800    #[case::rejected(OrderStatus::Rejected, "0", 1, "Rejected")]
2801    fn test_external_order_status_event_generation(
2802        #[case] status: OrderStatus,
2803        #[case] filled_qty: &str,
2804        #[case] expected_events: usize,
2805        #[case] last_event_type: &str,
2806    ) {
2807        let instrument = crypto_perpetual_ethusdt();
2808        let order = OrderTestBuilder::new(OrderType::Limit)
2809            .instrument_id(instrument.id())
2810            .side(OrderSide::Buy)
2811            .quantity(Quantity::from("1.0"))
2812            .price(Price::from("100.00"))
2813            .build();
2814        let report = make_test_report(instrument.id(), OrderType::Limit, status, filled_qty, false);
2815
2816        let events = generate_external_order_status_events(
2817            &order,
2818            &report,
2819            &AccountId::from("TEST-001"),
2820            &InstrumentAny::CryptoPerpetual(instrument),
2821            UnixNanos::from(2_000_000),
2822        );
2823
2824        assert_eq!(events.len(), expected_events, "status={status}");
2825        let last = events.last().unwrap();
2826        let actual_type = match last {
2827            OrderEventAny::Accepted(_) => "Accepted",
2828            OrderEventAny::Canceled(_) => "Canceled",
2829            OrderEventAny::Expired(_) => "Expired",
2830            OrderEventAny::Filled(_) => "Filled",
2831            OrderEventAny::Rejected(_) => "Rejected",
2832            _ => "Other",
2833        };
2834        assert_eq!(actual_type, last_event_type, "status={status}");
2835    }
2836
2837    #[rstest]
2838    #[case::market(OrderType::Market, false, LiquiditySide::Taker)]
2839    #[case::stop_market(OrderType::StopMarket, false, LiquiditySide::Taker)]
2840    #[case::trailing_stop_market(OrderType::TrailingStopMarket, false, LiquiditySide::Taker)]
2841    #[case::limit_post_only(OrderType::Limit, true, LiquiditySide::Maker)]
2842    #[case::limit_default(OrderType::Limit, false, LiquiditySide::NoLiquiditySide)]
2843    fn test_inferred_fill_liquidity_side(
2844        #[case] order_type: OrderType,
2845        #[case] post_only: bool,
2846        #[case] expected: LiquiditySide,
2847    ) {
2848        let instrument = crypto_perpetual_ethusdt();
2849        let order = match order_type {
2850            OrderType::Limit => OrderTestBuilder::new(order_type)
2851                .instrument_id(instrument.id())
2852                .side(OrderSide::Buy)
2853                .quantity(Quantity::from("1.0"))
2854                .price(Price::from("100.00"))
2855                .build(),
2856            OrderType::StopMarket => OrderTestBuilder::new(order_type)
2857                .instrument_id(instrument.id())
2858                .side(OrderSide::Buy)
2859                .quantity(Quantity::from("1.0"))
2860                .trigger_price(Price::from("100.00"))
2861                .build(),
2862            OrderType::TrailingStopMarket => OrderTestBuilder::new(order_type)
2863                .instrument_id(instrument.id())
2864                .side(OrderSide::Buy)
2865                .quantity(Quantity::from("1.0"))
2866                .trigger_price(Price::from("100.00"))
2867                .trailing_offset(dec!(1.0))
2868                .build(),
2869            _ => OrderTestBuilder::new(order_type)
2870                .instrument_id(instrument.id())
2871                .side(OrderSide::Buy)
2872                .quantity(Quantity::from("1.0"))
2873                .build(),
2874        };
2875        let report = make_test_report(
2876            instrument.id(),
2877            order_type,
2878            OrderStatus::Filled,
2879            "1.0",
2880            post_only,
2881        );
2882
2883        let fill = create_inferred_fill(
2884            &order,
2885            &report,
2886            &AccountId::from("TEST-001"),
2887            &InstrumentAny::CryptoPerpetual(instrument),
2888            UnixNanos::from(2_000_000),
2889        );
2890
2891        let filled = match fill.unwrap() {
2892            OrderEventAny::Filled(f) => f,
2893            _ => panic!("Expected Filled event"),
2894        };
2895        assert_eq!(
2896            filled.liquidity_side, expected,
2897            "order_type={order_type}, post_only={post_only}"
2898        );
2899    }
2900
2901    #[rstest]
2902    fn test_inferred_fill_no_price_returns_none() {
2903        let instrument = crypto_perpetual_ethusdt();
2904        let order = OrderTestBuilder::new(OrderType::Market)
2905            .instrument_id(instrument.id())
2906            .side(OrderSide::Buy)
2907            .quantity(Quantity::from("1.0"))
2908            .build();
2909
2910        let report = OrderStatusReport::new(
2911            AccountId::from("TEST-001"),
2912            instrument.id(),
2913            None,
2914            VenueOrderId::from("V-001"),
2915            OrderSide::Buy,
2916            OrderType::Market,
2917            TimeInForce::Ioc,
2918            OrderStatus::Filled,
2919            Quantity::from("1.0"),
2920            Quantity::from("1.0"),
2921            UnixNanos::from(1_000_000),
2922            UnixNanos::from(1_000_000),
2923            UnixNanos::from(1_000_000),
2924            None,
2925        );
2926
2927        let fill = create_inferred_fill(
2928            &order,
2929            &report,
2930            &AccountId::from("TEST-001"),
2931            &InstrumentAny::CryptoPerpetual(instrument),
2932            UnixNanos::from(2_000_000),
2933        );
2934
2935        assert!(fill.is_none());
2936    }
2937
2938    // Tests for reconcile_fill_report
2939
2940    fn create_test_fill_report(
2941        instrument_id: InstrumentId,
2942        venue_order_id: VenueOrderId,
2943        trade_id: TradeId,
2944        last_qty: Quantity,
2945        last_px: Price,
2946    ) -> FillReport {
2947        FillReport::new(
2948            AccountId::from("TEST-001"),
2949            instrument_id,
2950            venue_order_id,
2951            trade_id,
2952            OrderSide::Buy,
2953            last_qty,
2954            last_px,
2955            Money::new(0.10, Currency::USD()),
2956            LiquiditySide::Taker,
2957            None,
2958            None,
2959            UnixNanos::from(1_000_000),
2960            UnixNanos::from(1_000_000),
2961            None,
2962        )
2963    }
2964
2965    #[rstest]
2966    fn test_reconcile_fill_report_success(instrument: InstrumentAny) {
2967        let order = OrderTestBuilder::new(OrderType::Market)
2968            .instrument_id(instrument.id())
2969            .side(OrderSide::Buy)
2970            .quantity(Quantity::from("100"))
2971            .build();
2972
2973        let fill_report = create_test_fill_report(
2974            instrument.id(),
2975            VenueOrderId::from("V-001"),
2976            TradeId::from("T-001"),
2977            Quantity::from("50"),
2978            Price::from("1.00000"),
2979        );
2980
2981        let result = reconcile_fill_report(
2982            &order,
2983            &fill_report,
2984            &instrument,
2985            UnixNanos::from(2_000_000),
2986            false,
2987        );
2988
2989        assert!(result.is_some());
2990        if let Some(OrderEventAny::Filled(filled)) = result {
2991            assert_eq!(filled.last_qty, Quantity::from("50"));
2992            assert_eq!(filled.last_px, Price::from("1.00000"));
2993            assert_eq!(filled.trade_id, TradeId::from("T-001"));
2994            assert!(filled.reconciliation);
2995        } else {
2996            panic!("Expected OrderFilled event");
2997        }
2998    }
2999
3000    #[rstest]
3001    fn test_reconcile_fill_report_duplicate_detected(instrument: InstrumentAny) {
3002        // Create an order
3003        let mut order = OrderTestBuilder::new(OrderType::Market)
3004            .instrument_id(instrument.id())
3005            .side(OrderSide::Buy)
3006            .quantity(Quantity::from("100"))
3007            .build();
3008
3009        let account_id = AccountId::from("TEST-001");
3010        let venue_order_id = VenueOrderId::from("V-001");
3011        let trade_id = TradeId::from("T-001");
3012
3013        // Submit the order first
3014        let submitted = OrderSubmitted::new(
3015            order.trader_id(),
3016            order.strategy_id(),
3017            order.instrument_id(),
3018            order.client_order_id(),
3019            account_id,
3020            UUID4::new(),
3021            UnixNanos::from(500_000),
3022            UnixNanos::from(500_000),
3023        );
3024        order.apply(OrderEventAny::Submitted(submitted)).unwrap();
3025
3026        // Accept the order
3027        let accepted = OrderAccepted::new(
3028            order.trader_id(),
3029            order.strategy_id(),
3030            order.instrument_id(),
3031            order.client_order_id(),
3032            venue_order_id,
3033            account_id,
3034            UUID4::new(),
3035            UnixNanos::from(600_000),
3036            UnixNanos::from(600_000),
3037            false,
3038        );
3039        order.apply(OrderEventAny::Accepted(accepted)).unwrap();
3040
3041        // Now apply a fill to the order
3042        let filled_event = OrderFilled::new(
3043            order.trader_id(),
3044            order.strategy_id(),
3045            order.instrument_id(),
3046            order.client_order_id(),
3047            venue_order_id,
3048            account_id,
3049            trade_id,
3050            OrderSide::Buy,
3051            order.order_type(),
3052            Quantity::from("50"),
3053            Price::from("1.00000"),
3054            Currency::USD(),
3055            LiquiditySide::Taker,
3056            UUID4::new(),
3057            UnixNanos::from(1_000_000),
3058            UnixNanos::from(1_000_000),
3059            false,
3060            None,
3061            None,
3062        );
3063        order.apply(OrderEventAny::Filled(filled_event)).unwrap();
3064
3065        // Now try to reconcile the same fill - should be rejected as duplicate
3066        let fill_report = create_test_fill_report(
3067            instrument.id(),
3068            venue_order_id,
3069            trade_id, // Same trade_id
3070            Quantity::from("50"),
3071            Price::from("1.00000"),
3072        );
3073
3074        let result = reconcile_fill_report(
3075            &order,
3076            &fill_report,
3077            &instrument,
3078            UnixNanos::from(2_000_000),
3079            false,
3080        );
3081
3082        assert!(result.is_none());
3083    }
3084
3085    #[rstest]
3086    fn test_reconcile_fill_report_overfill_rejected(instrument: InstrumentAny) {
3087        let order = OrderTestBuilder::new(OrderType::Market)
3088            .instrument_id(instrument.id())
3089            .side(OrderSide::Buy)
3090            .quantity(Quantity::from("100"))
3091            .build();
3092
3093        // Fill for 150 would overfill a 100 qty order
3094        let fill_report = create_test_fill_report(
3095            instrument.id(),
3096            VenueOrderId::from("V-001"),
3097            TradeId::from("T-001"),
3098            Quantity::from("150"),
3099            Price::from("1.00000"),
3100        );
3101
3102        let result = reconcile_fill_report(
3103            &order,
3104            &fill_report,
3105            &instrument,
3106            UnixNanos::from(2_000_000),
3107            false, // Don't allow overfills
3108        );
3109
3110        assert!(result.is_none());
3111    }
3112
3113    #[rstest]
3114    fn test_reconcile_fill_report_overfill_allowed(instrument: InstrumentAny) {
3115        let order = OrderTestBuilder::new(OrderType::Market)
3116            .instrument_id(instrument.id())
3117            .side(OrderSide::Buy)
3118            .quantity(Quantity::from("100"))
3119            .build();
3120
3121        let fill_report = create_test_fill_report(
3122            instrument.id(),
3123            VenueOrderId::from("V-001"),
3124            TradeId::from("T-001"),
3125            Quantity::from("150"),
3126            Price::from("1.00000"),
3127        );
3128
3129        let result = reconcile_fill_report(
3130            &order,
3131            &fill_report,
3132            &instrument,
3133            UnixNanos::from(2_000_000),
3134            true, // Allow overfills
3135        );
3136
3137        // Should produce a fill event when overfills are allowed
3138        assert!(result.is_some());
3139    }
3140
3141    // Tests for check_position_reconciliation
3142
3143    #[rstest]
3144    fn test_check_position_reconciliation_both_flat() {
3145        let report = PositionStatusReport::new(
3146            AccountId::from("TEST-001"),
3147            InstrumentId::from("AUDUSD.SIM"),
3148            PositionSideSpecified::Flat,
3149            Quantity::from("0"),
3150            UnixNanos::from(1_000_000),
3151            UnixNanos::from(1_000_000),
3152            None,
3153            None,
3154            None,
3155        );
3156
3157        let result = check_position_reconciliation(&report, dec!(0), Some(5));
3158        assert!(result);
3159    }
3160
3161    #[rstest]
3162    fn test_check_position_reconciliation_exact_match_long() {
3163        let report = PositionStatusReport::new(
3164            AccountId::from("TEST-001"),
3165            InstrumentId::from("AUDUSD.SIM"),
3166            PositionSideSpecified::Long,
3167            Quantity::from("100"),
3168            UnixNanos::from(1_000_000),
3169            UnixNanos::from(1_000_000),
3170            None,
3171            None,
3172            None,
3173        );
3174
3175        let result = check_position_reconciliation(&report, dec!(100), Some(0));
3176        assert!(result);
3177    }
3178
3179    #[rstest]
3180    fn test_check_position_reconciliation_exact_match_short() {
3181        let report = PositionStatusReport::new(
3182            AccountId::from("TEST-001"),
3183            InstrumentId::from("AUDUSD.SIM"),
3184            PositionSideSpecified::Short,
3185            Quantity::from("50"),
3186            UnixNanos::from(1_000_000),
3187            UnixNanos::from(1_000_000),
3188            None,
3189            None,
3190            None,
3191        );
3192
3193        let result = check_position_reconciliation(&report, dec!(-50), Some(0));
3194        assert!(result);
3195    }
3196
3197    #[rstest]
3198    fn test_check_position_reconciliation_within_tolerance() {
3199        let report = PositionStatusReport::new(
3200            AccountId::from("TEST-001"),
3201            InstrumentId::from("AUDUSD.SIM"),
3202            PositionSideSpecified::Long,
3203            Quantity::from("100.00001"),
3204            UnixNanos::from(1_000_000),
3205            UnixNanos::from(1_000_000),
3206            None,
3207            None,
3208            None,
3209        );
3210
3211        // Cached qty is slightly different but within tolerance
3212        let result = check_position_reconciliation(&report, dec!(100.00000), Some(5));
3213        assert!(result);
3214    }
3215
3216    #[rstest]
3217    fn test_check_position_reconciliation_discrepancy() {
3218        let report = PositionStatusReport::new(
3219            AccountId::from("TEST-001"),
3220            InstrumentId::from("AUDUSD.SIM"),
3221            PositionSideSpecified::Long,
3222            Quantity::from("100"),
3223            UnixNanos::from(1_000_000),
3224            UnixNanos::from(1_000_000),
3225            None,
3226            None,
3227            None,
3228        );
3229
3230        // Cached qty is significantly different
3231        let result = check_position_reconciliation(&report, dec!(50), Some(0));
3232        assert!(!result);
3233    }
3234
3235    // Tests for is_within_single_unit_tolerance
3236
3237    #[rstest]
3238    fn test_is_within_single_unit_tolerance_exact_match() {
3239        assert!(is_within_single_unit_tolerance(dec!(100), dec!(100), 0));
3240        assert!(is_within_single_unit_tolerance(
3241            dec!(100.12345),
3242            dec!(100.12345),
3243            5
3244        ));
3245    }
3246
3247    #[rstest]
3248    fn test_is_within_single_unit_tolerance_integer_precision() {
3249        // Integer precision requires exact match
3250        assert!(is_within_single_unit_tolerance(dec!(100), dec!(100), 0));
3251        assert!(!is_within_single_unit_tolerance(dec!(100), dec!(101), 0));
3252    }
3253
3254    #[rstest]
3255    fn test_is_within_single_unit_tolerance_fractional_precision() {
3256        // With precision 2, tolerance is 0.01
3257        assert!(is_within_single_unit_tolerance(dec!(100), dec!(100.01), 2));
3258        assert!(is_within_single_unit_tolerance(dec!(100), dec!(99.99), 2));
3259        assert!(!is_within_single_unit_tolerance(dec!(100), dec!(100.02), 2));
3260    }
3261
3262    #[rstest]
3263    fn test_is_within_single_unit_tolerance_high_precision() {
3264        // With precision 5, tolerance is 0.00001
3265        assert!(is_within_single_unit_tolerance(
3266            dec!(100),
3267            dec!(100.00001),
3268            5
3269        ));
3270        assert!(is_within_single_unit_tolerance(
3271            dec!(100),
3272            dec!(99.99999),
3273            5
3274        ));
3275        assert!(!is_within_single_unit_tolerance(
3276            dec!(100),
3277            dec!(100.00002),
3278            5
3279        ));
3280    }
3281
3282    fn create_test_order_status_report(
3283        client_order_id: ClientOrderId,
3284        venue_order_id: VenueOrderId,
3285        instrument_id: InstrumentId,
3286        order_type: OrderType,
3287        order_status: OrderStatus,
3288        quantity: Quantity,
3289        filled_qty: Quantity,
3290    ) -> OrderStatusReport {
3291        OrderStatusReport::new(
3292            AccountId::from("SIM-001"),
3293            instrument_id,
3294            Some(client_order_id),
3295            venue_order_id,
3296            OrderSide::Buy,
3297            order_type,
3298            TimeInForce::Gtc,
3299            order_status,
3300            quantity,
3301            filled_qty,
3302            UnixNanos::from(1_000_000),
3303            UnixNanos::from(1_000_000),
3304            UnixNanos::from(1_000_000),
3305            None,
3306        )
3307    }
3308
3309    #[rstest]
3310    #[case::identical_limit_order(
3311        OrderType::Limit,
3312        Quantity::from(100),
3313        Some(Price::from("1.00000")),
3314        None,
3315        Quantity::from(100),
3316        Some(Price::from("1.00000")),
3317        None,
3318        false
3319    )]
3320    #[case::quantity_changed(
3321        OrderType::Limit,
3322        Quantity::from(100),
3323        Some(Price::from("1.00000")),
3324        None,
3325        Quantity::from(150),
3326        Some(Price::from("1.00000")),
3327        None,
3328        true
3329    )]
3330    #[case::limit_price_changed(
3331        OrderType::Limit,
3332        Quantity::from(100),
3333        Some(Price::from("1.00000")),
3334        None,
3335        Quantity::from(100),
3336        Some(Price::from("1.00100")),
3337        None,
3338        true
3339    )]
3340    #[case::stop_trigger_changed(
3341        OrderType::StopMarket,
3342        Quantity::from(100),
3343        None,
3344        Some(Price::from("0.99000")),
3345        Quantity::from(100),
3346        None,
3347        Some(Price::from("0.98000")),
3348        true
3349    )]
3350    #[case::stop_limit_trigger_changed(
3351        OrderType::StopLimit,
3352        Quantity::from(100),
3353        Some(Price::from("1.00000")),
3354        Some(Price::from("0.99000")),
3355        Quantity::from(100),
3356        Some(Price::from("1.00000")),
3357        Some(Price::from("0.98000")),
3358        true
3359    )]
3360    #[case::stop_limit_price_changed(
3361        OrderType::StopLimit,
3362        Quantity::from(100),
3363        Some(Price::from("1.00000")),
3364        Some(Price::from("0.99000")),
3365        Quantity::from(100),
3366        Some(Price::from("1.00100")),
3367        Some(Price::from("0.99000")),
3368        true
3369    )]
3370    #[case::market_order_no_update(
3371        OrderType::Market,
3372        Quantity::from(100),
3373        None,
3374        None,
3375        Quantity::from(100),
3376        None,
3377        None,
3378        false
3379    )]
3380    fn test_should_reconciliation_update(
3381        instrument: InstrumentAny,
3382        #[case] order_type: OrderType,
3383        #[case] order_qty: Quantity,
3384        #[case] order_price: Option<Price>,
3385        #[case] order_trigger: Option<Price>,
3386        #[case] report_qty: Quantity,
3387        #[case] report_price: Option<Price>,
3388        #[case] report_trigger: Option<Price>,
3389        #[case] expected: bool,
3390    ) {
3391        let client_order_id = ClientOrderId::from("O-001");
3392        let venue_order_id = VenueOrderId::from("V-001");
3393
3394        let mut order = match (order_price, order_trigger) {
3395            (Some(price), Some(trigger)) => OrderTestBuilder::new(order_type)
3396                .instrument_id(instrument.id())
3397                .client_order_id(client_order_id)
3398                .side(OrderSide::Buy)
3399                .quantity(order_qty)
3400                .price(price)
3401                .trigger_price(trigger)
3402                .build(),
3403            (Some(price), None) => OrderTestBuilder::new(order_type)
3404                .instrument_id(instrument.id())
3405                .client_order_id(client_order_id)
3406                .side(OrderSide::Buy)
3407                .quantity(order_qty)
3408                .price(price)
3409                .build(),
3410            (None, Some(trigger)) => OrderTestBuilder::new(order_type)
3411                .instrument_id(instrument.id())
3412                .client_order_id(client_order_id)
3413                .side(OrderSide::Buy)
3414                .quantity(order_qty)
3415                .trigger_price(trigger)
3416                .build(),
3417            (None, None) => OrderTestBuilder::new(order_type)
3418                .instrument_id(instrument.id())
3419                .client_order_id(client_order_id)
3420                .side(OrderSide::Buy)
3421                .quantity(order_qty)
3422                .build(),
3423        };
3424
3425        let submitted = OrderSubmitted::new(
3426            order.trader_id(),
3427            order.strategy_id(),
3428            order.instrument_id(),
3429            order.client_order_id(),
3430            AccountId::from("SIM-001"),
3431            UUID4::new(),
3432            UnixNanos::default(),
3433            UnixNanos::default(),
3434        );
3435        order.apply(OrderEventAny::Submitted(submitted)).unwrap();
3436
3437        let accepted = OrderAccepted::new(
3438            order.trader_id(),
3439            order.strategy_id(),
3440            order.instrument_id(),
3441            order.client_order_id(),
3442            venue_order_id,
3443            AccountId::from("SIM-001"),
3444            UUID4::new(),
3445            UnixNanos::default(),
3446            UnixNanos::default(),
3447            false,
3448        );
3449        order.apply(OrderEventAny::Accepted(accepted)).unwrap();
3450
3451        let mut report = create_test_order_status_report(
3452            client_order_id,
3453            venue_order_id,
3454            instrument.id(),
3455            order_type,
3456            OrderStatus::Accepted,
3457            report_qty,
3458            Quantity::from(0),
3459        );
3460        report.price = report_price;
3461        report.trigger_price = report_trigger;
3462
3463        assert_eq!(should_reconciliation_update(&order, &report), expected);
3464    }
3465
3466    #[rstest]
3467    fn test_reconcile_order_report_already_in_sync(instrument: InstrumentAny) {
3468        let client_order_id = ClientOrderId::from("O-001");
3469        let venue_order_id = VenueOrderId::from("V-001");
3470
3471        let mut order = OrderTestBuilder::new(OrderType::Limit)
3472            .instrument_id(instrument.id())
3473            .client_order_id(client_order_id)
3474            .side(OrderSide::Buy)
3475            .quantity(Quantity::from(100))
3476            .price(Price::from("1.00000"))
3477            .build();
3478
3479        let submitted = OrderSubmitted::new(
3480            order.trader_id(),
3481            order.strategy_id(),
3482            order.instrument_id(),
3483            order.client_order_id(),
3484            AccountId::from("SIM-001"),
3485            UUID4::new(),
3486            UnixNanos::default(),
3487            UnixNanos::default(),
3488        );
3489        order.apply(OrderEventAny::Submitted(submitted)).unwrap();
3490
3491        let accepted = OrderAccepted::new(
3492            order.trader_id(),
3493            order.strategy_id(),
3494            order.instrument_id(),
3495            order.client_order_id(),
3496            venue_order_id,
3497            AccountId::from("SIM-001"),
3498            UUID4::new(),
3499            UnixNanos::default(),
3500            UnixNanos::default(),
3501            false,
3502        );
3503        order.apply(OrderEventAny::Accepted(accepted)).unwrap();
3504
3505        let mut report = create_test_order_status_report(
3506            client_order_id,
3507            venue_order_id,
3508            instrument.id(),
3509            OrderType::Limit,
3510            OrderStatus::Accepted,
3511            Quantity::from(100),
3512            Quantity::from(0),
3513        );
3514        report.price = Some(Price::from("1.00000"));
3515
3516        let result =
3517            reconcile_order_report(&order, &report, Some(&instrument), UnixNanos::default());
3518        assert!(result.is_none());
3519    }
3520
3521    #[rstest]
3522    fn test_reconcile_order_report_generates_canceled(instrument: InstrumentAny) {
3523        let client_order_id = ClientOrderId::from("O-001");
3524        let venue_order_id = VenueOrderId::from("V-001");
3525
3526        let mut order = OrderTestBuilder::new(OrderType::Limit)
3527            .instrument_id(instrument.id())
3528            .client_order_id(client_order_id)
3529            .side(OrderSide::Buy)
3530            .quantity(Quantity::from(100))
3531            .price(Price::from("1.00000"))
3532            .build();
3533
3534        let submitted = OrderSubmitted::new(
3535            order.trader_id(),
3536            order.strategy_id(),
3537            order.instrument_id(),
3538            order.client_order_id(),
3539            AccountId::from("SIM-001"),
3540            UUID4::new(),
3541            UnixNanos::default(),
3542            UnixNanos::default(),
3543        );
3544        order.apply(OrderEventAny::Submitted(submitted)).unwrap();
3545
3546        let accepted = OrderAccepted::new(
3547            order.trader_id(),
3548            order.strategy_id(),
3549            order.instrument_id(),
3550            order.client_order_id(),
3551            venue_order_id,
3552            AccountId::from("SIM-001"),
3553            UUID4::new(),
3554            UnixNanos::default(),
3555            UnixNanos::default(),
3556            false,
3557        );
3558        order.apply(OrderEventAny::Accepted(accepted)).unwrap();
3559
3560        let report = create_test_order_status_report(
3561            client_order_id,
3562            venue_order_id,
3563            instrument.id(),
3564            OrderType::Limit,
3565            OrderStatus::Canceled,
3566            Quantity::from(100),
3567            Quantity::from(0),
3568        );
3569
3570        let result =
3571            reconcile_order_report(&order, &report, Some(&instrument), UnixNanos::default());
3572        assert!(result.is_some());
3573        assert!(matches!(result.unwrap(), OrderEventAny::Canceled(_)));
3574    }
3575
3576    #[rstest]
3577    fn test_reconcile_order_report_generates_expired(instrument: InstrumentAny) {
3578        let client_order_id = ClientOrderId::from("O-001");
3579        let venue_order_id = VenueOrderId::from("V-001");
3580
3581        let mut order = OrderTestBuilder::new(OrderType::Limit)
3582            .instrument_id(instrument.id())
3583            .client_order_id(client_order_id)
3584            .side(OrderSide::Buy)
3585            .quantity(Quantity::from(100))
3586            .price(Price::from("1.00000"))
3587            .build();
3588
3589        let submitted = OrderSubmitted::new(
3590            order.trader_id(),
3591            order.strategy_id(),
3592            order.instrument_id(),
3593            order.client_order_id(),
3594            AccountId::from("SIM-001"),
3595            UUID4::new(),
3596            UnixNanos::default(),
3597            UnixNanos::default(),
3598        );
3599        order.apply(OrderEventAny::Submitted(submitted)).unwrap();
3600
3601        let accepted = OrderAccepted::new(
3602            order.trader_id(),
3603            order.strategy_id(),
3604            order.instrument_id(),
3605            order.client_order_id(),
3606            venue_order_id,
3607            AccountId::from("SIM-001"),
3608            UUID4::new(),
3609            UnixNanos::default(),
3610            UnixNanos::default(),
3611            false,
3612        );
3613        order.apply(OrderEventAny::Accepted(accepted)).unwrap();
3614
3615        let report = create_test_order_status_report(
3616            client_order_id,
3617            venue_order_id,
3618            instrument.id(),
3619            OrderType::Limit,
3620            OrderStatus::Expired,
3621            Quantity::from(100),
3622            Quantity::from(0),
3623        );
3624
3625        let result =
3626            reconcile_order_report(&order, &report, Some(&instrument), UnixNanos::default());
3627        assert!(result.is_some());
3628        assert!(matches!(result.unwrap(), OrderEventAny::Expired(_)));
3629    }
3630
3631    #[rstest]
3632    fn test_reconcile_order_report_generates_rejected(instrument: InstrumentAny) {
3633        let client_order_id = ClientOrderId::from("O-001");
3634        let venue_order_id = VenueOrderId::from("V-001");
3635
3636        let mut order = OrderTestBuilder::new(OrderType::Limit)
3637            .instrument_id(instrument.id())
3638            .client_order_id(client_order_id)
3639            .side(OrderSide::Buy)
3640            .quantity(Quantity::from(100))
3641            .price(Price::from("1.00000"))
3642            .build();
3643
3644        let submitted = OrderSubmitted::new(
3645            order.trader_id(),
3646            order.strategy_id(),
3647            order.instrument_id(),
3648            order.client_order_id(),
3649            AccountId::from("SIM-001"),
3650            UUID4::new(),
3651            UnixNanos::default(),
3652            UnixNanos::default(),
3653        );
3654        order.apply(OrderEventAny::Submitted(submitted)).unwrap();
3655
3656        let mut report = create_test_order_status_report(
3657            client_order_id,
3658            venue_order_id,
3659            instrument.id(),
3660            OrderType::Limit,
3661            OrderStatus::Rejected,
3662            Quantity::from(100),
3663            Quantity::from(0),
3664        );
3665        report.cancel_reason = Some("INSUFFICIENT_MARGIN".to_string());
3666
3667        let result =
3668            reconcile_order_report(&order, &report, Some(&instrument), UnixNanos::default());
3669        assert!(result.is_some());
3670        if let OrderEventAny::Rejected(rejected) = result.unwrap() {
3671            assert_eq!(rejected.reason.as_str(), "INSUFFICIENT_MARGIN");
3672            assert_eq!(rejected.reconciliation, 1);
3673        } else {
3674            panic!("Expected Rejected event");
3675        }
3676    }
3677
3678    #[rstest]
3679    fn test_reconcile_order_report_generates_updated(instrument: InstrumentAny) {
3680        let client_order_id = ClientOrderId::from("O-001");
3681        let venue_order_id = VenueOrderId::from("V-001");
3682
3683        let mut order = OrderTestBuilder::new(OrderType::Limit)
3684            .instrument_id(instrument.id())
3685            .client_order_id(client_order_id)
3686            .side(OrderSide::Buy)
3687            .quantity(Quantity::from(100))
3688            .price(Price::from("1.00000"))
3689            .build();
3690
3691        let submitted = OrderSubmitted::new(
3692            order.trader_id(),
3693            order.strategy_id(),
3694            order.instrument_id(),
3695            order.client_order_id(),
3696            AccountId::from("SIM-001"),
3697            UUID4::new(),
3698            UnixNanos::default(),
3699            UnixNanos::default(),
3700        );
3701        order.apply(OrderEventAny::Submitted(submitted)).unwrap();
3702
3703        let accepted = OrderAccepted::new(
3704            order.trader_id(),
3705            order.strategy_id(),
3706            order.instrument_id(),
3707            order.client_order_id(),
3708            venue_order_id,
3709            AccountId::from("SIM-001"),
3710            UUID4::new(),
3711            UnixNanos::default(),
3712            UnixNanos::default(),
3713            false,
3714        );
3715        order.apply(OrderEventAny::Accepted(accepted)).unwrap();
3716
3717        // Report with changed price - same status, same filled_qty
3718        let mut report = create_test_order_status_report(
3719            client_order_id,
3720            venue_order_id,
3721            instrument.id(),
3722            OrderType::Limit,
3723            OrderStatus::Accepted,
3724            Quantity::from(100),
3725            Quantity::from(0),
3726        );
3727        report.price = Some(Price::from("1.00100"));
3728
3729        let result =
3730            reconcile_order_report(&order, &report, Some(&instrument), UnixNanos::default());
3731        assert!(result.is_some());
3732        assert!(matches!(result.unwrap(), OrderEventAny::Updated(_)));
3733    }
3734
3735    #[rstest]
3736    fn test_reconcile_order_report_generates_fill_for_qty_mismatch(instrument: InstrumentAny) {
3737        let client_order_id = ClientOrderId::from("O-001");
3738        let venue_order_id = VenueOrderId::from("V-001");
3739
3740        let mut order = OrderTestBuilder::new(OrderType::Limit)
3741            .instrument_id(instrument.id())
3742            .client_order_id(client_order_id)
3743            .side(OrderSide::Buy)
3744            .quantity(Quantity::from(100))
3745            .price(Price::from("1.00000"))
3746            .build();
3747
3748        let submitted = OrderSubmitted::new(
3749            order.trader_id(),
3750            order.strategy_id(),
3751            order.instrument_id(),
3752            order.client_order_id(),
3753            AccountId::from("SIM-001"),
3754            UUID4::new(),
3755            UnixNanos::default(),
3756            UnixNanos::default(),
3757        );
3758        order.apply(OrderEventAny::Submitted(submitted)).unwrap();
3759
3760        let accepted = OrderAccepted::new(
3761            order.trader_id(),
3762            order.strategy_id(),
3763            order.instrument_id(),
3764            order.client_order_id(),
3765            venue_order_id,
3766            AccountId::from("SIM-001"),
3767            UUID4::new(),
3768            UnixNanos::default(),
3769            UnixNanos::default(),
3770            false,
3771        );
3772        order.apply(OrderEventAny::Accepted(accepted)).unwrap();
3773
3774        // Report shows 50 filled but order has 0
3775        let mut report = create_test_order_status_report(
3776            client_order_id,
3777            venue_order_id,
3778            instrument.id(),
3779            OrderType::Limit,
3780            OrderStatus::PartiallyFilled,
3781            Quantity::from(100),
3782            Quantity::from(50),
3783        );
3784        report.avg_px = Some(dec!(1.0));
3785
3786        let result =
3787            reconcile_order_report(&order, &report, Some(&instrument), UnixNanos::default());
3788        assert!(result.is_some());
3789        assert!(matches!(result.unwrap(), OrderEventAny::Filled(_)));
3790    }
3791
3792    #[rstest]
3793    fn test_create_reconciliation_rejected_with_reason() {
3794        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3795        let client_order_id = ClientOrderId::from("O-001");
3796
3797        let mut order = OrderTestBuilder::new(OrderType::Limit)
3798            .instrument_id(instrument.id())
3799            .client_order_id(client_order_id)
3800            .side(OrderSide::Buy)
3801            .quantity(Quantity::from(100))
3802            .price(Price::from("1.00000"))
3803            .build();
3804
3805        let submitted = OrderSubmitted::new(
3806            order.trader_id(),
3807            order.strategy_id(),
3808            order.instrument_id(),
3809            order.client_order_id(),
3810            AccountId::from("SIM-001"),
3811            UUID4::new(),
3812            UnixNanos::default(),
3813            UnixNanos::default(),
3814        );
3815        order.apply(OrderEventAny::Submitted(submitted)).unwrap();
3816
3817        let result =
3818            create_reconciliation_rejected(&order, Some("MARGIN_CALL"), UnixNanos::from(1_000));
3819        assert!(result.is_some());
3820        if let OrderEventAny::Rejected(rejected) = result.unwrap() {
3821            assert_eq!(rejected.reason.as_str(), "MARGIN_CALL");
3822            assert_eq!(rejected.reconciliation, 1);
3823            assert_eq!(rejected.due_post_only, 0);
3824        } else {
3825            panic!("Expected Rejected event");
3826        }
3827    }
3828
3829    #[rstest]
3830    fn test_create_reconciliation_rejected_without_reason() {
3831        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3832        let client_order_id = ClientOrderId::from("O-001");
3833
3834        let mut order = OrderTestBuilder::new(OrderType::Limit)
3835            .instrument_id(instrument.id())
3836            .client_order_id(client_order_id)
3837            .side(OrderSide::Buy)
3838            .quantity(Quantity::from(100))
3839            .price(Price::from("1.00000"))
3840            .build();
3841
3842        let submitted = OrderSubmitted::new(
3843            order.trader_id(),
3844            order.strategy_id(),
3845            order.instrument_id(),
3846            order.client_order_id(),
3847            AccountId::from("SIM-001"),
3848            UUID4::new(),
3849            UnixNanos::default(),
3850            UnixNanos::default(),
3851        );
3852        order.apply(OrderEventAny::Submitted(submitted)).unwrap();
3853
3854        let result = create_reconciliation_rejected(&order, None, UnixNanos::from(1_000));
3855        assert!(result.is_some());
3856        if let OrderEventAny::Rejected(rejected) = result.unwrap() {
3857            assert_eq!(rejected.reason.as_str(), "UNKNOWN");
3858        } else {
3859            panic!("Expected Rejected event");
3860        }
3861    }
3862
3863    #[rstest]
3864    fn test_create_reconciliation_rejected_no_account_id() {
3865        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3866        let client_order_id = ClientOrderId::from("O-001");
3867
3868        // Order without account_id (not yet submitted)
3869        let order = OrderTestBuilder::new(OrderType::Limit)
3870            .instrument_id(instrument.id())
3871            .client_order_id(client_order_id)
3872            .side(OrderSide::Buy)
3873            .quantity(Quantity::from(100))
3874            .price(Price::from("1.00000"))
3875            .build();
3876
3877        let result = create_reconciliation_rejected(&order, Some("TEST"), UnixNanos::from(1_000));
3878        assert!(result.is_none());
3879    }
3880
3881    #[rstest]
3882    fn test_create_synthetic_venue_order_id_format() {
3883        let ts = 1_000_000_u64;
3884
3885        let id = create_synthetic_venue_order_id(ts);
3886
3887        // Format: S-{hex_timestamp}-{uuid_prefix}
3888        assert!(id.as_str().starts_with("S-"));
3889        let parts: Vec<&str> = id.as_str().split('-').collect();
3890        assert_eq!(parts.len(), 3);
3891        assert_eq!(parts[0], "S");
3892        assert!(!parts[1].is_empty());
3893    }
3894
3895    #[rstest]
3896    fn test_create_synthetic_trade_id_format() {
3897        let ts = 1_000_000_u64;
3898
3899        let id = create_synthetic_trade_id(ts);
3900
3901        // Format: S-{hex_timestamp}-{uuid_prefix}
3902        assert!(id.as_str().starts_with("S-"));
3903        let parts: Vec<&str> = id.as_str().split('-').collect();
3904        assert_eq!(parts.len(), 3);
3905        assert_eq!(parts[0], "S");
3906        assert!(!parts[1].is_empty());
3907    }
3908
3909    #[rstest]
3910    fn test_create_inferred_fill_for_qty_zero_quantity_returns_none() {
3911        let instrument = crypto_perpetual_ethusdt();
3912        let order = OrderTestBuilder::new(OrderType::Limit)
3913            .instrument_id(instrument.id())
3914            .side(OrderSide::Buy)
3915            .quantity(Quantity::from("10.0"))
3916            .price(Price::from("100.00"))
3917            .build();
3918
3919        let report = make_test_report(
3920            instrument.id(),
3921            OrderType::Limit,
3922            OrderStatus::Filled,
3923            "10.0",
3924            false,
3925        );
3926
3927        let result = create_inferred_fill_for_qty(
3928            &order,
3929            &report,
3930            &AccountId::from("TEST-001"),
3931            &InstrumentAny::CryptoPerpetual(instrument),
3932            Quantity::zero(0),
3933            UnixNanos::from(1_000_000),
3934        );
3935
3936        assert!(result.is_none());
3937    }
3938
3939    #[rstest]
3940    fn test_create_inferred_fill_for_qty_uses_report_avg_px() {
3941        let instrument = crypto_perpetual_ethusdt();
3942        let order = OrderTestBuilder::new(OrderType::Limit)
3943            .instrument_id(instrument.id())
3944            .side(OrderSide::Buy)
3945            .quantity(Quantity::from("10.0"))
3946            .price(Price::from("100.00"))
3947            .build();
3948
3949        // Report with avg_px different from order price
3950        let report = OrderStatusReport::new(
3951            AccountId::from("TEST-001"),
3952            instrument.id(),
3953            Some(order.client_order_id()),
3954            VenueOrderId::from("V-001"),
3955            OrderSide::Buy,
3956            OrderType::Limit,
3957            TimeInForce::Gtc,
3958            OrderStatus::Filled,
3959            Quantity::from("10.0"),
3960            Quantity::from("10.0"),
3961            UnixNanos::from(1_000_000),
3962            UnixNanos::from(1_000_000),
3963            UnixNanos::from(1_000_000),
3964            None,
3965        )
3966        .with_avg_px(105.50)
3967        .unwrap();
3968
3969        let result = create_inferred_fill_for_qty(
3970            &order,
3971            &report,
3972            &AccountId::from("TEST-001"),
3973            &InstrumentAny::CryptoPerpetual(instrument),
3974            Quantity::from("5.0"),
3975            UnixNanos::from(2_000_000),
3976        );
3977
3978        let filled = match result.unwrap() {
3979            OrderEventAny::Filled(f) => f,
3980            _ => panic!("Expected Filled event"),
3981        };
3982
3983        // Should use avg_px from report (105.50), not order price (100.00)
3984        assert_eq!(filled.last_px, Price::from("105.50"));
3985        assert_eq!(filled.last_qty, Quantity::from("5.0"));
3986    }
3987
3988    #[rstest]
3989    fn test_create_inferred_fill_for_qty_uses_report_price_when_no_avg_px() {
3990        let instrument = crypto_perpetual_ethusdt();
3991        let order = OrderTestBuilder::new(OrderType::Limit)
3992            .instrument_id(instrument.id())
3993            .side(OrderSide::Buy)
3994            .quantity(Quantity::from("10.0"))
3995            .price(Price::from("100.00"))
3996            .build();
3997
3998        // Report with price but no avg_px
3999        let report = OrderStatusReport::new(
4000            AccountId::from("TEST-001"),
4001            instrument.id(),
4002            Some(order.client_order_id()),
4003            VenueOrderId::from("V-001"),
4004            OrderSide::Buy,
4005            OrderType::Limit,
4006            TimeInForce::Gtc,
4007            OrderStatus::Filled,
4008            Quantity::from("10.0"),
4009            Quantity::from("10.0"),
4010            UnixNanos::from(1_000_000),
4011            UnixNanos::from(1_000_000),
4012            UnixNanos::from(1_000_000),
4013            None,
4014        )
4015        .with_price(Price::from("102.00"));
4016
4017        let result = create_inferred_fill_for_qty(
4018            &order,
4019            &report,
4020            &AccountId::from("TEST-001"),
4021            &InstrumentAny::CryptoPerpetual(instrument),
4022            Quantity::from("5.0"),
4023            UnixNanos::from(2_000_000),
4024        );
4025
4026        let filled = match result.unwrap() {
4027            OrderEventAny::Filled(f) => f,
4028            _ => panic!("Expected Filled event"),
4029        };
4030
4031        // Should use price from report (102.00)
4032        assert_eq!(filled.last_px, Price::from("102.00"));
4033    }
4034
4035    #[rstest]
4036    fn test_create_inferred_fill_for_qty_uses_order_price_as_fallback() {
4037        let instrument = crypto_perpetual_ethusdt();
4038        let order = OrderTestBuilder::new(OrderType::Limit)
4039            .instrument_id(instrument.id())
4040            .side(OrderSide::Buy)
4041            .quantity(Quantity::from("10.0"))
4042            .price(Price::from("100.00"))
4043            .build();
4044
4045        // Report with no price information
4046        let report = OrderStatusReport::new(
4047            AccountId::from("TEST-001"),
4048            instrument.id(),
4049            Some(order.client_order_id()),
4050            VenueOrderId::from("V-001"),
4051            OrderSide::Buy,
4052            OrderType::Limit,
4053            TimeInForce::Gtc,
4054            OrderStatus::Filled,
4055            Quantity::from("10.0"),
4056            Quantity::from("10.0"),
4057            UnixNanos::from(1_000_000),
4058            UnixNanos::from(1_000_000),
4059            UnixNanos::from(1_000_000),
4060            None,
4061        );
4062
4063        let result = create_inferred_fill_for_qty(
4064            &order,
4065            &report,
4066            &AccountId::from("TEST-001"),
4067            &InstrumentAny::CryptoPerpetual(instrument),
4068            Quantity::from("5.0"),
4069            UnixNanos::from(2_000_000),
4070        );
4071
4072        let filled = match result.unwrap() {
4073            OrderEventAny::Filled(f) => f,
4074            _ => panic!("Expected Filled event"),
4075        };
4076
4077        // Should fall back to order price (100.00)
4078        assert_eq!(filled.last_px, Price::from("100.00"));
4079    }
4080
4081    #[rstest]
4082    fn test_create_inferred_fill_for_qty_no_price_returns_none() {
4083        let instrument = crypto_perpetual_ethusdt();
4084
4085        // Market order has no price
4086        let order = OrderTestBuilder::new(OrderType::Market)
4087            .instrument_id(instrument.id())
4088            .side(OrderSide::Buy)
4089            .quantity(Quantity::from("10.0"))
4090            .build();
4091
4092        // Report with no price information
4093        let report = OrderStatusReport::new(
4094            AccountId::from("TEST-001"),
4095            instrument.id(),
4096            Some(order.client_order_id()),
4097            VenueOrderId::from("V-001"),
4098            OrderSide::Buy,
4099            OrderType::Market,
4100            TimeInForce::Ioc,
4101            OrderStatus::Filled,
4102            Quantity::from("10.0"),
4103            Quantity::from("10.0"),
4104            UnixNanos::from(1_000_000),
4105            UnixNanos::from(1_000_000),
4106            UnixNanos::from(1_000_000),
4107            None,
4108        );
4109
4110        let result = create_inferred_fill_for_qty(
4111            &order,
4112            &report,
4113            &AccountId::from("TEST-001"),
4114            &InstrumentAny::CryptoPerpetual(instrument),
4115            Quantity::from("5.0"),
4116            UnixNanos::from(2_000_000),
4117        );
4118
4119        assert!(result.is_none());
4120    }
4121
4122    #[rstest]
4123    #[case::market_order(OrderType::Market, false, LiquiditySide::Taker)]
4124    #[case::stop_market(OrderType::StopMarket, false, LiquiditySide::Taker)]
4125    #[case::trailing_stop_market(OrderType::TrailingStopMarket, false, LiquiditySide::Taker)]
4126    #[case::limit_post_only(OrderType::Limit, true, LiquiditySide::Maker)]
4127    #[case::limit_default(OrderType::Limit, false, LiquiditySide::NoLiquiditySide)]
4128    fn test_create_inferred_fill_for_qty_liquidity_side(
4129        #[case] order_type: OrderType,
4130        #[case] post_only: bool,
4131        #[case] expected: LiquiditySide,
4132    ) {
4133        let instrument = crypto_perpetual_ethusdt();
4134        let order = match order_type {
4135            OrderType::Limit => OrderTestBuilder::new(order_type)
4136                .instrument_id(instrument.id())
4137                .side(OrderSide::Buy)
4138                .quantity(Quantity::from("10.0"))
4139                .price(Price::from("100.00"))
4140                .post_only(post_only)
4141                .build(),
4142            OrderType::StopMarket => OrderTestBuilder::new(order_type)
4143                .instrument_id(instrument.id())
4144                .side(OrderSide::Buy)
4145                .quantity(Quantity::from("10.0"))
4146                .trigger_price(Price::from("100.00"))
4147                .build(),
4148            OrderType::TrailingStopMarket => OrderTestBuilder::new(order_type)
4149                .instrument_id(instrument.id())
4150                .side(OrderSide::Buy)
4151                .quantity(Quantity::from("10.0"))
4152                .trigger_price(Price::from("100.00"))
4153                .trailing_offset(Decimal::from(1))
4154                .build(),
4155            _ => OrderTestBuilder::new(order_type)
4156                .instrument_id(instrument.id())
4157                .side(OrderSide::Buy)
4158                .quantity(Quantity::from("10.0"))
4159                .build(),
4160        };
4161
4162        let report = make_test_report(
4163            instrument.id(),
4164            order_type,
4165            OrderStatus::Filled,
4166            "10.0",
4167            post_only,
4168        );
4169
4170        let result = create_inferred_fill_for_qty(
4171            &order,
4172            &report,
4173            &AccountId::from("TEST-001"),
4174            &InstrumentAny::CryptoPerpetual(instrument),
4175            Quantity::from("5.0"),
4176            UnixNanos::from(2_000_000),
4177        );
4178
4179        let filled = match result.unwrap() {
4180            OrderEventAny::Filled(f) => f,
4181            _ => panic!("Expected Filled event"),
4182        };
4183
4184        assert_eq!(
4185            filled.liquidity_side, expected,
4186            "order_type={order_type}, post_only={post_only}"
4187        );
4188    }
4189
4190    #[rstest]
4191    fn test_create_inferred_fill_for_qty_trade_id_format() {
4192        let instrument = crypto_perpetual_ethusdt();
4193        let order = OrderTestBuilder::new(OrderType::Limit)
4194            .instrument_id(instrument.id())
4195            .side(OrderSide::Buy)
4196            .quantity(Quantity::from("10.0"))
4197            .price(Price::from("100.00"))
4198            .build();
4199
4200        let report = make_test_report(
4201            instrument.id(),
4202            OrderType::Limit,
4203            OrderStatus::Filled,
4204            "10.0",
4205            false,
4206        );
4207
4208        let ts_now = UnixNanos::from(2_000_000);
4209        let result = create_inferred_fill_for_qty(
4210            &order,
4211            &report,
4212            &AccountId::from("TEST-001"),
4213            &InstrumentAny::CryptoPerpetual(instrument),
4214            Quantity::from("5.0"),
4215            ts_now,
4216        );
4217
4218        let filled = match result.unwrap() {
4219            OrderEventAny::Filled(f) => f,
4220            _ => panic!("Expected Filled event"),
4221        };
4222
4223        // Trade ID should be a valid UUID (36 characters with dashes)
4224        assert_eq!(filled.trade_id.as_str().len(), 36);
4225        assert!(filled.trade_id.as_str().contains('-'));
4226    }
4227
4228    #[rstest]
4229    fn test_create_inferred_fill_for_qty_reconciliation_flag() {
4230        let instrument = crypto_perpetual_ethusdt();
4231        let order = OrderTestBuilder::new(OrderType::Limit)
4232            .instrument_id(instrument.id())
4233            .side(OrderSide::Buy)
4234            .quantity(Quantity::from("10.0"))
4235            .price(Price::from("100.00"))
4236            .build();
4237
4238        let report = make_test_report(
4239            instrument.id(),
4240            OrderType::Limit,
4241            OrderStatus::Filled,
4242            "10.0",
4243            false,
4244        );
4245
4246        let result = create_inferred_fill_for_qty(
4247            &order,
4248            &report,
4249            &AccountId::from("TEST-001"),
4250            &InstrumentAny::CryptoPerpetual(instrument),
4251            Quantity::from("5.0"),
4252            UnixNanos::from(2_000_000),
4253        );
4254
4255        let filled = match result.unwrap() {
4256            OrderEventAny::Filled(f) => f,
4257            _ => panic!("Expected Filled event"),
4258        };
4259
4260        assert!(filled.reconciliation, "reconciliation flag should be true");
4261    }
4262}