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 ahash::AHashMap;
21use nautilus_common::enums::LogColor;
22use nautilus_core::{UUID4, UnixNanos};
23use nautilus_model::{
24    enums::{LiquiditySide, OrderSide, OrderStatus, OrderType, PositionSideSpecified, TimeInForce},
25    events::{
26        OrderAccepted, OrderCanceled, OrderEventAny, OrderExpired, OrderFilled, OrderRejected,
27        OrderTriggered, OrderUpdated,
28    },
29    identifiers::{AccountId, InstrumentId, TradeId, VenueOrderId},
30    instruments::{Instrument, InstrumentAny},
31    orders::{Order, OrderAny},
32    reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
33    types::{Money, Price, Quantity},
34};
35use rust_decimal::Decimal;
36use ustr::Ustr;
37
38/// Immutable snapshot of fill data for position simulation.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct FillSnapshot {
41    /// The event timestamp (nanoseconds).
42    pub ts_event: u64,
43    /// The order side (BUY or SELL).
44    pub side: OrderSide,
45    /// The fill quantity.
46    pub qty: Decimal,
47    /// The fill price.
48    pub px: Decimal,
49    /// The venue order ID.
50    pub venue_order_id: VenueOrderId,
51}
52
53/// Represents a position snapshot from the venue.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct VenuePositionSnapshot {
56    /// The position side (LONG, SHORT, or FLAT).
57    pub side: OrderSide, // Using OrderSide to represent position side for simplicity
58    /// The position quantity (always positive, even for SHORT).
59    pub qty: Decimal,
60    /// The average entry price (can be zero for FLAT positions).
61    pub avg_px: Decimal,
62}
63
64/// Result of the fill adjustment process.
65#[derive(Debug, Clone, PartialEq)]
66pub enum FillAdjustmentResult {
67    /// No adjustment needed - return fills unchanged.
68    NoAdjustment,
69    /// Add synthetic opening fill to oldest lifecycle.
70    AddSyntheticOpening {
71        /// The synthetic fill to add at the beginning.
72        synthetic_fill: FillSnapshot,
73        /// All existing fills to keep.
74        existing_fills: Vec<FillSnapshot>,
75    },
76    /// Replace entire current lifecycle with single synthetic fill.
77    ReplaceCurrentLifecycle {
78        /// The single synthetic fill representing the entire position.
79        synthetic_fill: FillSnapshot,
80        /// The first venue order ID to use.
81        first_venue_order_id: VenueOrderId,
82    },
83    /// Filter fills to current lifecycle only (after last zero-crossing).
84    FilterToCurrentLifecycle {
85        /// Timestamp of the last zero-crossing.
86        last_zero_crossing_ts: u64,
87        /// Fills from current lifecycle.
88        current_lifecycle_fills: Vec<FillSnapshot>,
89    },
90}
91
92impl FillSnapshot {
93    /// Create a new fill snapshot.
94    #[must_use]
95    pub fn new(
96        ts_event: u64,
97        side: OrderSide,
98        qty: Decimal,
99        px: Decimal,
100        venue_order_id: VenueOrderId,
101    ) -> Self {
102        Self {
103            ts_event,
104            side,
105            qty,
106            px,
107            venue_order_id,
108        }
109    }
110
111    /// Return signed direction multiplier: +1 for BUY, -1 for SELL.
112    #[must_use]
113    pub fn direction(&self) -> i8 {
114        match self.side {
115            OrderSide::Buy => 1,
116            OrderSide::Sell => -1,
117            _ => 0,
118        }
119    }
120}
121
122/// Simulate position from chronologically ordered fills using netting logic.
123///
124/// # Returns
125///
126/// Returns a tuple of (quantity, value) after applying all fills.
127#[must_use]
128pub fn simulate_position(fills: &[FillSnapshot]) -> (Decimal, Decimal) {
129    let mut qty = Decimal::ZERO;
130    let mut value = Decimal::ZERO;
131
132    for fill in fills {
133        let direction = Decimal::from(fill.direction());
134        let new_qty = qty + (direction * fill.qty);
135
136        // Check if we're accumulating or crossing zero (flip/close)
137        if (qty >= Decimal::ZERO && direction > Decimal::ZERO)
138            || (qty <= Decimal::ZERO && direction < Decimal::ZERO)
139        {
140            // Accumulating in same direction
141            value += fill.qty * fill.px;
142            qty = new_qty;
143        } else {
144            // Closing or flipping position
145            if qty.abs() >= fill.qty {
146                // Partial close - maintain average price by reducing value proportionally
147                let close_ratio = fill.qty / qty.abs();
148                value *= Decimal::ONE - close_ratio;
149                qty = new_qty;
150            } else {
151                // Close and flip - reset value to opening position
152                let remaining = fill.qty - qty.abs();
153                qty = direction * remaining;
154                value = remaining * fill.px;
155            }
156        }
157    }
158
159    (qty, value)
160}
161
162/// Detect zero-crossing timestamps in a sequence of fills.
163///
164/// A zero-crossing occurs when position quantity crosses through zero (FLAT).
165/// This includes both landing exactly on zero and flipping from long to short or vice versa.
166///
167/// # Returns
168///
169/// Returns a list of timestamps where position crosses through zero.
170#[must_use]
171pub fn detect_zero_crossings(fills: &[FillSnapshot]) -> Vec<u64> {
172    let mut running_qty = Decimal::ZERO;
173    let mut zero_crossings = Vec::new();
174
175    for fill in fills {
176        let prev_qty = running_qty;
177        running_qty += Decimal::from(fill.direction()) * fill.qty;
178
179        // Detect when position crosses zero
180        if prev_qty != Decimal::ZERO {
181            if running_qty == Decimal::ZERO {
182                // Landed exactly on zero
183                zero_crossings.push(fill.ts_event);
184            } else if (prev_qty > Decimal::ZERO) != (running_qty > Decimal::ZERO) {
185                // Sign changed - crossed through zero (flip)
186                zero_crossings.push(fill.ts_event);
187            }
188        }
189    }
190
191    zero_crossings
192}
193
194/// Check if simulated position matches venue position within tolerance.
195///
196/// # Returns
197///
198/// Returns true if quantities and average prices match within tolerance.
199#[must_use]
200pub fn check_position_match(
201    simulated_qty: Decimal,
202    simulated_value: Decimal,
203    venue_qty: Decimal,
204    venue_avg_px: Decimal,
205    tolerance: Decimal,
206) -> bool {
207    if simulated_qty != venue_qty {
208        return false;
209    }
210
211    if simulated_qty == Decimal::ZERO {
212        return true; // Both FLAT
213    }
214
215    // Guard against division by zero
216    let abs_qty = simulated_qty.abs();
217    if abs_qty == Decimal::ZERO {
218        return false;
219    }
220
221    let simulated_avg_px = simulated_value / abs_qty;
222
223    // If venue avg px is zero, we cannot calculate relative difference
224    if venue_avg_px == Decimal::ZERO {
225        return false;
226    }
227
228    let relative_diff = (simulated_avg_px - venue_avg_px).abs() / venue_avg_px;
229
230    relative_diff <= tolerance
231}
232
233/// Calculate the price needed for a reconciliation order to achieve target position.
234///
235/// This is a pure function that calculates what price a fill would need to have
236/// to move from the current position state to the target position state with the
237/// correct average price, accounting for the netting simulation logic.
238///
239/// # Returns
240///
241/// Returns `Some(Decimal)` if a valid reconciliation price can be calculated, `None` otherwise.
242///
243/// # Notes
244///
245/// The function handles four scenarios:
246/// 1. Position to flat: reconciliation_px = current_avg_px (close at current average)
247/// 2. Flat to position: reconciliation_px = target_avg_px
248/// 3. Position flip (sign change): reconciliation_px = target_avg_px (due to value reset in simulation)
249/// 4. Accumulation/reduction: weighted average formula
250pub fn calculate_reconciliation_price(
251    current_position_qty: Decimal,
252    current_position_avg_px: Option<Decimal>,
253    target_position_qty: Decimal,
254    target_position_avg_px: Option<Decimal>,
255) -> Option<Decimal> {
256    let qty_diff = target_position_qty - current_position_qty;
257
258    if qty_diff == Decimal::ZERO {
259        return None; // No reconciliation needed
260    }
261
262    // Special case: closing to flat (target_position_qty == 0)
263    // When flattening, the reconciliation price equals the current position's average price
264    if target_position_qty == Decimal::ZERO {
265        return current_position_avg_px;
266    }
267
268    // If target average price is not provided or zero, we cannot calculate
269    let target_avg_px = target_position_avg_px?;
270    if target_avg_px == Decimal::ZERO {
271        return None;
272    }
273
274    // If current position is flat, the reconciliation price equals target avg price
275    if current_position_qty == Decimal::ZERO || current_position_avg_px.is_none() {
276        return Some(target_avg_px);
277    }
278
279    let current_avg_px = current_position_avg_px?;
280
281    // Check if this is a flip scenario (sign change)
282    // In simulation, flips reset value to remaining * px, so reconciliation_px = target_avg_px
283    let is_flip = (current_position_qty > Decimal::ZERO) != (target_position_qty > Decimal::ZERO)
284        && target_position_qty != Decimal::ZERO;
285
286    if is_flip {
287        return Some(target_avg_px);
288    }
289
290    // For accumulation or reduction (same side), use weighted average formula
291    // Formula: (target_qty * target_avg_px) = (current_qty * current_avg_px) + (qty_diff * reconciliation_px)
292    let target_value = target_position_qty * target_avg_px;
293    let current_value = current_position_qty * current_avg_px;
294    let diff_value = target_value - current_value;
295
296    // qty_diff is guaranteed to be non-zero here due to early return at line 270
297    let reconciliation_px = diff_value / qty_diff;
298
299    // Ensure price is positive
300    if reconciliation_px > Decimal::ZERO {
301        return Some(reconciliation_px);
302    }
303
304    None
305}
306
307/// Adjust fills for partial reconciliation window to handle incomplete position lifecycles.
308///
309/// This function analyzes fills and determines if adjustments are needed when the reconciliation
310/// window doesn't capture the complete position history (missing opening fills).
311///
312/// # Returns
313///
314/// Returns `FillAdjustmentResult` indicating what adjustments (if any) are needed.
315///
316/// # Panics
317///
318/// This function does not panic under normal circumstances as all unwrap calls are guarded by prior checks.
319#[must_use]
320pub fn adjust_fills_for_partial_window(
321    fills: &[FillSnapshot],
322    venue_position: &VenuePositionSnapshot,
323    _instrument: &InstrumentAny,
324    tolerance: Decimal,
325) -> FillAdjustmentResult {
326    // If no fills, nothing to adjust
327    if fills.is_empty() {
328        return FillAdjustmentResult::NoAdjustment;
329    }
330
331    // If venue position is FLAT, return unchanged
332    if venue_position.qty == Decimal::ZERO {
333        return FillAdjustmentResult::NoAdjustment;
334    }
335
336    // Detect zero-crossings
337    let zero_crossings = detect_zero_crossings(fills);
338
339    // Convert venue position to signed quantity
340    let venue_qty_signed = match venue_position.side {
341        OrderSide::Buy => venue_position.qty,
342        OrderSide::Sell => -venue_position.qty,
343        _ => Decimal::ZERO,
344    };
345
346    // Case 1: Has zero-crossings - focus on current lifecycle after last zero-crossing
347    if !zero_crossings.is_empty() {
348        // Find the last zero-crossing that lands on FLAT (qty==0)
349        // This separates lifecycles; flips within a lifecycle don't count
350        let mut last_flat_crossing_ts = None;
351        let mut running_qty = Decimal::ZERO;
352
353        for fill in fills {
354            let prev_qty = running_qty;
355            running_qty += Decimal::from(fill.direction()) * fill.qty;
356
357            if prev_qty != Decimal::ZERO && running_qty == Decimal::ZERO {
358                last_flat_crossing_ts = Some(fill.ts_event);
359            }
360        }
361
362        let lifecycle_boundary_ts =
363            last_flat_crossing_ts.unwrap_or(*zero_crossings.last().unwrap());
364
365        // Get fills from current lifecycle (after lifecycle boundary)
366        let current_lifecycle_fills: Vec<FillSnapshot> = fills
367            .iter()
368            .filter(|f| f.ts_event > lifecycle_boundary_ts)
369            .cloned()
370            .collect();
371
372        if current_lifecycle_fills.is_empty() {
373            return FillAdjustmentResult::NoAdjustment;
374        }
375
376        // Simulate current lifecycle
377        let (current_qty, current_value) = simulate_position(&current_lifecycle_fills);
378
379        // Check if current lifecycle matches venue
380        if check_position_match(
381            current_qty,
382            current_value,
383            venue_qty_signed,
384            venue_position.avg_px,
385            tolerance,
386        ) {
387            // Current lifecycle matches - filter out old lifecycles
388            return FillAdjustmentResult::FilterToCurrentLifecycle {
389                last_zero_crossing_ts: lifecycle_boundary_ts,
390                current_lifecycle_fills,
391            };
392        }
393
394        // Current lifecycle doesn't match - replace with synthetic fill
395        if let Some(first_fill) = current_lifecycle_fills.first() {
396            let synthetic_fill = FillSnapshot::new(
397                first_fill.ts_event.saturating_sub(1), // Timestamp before first fill
398                venue_position.side,
399                venue_position.qty,
400                venue_position.avg_px,
401                first_fill.venue_order_id,
402            );
403
404            return FillAdjustmentResult::ReplaceCurrentLifecycle {
405                synthetic_fill,
406                first_venue_order_id: first_fill.venue_order_id,
407            };
408        }
409
410        return FillAdjustmentResult::NoAdjustment;
411    }
412
413    // Case 2: Single lifecycle or one zero-crossing
414    // Determine which fills to analyze
415    let oldest_lifecycle_fills: Vec<FillSnapshot> =
416        if let Some(&first_zero_crossing_ts) = zero_crossings.first() {
417            // Get fills before first zero-crossing
418            fills
419                .iter()
420                .filter(|f| f.ts_event <= first_zero_crossing_ts)
421                .cloned()
422                .collect()
423        } else {
424            // No zero-crossings - all fills are in single lifecycle
425            fills.to_vec()
426        };
427
428    if oldest_lifecycle_fills.is_empty() {
429        return FillAdjustmentResult::NoAdjustment;
430    }
431
432    // Simulate oldest lifecycle
433    let (oldest_qty, oldest_value) = simulate_position(&oldest_lifecycle_fills);
434
435    // If single lifecycle (no zero-crossings)
436    if zero_crossings.is_empty() {
437        // Check if simulated position matches venue
438        if check_position_match(
439            oldest_qty,
440            oldest_value,
441            venue_qty_signed,
442            venue_position.avg_px,
443            tolerance,
444        ) {
445            return FillAdjustmentResult::NoAdjustment;
446        }
447
448        // Doesn't match - need to add synthetic opening fill
449        if let Some(first_fill) = oldest_lifecycle_fills.first() {
450            // Calculate what opening fill is needed
451            // Use simulated position as current, venue position as target
452            let oldest_avg_px = if oldest_qty == Decimal::ZERO {
453                None
454            } else {
455                Some(oldest_value / oldest_qty.abs())
456            };
457
458            let reconciliation_price = calculate_reconciliation_price(
459                oldest_qty,
460                oldest_avg_px,
461                venue_qty_signed,
462                Some(venue_position.avg_px),
463            );
464
465            if let Some(opening_px) = reconciliation_price {
466                // Calculate opening quantity needed
467                let opening_qty = if oldest_qty == Decimal::ZERO {
468                    venue_qty_signed
469                } else {
470                    // Work backwards: venue = opening + current fills
471                    venue_qty_signed - oldest_qty
472                };
473
474                if opening_qty.abs() > Decimal::ZERO {
475                    let synthetic_side = if opening_qty > Decimal::ZERO {
476                        OrderSide::Buy
477                    } else {
478                        OrderSide::Sell
479                    };
480
481                    let synthetic_fill = FillSnapshot::new(
482                        first_fill.ts_event.saturating_sub(1),
483                        synthetic_side,
484                        opening_qty.abs(),
485                        opening_px,
486                        first_fill.venue_order_id,
487                    );
488
489                    return FillAdjustmentResult::AddSyntheticOpening {
490                        synthetic_fill,
491                        existing_fills: oldest_lifecycle_fills,
492                    };
493                }
494            }
495        }
496
497        return FillAdjustmentResult::NoAdjustment;
498    }
499
500    // Has one zero-crossing - check if oldest lifecycle closes at zero
501    if oldest_qty == Decimal::ZERO {
502        // Lifecycle closes correctly - no adjustment needed
503        return FillAdjustmentResult::NoAdjustment;
504    }
505
506    // Oldest lifecycle doesn't close at zero - add synthetic opening fill
507    if !oldest_lifecycle_fills.is_empty()
508        && let Some(&first_zero_crossing_ts) = zero_crossings.first()
509    {
510        // Need to add opening fill that makes position close at zero-crossing
511        let current_lifecycle_fills: Vec<FillSnapshot> = fills
512            .iter()
513            .filter(|f| f.ts_event > first_zero_crossing_ts)
514            .cloned()
515            .collect();
516
517        if !current_lifecycle_fills.is_empty()
518            && let Some(first_current_fill) = current_lifecycle_fills.first()
519        {
520            let synthetic_fill = FillSnapshot::new(
521                first_current_fill.ts_event.saturating_sub(1),
522                venue_position.side,
523                venue_position.qty,
524                venue_position.avg_px,
525                first_current_fill.venue_order_id,
526            );
527
528            return FillAdjustmentResult::AddSyntheticOpening {
529                synthetic_fill,
530                existing_fills: oldest_lifecycle_fills,
531            };
532        }
533    }
534
535    FillAdjustmentResult::NoAdjustment
536}
537
538/// Create a synthetic `VenueOrderId` using timestamp and UUID suffix.
539///
540/// Format: `S-{hex_timestamp}-{uuid_prefix}`
541#[must_use]
542pub fn create_synthetic_venue_order_id(ts_event: u64) -> VenueOrderId {
543    let uuid = UUID4::new();
544    let uuid_str = uuid.to_string();
545    let uuid_suffix = &uuid_str[..8];
546    let venue_order_id_value = format!("S-{ts_event:x}-{uuid_suffix}");
547    VenueOrderId::new(&venue_order_id_value)
548}
549
550/// Create a synthetic `TradeId` using timestamp and UUID suffix.
551///
552/// Format: `S-{hex_timestamp}-{uuid_prefix}`
553#[must_use]
554pub fn create_synthetic_trade_id(ts_event: u64) -> TradeId {
555    let uuid = UUID4::new();
556    let uuid_str = uuid.to_string();
557    let uuid_suffix = &uuid_str[..8];
558    let trade_id_value = format!("S-{ts_event:x}-{uuid_suffix}");
559    TradeId::new(&trade_id_value)
560}
561
562/// Create a synthetic `OrderStatusReport` from a `FillSnapshot`.
563///
564/// # Errors
565///
566/// Returns an error if the fill quantity cannot be converted to f64.
567pub fn create_synthetic_order_report(
568    fill: &FillSnapshot,
569    account_id: AccountId,
570    instrument_id: InstrumentId,
571    instrument: &InstrumentAny,
572    venue_order_id: VenueOrderId,
573) -> anyhow::Result<OrderStatusReport> {
574    let order_qty = Quantity::from_decimal_dp(fill.qty, instrument.size_precision())?;
575
576    Ok(OrderStatusReport::new(
577        account_id,
578        instrument_id,
579        None, // client_order_id
580        venue_order_id,
581        fill.side,
582        OrderType::Market,
583        TimeInForce::Gtc,
584        OrderStatus::Filled,
585        order_qty,
586        order_qty, // filled_qty = order_qty (fully filled)
587        UnixNanos::from(fill.ts_event),
588        UnixNanos::from(fill.ts_event),
589        UnixNanos::from(fill.ts_event),
590        None, // report_id
591    ))
592}
593
594/// Create a synthetic `FillReport` from a `FillSnapshot`.
595///
596/// # Errors
597///
598/// Returns an error if the fill quantity or price cannot be converted.
599pub fn create_synthetic_fill_report(
600    fill: &FillSnapshot,
601    account_id: AccountId,
602    instrument_id: InstrumentId,
603    instrument: &InstrumentAny,
604    venue_order_id: VenueOrderId,
605) -> anyhow::Result<FillReport> {
606    let trade_id = create_synthetic_trade_id(fill.ts_event);
607    let qty = Quantity::from_decimal_dp(fill.qty, instrument.size_precision())?;
608    let px = Price::from_decimal_dp(fill.px, instrument.price_precision())?;
609
610    Ok(FillReport::new(
611        account_id,
612        instrument_id,
613        venue_order_id,
614        trade_id,
615        fill.side,
616        qty,
617        px,
618        Money::new(0.0, instrument.quote_currency()),
619        LiquiditySide::NoLiquiditySide,
620        None, // client_order_id
621        None, // venue_position_id
622        fill.ts_event.into(),
623        fill.ts_event.into(),
624        None, // report_id
625    ))
626}
627
628/// Result of processing fill reports for reconciliation.
629#[derive(Debug, Clone)]
630pub struct ReconciliationResult {
631    /// Order status reports keyed by venue order ID.
632    pub orders: AHashMap<VenueOrderId, OrderStatusReport>,
633    /// Fill reports keyed by venue order ID.
634    pub fills: AHashMap<VenueOrderId, Vec<FillReport>>,
635}
636
637const DEFAULT_TOLERANCE: Decimal = Decimal::from_parts(1, 0, 0, false, 4); // 0.0001
638
639/// Process fill reports from a mass status for position reconciliation.
640///
641/// This is the main entry point for position reconciliation. It:
642/// 1. Extracts fills and position for the given instrument
643/// 2. Detects position discrepancies
644/// 3. Returns adjusted order/fill reports ready for processing
645///
646/// # Errors
647///
648/// Returns an error if synthetic report creation fails.
649pub fn process_mass_status_for_reconciliation(
650    mass_status: &ExecutionMassStatus,
651    instrument: &InstrumentAny,
652    tolerance: Option<Decimal>,
653) -> anyhow::Result<ReconciliationResult> {
654    let instrument_id = instrument.id();
655    let account_id = mass_status.account_id;
656    let tol = tolerance.unwrap_or(DEFAULT_TOLERANCE);
657
658    // Get position report for this instrument
659    let position_reports = mass_status.position_reports();
660    let venue_position = match position_reports.get(&instrument_id).and_then(|r| r.first()) {
661        Some(report) => position_report_to_snapshot(report),
662        None => {
663            // No position report - return orders/fills unchanged
664            return Ok(extract_instrument_reports(mass_status, instrument_id));
665        }
666    };
667
668    // Extract and convert fills to snapshots
669    let extracted = extract_fills_for_instrument(mass_status, instrument_id);
670    let fill_snapshots = extracted.snapshots;
671    let mut order_map = extracted.orders;
672    let mut fill_map = extracted.fills;
673
674    if fill_snapshots.is_empty() {
675        return Ok(ReconciliationResult {
676            orders: order_map,
677            fills: fill_map,
678        });
679    }
680
681    // Run adjustment logic
682    let result = adjust_fills_for_partial_window(&fill_snapshots, &venue_position, instrument, tol);
683
684    // Apply adjustments
685    match result {
686        FillAdjustmentResult::NoAdjustment => {}
687
688        FillAdjustmentResult::AddSyntheticOpening {
689            synthetic_fill,
690            existing_fills: _,
691        } => {
692            let venue_order_id = create_synthetic_venue_order_id(synthetic_fill.ts_event);
693            let order = create_synthetic_order_report(
694                &synthetic_fill,
695                account_id,
696                instrument_id,
697                instrument,
698                venue_order_id,
699            )?;
700            let fill = create_synthetic_fill_report(
701                &synthetic_fill,
702                account_id,
703                instrument_id,
704                instrument,
705                venue_order_id,
706            )?;
707
708            order_map.insert(venue_order_id, order);
709            fill_map.entry(venue_order_id).or_default().insert(0, fill);
710        }
711
712        FillAdjustmentResult::ReplaceCurrentLifecycle {
713            synthetic_fill,
714            first_venue_order_id,
715        } => {
716            let order = create_synthetic_order_report(
717                &synthetic_fill,
718                account_id,
719                instrument_id,
720                instrument,
721                first_venue_order_id,
722            )?;
723            let fill = create_synthetic_fill_report(
724                &synthetic_fill,
725                account_id,
726                instrument_id,
727                instrument,
728                first_venue_order_id,
729            )?;
730
731            // Replace with only synthetic
732            order_map.clear();
733            fill_map.clear();
734            order_map.insert(first_venue_order_id, order);
735            fill_map.insert(first_venue_order_id, vec![fill]);
736        }
737
738        FillAdjustmentResult::FilterToCurrentLifecycle {
739            last_zero_crossing_ts,
740            current_lifecycle_fills: _,
741        } => {
742            // Filter fills to current lifecycle
743            for fills in fill_map.values_mut() {
744                fills.retain(|f| f.ts_event.as_u64() > last_zero_crossing_ts);
745            }
746            fill_map.retain(|_, fills| !fills.is_empty());
747
748            // Keep only orders that have fills or are still working
749            let orders_with_fills: ahash::AHashSet<VenueOrderId> =
750                fill_map.keys().copied().collect();
751            order_map.retain(|id, order| {
752                orders_with_fills.contains(id)
753                    || !matches!(
754                        order.order_status,
755                        OrderStatus::Denied
756                            | OrderStatus::Rejected
757                            | OrderStatus::Canceled
758                            | OrderStatus::Expired
759                            | OrderStatus::Filled
760                    )
761            });
762        }
763    }
764
765    Ok(ReconciliationResult {
766        orders: order_map,
767        fills: fill_map,
768    })
769}
770
771/// Convert a position status report to a venue position snapshot.
772fn position_report_to_snapshot(report: &PositionStatusReport) -> VenuePositionSnapshot {
773    let side = match report.position_side {
774        PositionSideSpecified::Long => OrderSide::Buy,
775        PositionSideSpecified::Short => OrderSide::Sell,
776        PositionSideSpecified::Flat => OrderSide::Buy,
777    };
778
779    VenuePositionSnapshot {
780        side,
781        qty: report.quantity.into(),
782        avg_px: report.avg_px_open.unwrap_or(Decimal::ZERO),
783    }
784}
785
786/// Extract orders and fills for a specific instrument from mass status.
787fn extract_instrument_reports(
788    mass_status: &ExecutionMassStatus,
789    instrument_id: InstrumentId,
790) -> ReconciliationResult {
791    let mut orders = AHashMap::new();
792    let mut fills = AHashMap::new();
793
794    for (id, order) in mass_status.order_reports() {
795        if order.instrument_id == instrument_id {
796            orders.insert(id, order.clone());
797        }
798    }
799
800    for (id, fill_list) in mass_status.fill_reports() {
801        let filtered: Vec<_> = fill_list
802            .iter()
803            .filter(|f| f.instrument_id == instrument_id)
804            .cloned()
805            .collect();
806        if !filtered.is_empty() {
807            fills.insert(id, filtered);
808        }
809    }
810
811    ReconciliationResult { orders, fills }
812}
813
814/// Extracted fills and reports for an instrument.
815struct ExtractedFills {
816    snapshots: Vec<FillSnapshot>,
817    orders: AHashMap<VenueOrderId, OrderStatusReport>,
818    fills: AHashMap<VenueOrderId, Vec<FillReport>>,
819}
820
821/// Extract fills for an instrument and convert to snapshots.
822fn extract_fills_for_instrument(
823    mass_status: &ExecutionMassStatus,
824    instrument_id: InstrumentId,
825) -> ExtractedFills {
826    let mut snapshots = Vec::new();
827    let mut order_map = AHashMap::new();
828    let mut fill_map = AHashMap::new();
829
830    // Seed order_map
831    for (id, order) in mass_status.order_reports() {
832        if order.instrument_id == instrument_id {
833            order_map.insert(id, order.clone());
834        }
835    }
836
837    // Extract fills
838    for (venue_order_id, fill_reports) in mass_status.fill_reports() {
839        for fill in fill_reports {
840            if fill.instrument_id == instrument_id {
841                let side = mass_status
842                    .order_reports()
843                    .get(&venue_order_id)
844                    .map_or(fill.order_side, |o| o.order_side);
845
846                snapshots.push(FillSnapshot::new(
847                    fill.ts_event.as_u64(),
848                    side,
849                    fill.last_qty.into(),
850                    fill.last_px.into(),
851                    venue_order_id,
852                ));
853
854                fill_map
855                    .entry(venue_order_id)
856                    .or_insert_with(Vec::new)
857                    .push(fill.clone());
858            }
859        }
860    }
861
862    // Sort chronologically
863    snapshots.sort_by_key(|f| f.ts_event);
864
865    ExtractedFills {
866        snapshots,
867        orders: order_map,
868        fills: fill_map,
869    }
870}
871
872/// Generates the appropriate order events for an external order and order status report.
873///
874/// After creating an external order, we need to transition it to its actual state
875/// based on the order status report from the venue. For terminal states like
876/// Canceled/Expired/Filled, we return multiple events to properly transition
877/// through states.
878#[must_use]
879pub fn generate_external_order_status_events(
880    order: &OrderAny,
881    report: &OrderStatusReport,
882    account_id: &AccountId,
883    instrument: &InstrumentAny,
884    ts_now: UnixNanos,
885) -> Vec<OrderEventAny> {
886    let accepted = OrderEventAny::Accepted(OrderAccepted::new(
887        order.trader_id(),
888        order.strategy_id(),
889        order.instrument_id(),
890        order.client_order_id(),
891        report.venue_order_id,
892        *account_id,
893        UUID4::new(),
894        report.ts_accepted,
895        ts_now,
896        true, // reconciliation
897    ));
898
899    match report.order_status {
900        OrderStatus::Accepted | OrderStatus::Triggered => vec![accepted],
901        OrderStatus::PartiallyFilled | OrderStatus::Filled => {
902            let mut events = vec![accepted];
903
904            if !report.filled_qty.is_zero()
905                && let Some(filled) =
906                    create_inferred_fill(order, report, account_id, instrument, ts_now)
907            {
908                events.push(filled);
909            }
910
911            events
912        }
913        OrderStatus::Canceled => {
914            let canceled = OrderEventAny::Canceled(OrderCanceled::new(
915                order.trader_id(),
916                order.strategy_id(),
917                order.instrument_id(),
918                order.client_order_id(),
919                UUID4::new(),
920                report.ts_last,
921                ts_now,
922                true, // reconciliation
923                Some(report.venue_order_id),
924                Some(*account_id),
925            ));
926            vec![accepted, canceled]
927        }
928        OrderStatus::Expired => {
929            let expired = OrderEventAny::Expired(OrderExpired::new(
930                order.trader_id(),
931                order.strategy_id(),
932                order.instrument_id(),
933                order.client_order_id(),
934                UUID4::new(),
935                report.ts_last,
936                ts_now,
937                true, // reconciliation
938                Some(report.venue_order_id),
939                Some(*account_id),
940            ));
941            vec![accepted, expired]
942        }
943        OrderStatus::Rejected => {
944            // Rejected goes directly to terminal state without acceptance
945            vec![OrderEventAny::Rejected(OrderRejected::new(
946                order.trader_id(),
947                order.strategy_id(),
948                order.instrument_id(),
949                order.client_order_id(),
950                *account_id,
951                Ustr::from(report.cancel_reason.as_deref().unwrap_or("UNKNOWN")),
952                UUID4::new(),
953                report.ts_last,
954                ts_now,
955                true, // reconciliation
956                false,
957            ))]
958        }
959        _ => {
960            log::warn!(
961                "Unhandled order status {} for external order {}",
962                report.order_status,
963                order.client_order_id()
964            );
965            Vec::new()
966        }
967    }
968}
969
970/// Creates an inferred fill event for reconciliation when fill reports are missing.
971#[must_use]
972pub fn create_inferred_fill(
973    order: &OrderAny,
974    report: &OrderStatusReport,
975    account_id: &AccountId,
976    instrument: &InstrumentAny,
977    ts_now: UnixNanos,
978) -> Option<OrderEventAny> {
979    let liquidity_side = match order.order_type() {
980        OrderType::Market | OrderType::StopMarket | OrderType::TrailingStopMarket => {
981            LiquiditySide::Taker
982        }
983        _ if report.post_only => LiquiditySide::Maker,
984        _ => LiquiditySide::NoLiquiditySide,
985    };
986
987    let last_px = if let Some(avg_px) = report.avg_px {
988        match Price::from_decimal_dp(avg_px, instrument.price_precision()) {
989            Ok(px) => px,
990            Err(e) => {
991                log::warn!("Failed to create price from avg_px for inferred fill: {e}");
992                return None;
993            }
994        }
995    } else if let Some(price) = report.price {
996        price
997    } else {
998        log::warn!(
999            "Cannot create inferred fill for {}: no avg_px or price available",
1000            order.client_order_id()
1001        );
1002        return None;
1003    };
1004
1005    let trade_id = TradeId::from(UUID4::new().as_str());
1006
1007    log::info!(
1008        "Generated inferred fill for {} ({}) qty={} px={}",
1009        order.client_order_id(),
1010        report.venue_order_id,
1011        report.filled_qty,
1012        last_px,
1013    );
1014
1015    Some(OrderEventAny::Filled(OrderFilled::new(
1016        order.trader_id(),
1017        order.strategy_id(),
1018        order.instrument_id(),
1019        order.client_order_id(),
1020        report.venue_order_id,
1021        *account_id,
1022        trade_id,
1023        report.order_side,
1024        order.order_type(),
1025        report.filled_qty,
1026        last_px,
1027        instrument.quote_currency(),
1028        liquidity_side,
1029        UUID4::new(),
1030        report.ts_last,
1031        ts_now,
1032        true, // reconciliation
1033        report.venue_position_id,
1034        None, // commission - not available for inferred fills
1035    )))
1036}
1037
1038/// Creates an OrderAccepted event for reconciliation.
1039///
1040/// # Panics
1041///
1042/// Panics if the order does not have an `account_id` set.
1043#[must_use]
1044pub fn create_reconciliation_accepted(
1045    order: &OrderAny,
1046    report: &OrderStatusReport,
1047    ts_now: UnixNanos,
1048) -> OrderEventAny {
1049    OrderEventAny::Accepted(OrderAccepted::new(
1050        order.trader_id(),
1051        order.strategy_id(),
1052        order.instrument_id(),
1053        order.client_order_id(),
1054        order.venue_order_id().unwrap_or(report.venue_order_id),
1055        order
1056            .account_id()
1057            .expect("Order should have account_id for reconciliation"),
1058        UUID4::new(),
1059        report.ts_accepted,
1060        ts_now,
1061        true, // reconciliation
1062    ))
1063}
1064
1065/// Creates an OrderRejected event for reconciliation.
1066#[must_use]
1067pub fn create_reconciliation_rejected(
1068    order: &OrderAny,
1069    reason: Option<&str>,
1070    ts_now: UnixNanos,
1071) -> Option<OrderEventAny> {
1072    let account_id = order.account_id()?;
1073    let reason = reason.unwrap_or("UNKNOWN");
1074
1075    Some(OrderEventAny::Rejected(OrderRejected::new(
1076        order.trader_id(),
1077        order.strategy_id(),
1078        order.instrument_id(),
1079        order.client_order_id(),
1080        account_id,
1081        Ustr::from(reason),
1082        UUID4::new(),
1083        ts_now,
1084        ts_now,
1085        true,  // reconciliation
1086        false, // due_post_only
1087    )))
1088}
1089
1090/// Creates an OrderTriggered event for reconciliation.
1091#[must_use]
1092pub fn create_reconciliation_triggered(
1093    order: &OrderAny,
1094    report: &OrderStatusReport,
1095    ts_now: UnixNanos,
1096) -> OrderEventAny {
1097    OrderEventAny::Triggered(OrderTriggered::new(
1098        order.trader_id(),
1099        order.strategy_id(),
1100        order.instrument_id(),
1101        order.client_order_id(),
1102        UUID4::new(),
1103        report.ts_triggered.unwrap_or(ts_now),
1104        ts_now,
1105        true, // reconciliation
1106        order.venue_order_id(),
1107        order.account_id(),
1108    ))
1109}
1110
1111/// Creates an OrderCanceled event for reconciliation.
1112#[must_use]
1113pub fn create_reconciliation_canceled(
1114    order: &OrderAny,
1115    report: &OrderStatusReport,
1116    ts_now: UnixNanos,
1117) -> OrderEventAny {
1118    OrderEventAny::Canceled(OrderCanceled::new(
1119        order.trader_id(),
1120        order.strategy_id(),
1121        order.instrument_id(),
1122        order.client_order_id(),
1123        UUID4::new(),
1124        report.ts_last,
1125        ts_now,
1126        true, // reconciliation
1127        order.venue_order_id(),
1128        order.account_id(),
1129    ))
1130}
1131
1132/// Creates an OrderExpired event for reconciliation.
1133#[must_use]
1134pub fn create_reconciliation_expired(
1135    order: &OrderAny,
1136    report: &OrderStatusReport,
1137    ts_now: UnixNanos,
1138) -> OrderEventAny {
1139    OrderEventAny::Expired(OrderExpired::new(
1140        order.trader_id(),
1141        order.strategy_id(),
1142        order.instrument_id(),
1143        order.client_order_id(),
1144        UUID4::new(),
1145        report.ts_last,
1146        ts_now,
1147        true, // reconciliation
1148        order.venue_order_id(),
1149        order.account_id(),
1150    ))
1151}
1152
1153/// Creates an OrderUpdated event for reconciliation.
1154#[must_use]
1155pub fn create_reconciliation_updated(
1156    order: &OrderAny,
1157    report: &OrderStatusReport,
1158    ts_now: UnixNanos,
1159) -> OrderEventAny {
1160    OrderEventAny::Updated(OrderUpdated::new(
1161        order.trader_id(),
1162        order.strategy_id(),
1163        order.instrument_id(),
1164        order.client_order_id(),
1165        report.quantity,
1166        UUID4::new(),
1167        report.ts_last,
1168        ts_now,
1169        true, // reconciliation
1170        order.venue_order_id(),
1171        order.account_id(),
1172        report.price,
1173        report.trigger_price,
1174        None, // protection_price
1175    ))
1176}
1177
1178/// Checks if the order should be updated based on quantity, price, or trigger price
1179/// differences from the venue report.
1180#[must_use]
1181pub fn should_reconciliation_update(order: &OrderAny, report: &OrderStatusReport) -> bool {
1182    // Quantity change only valid if new qty >= filled qty
1183    if report.quantity != order.quantity() && report.quantity >= order.filled_qty() {
1184        return true;
1185    }
1186
1187    match order.order_type() {
1188        OrderType::Limit => report.price != order.price(),
1189        OrderType::StopMarket | OrderType::TrailingStopMarket => {
1190            report.trigger_price != order.trigger_price()
1191        }
1192        OrderType::StopLimit | OrderType::TrailingStopLimit => {
1193            report.trigger_price != order.trigger_price() || report.price != order.price()
1194        }
1195        _ => false,
1196    }
1197}
1198
1199/// Reconciles an order with a venue status report, generating appropriate events.
1200///
1201/// This is the core reconciliation logic that handles all order status transitions.
1202/// For fill reconciliation with inferred fills, use `reconcile_order_with_fills`.
1203#[must_use]
1204pub fn reconcile_order_report(
1205    order: &OrderAny,
1206    report: &OrderStatusReport,
1207    instrument: Option<&InstrumentAny>,
1208    ts_now: UnixNanos,
1209) -> Option<OrderEventAny> {
1210    if order.status() == report.order_status && order.filled_qty() == report.filled_qty {
1211        if should_reconciliation_update(order, report) {
1212            log::info!(
1213                "Order {} has been updated at venue: qty={}->{}, price={:?}->{:?}",
1214                order.client_order_id(),
1215                order.quantity(),
1216                report.quantity,
1217                order.price(),
1218                report.price
1219            );
1220            return Some(create_reconciliation_updated(order, report, ts_now));
1221        }
1222        return None; // Already in sync
1223    }
1224
1225    match report.order_status {
1226        OrderStatus::Accepted => {
1227            if order.status() == OrderStatus::Accepted
1228                && should_reconciliation_update(order, report)
1229            {
1230                return Some(create_reconciliation_updated(order, report, ts_now));
1231            }
1232            Some(create_reconciliation_accepted(order, report, ts_now))
1233        }
1234        OrderStatus::Rejected => {
1235            create_reconciliation_rejected(order, report.cancel_reason.as_deref(), ts_now)
1236        }
1237        OrderStatus::Triggered => Some(create_reconciliation_triggered(order, report, ts_now)),
1238        OrderStatus::Canceled => Some(create_reconciliation_canceled(order, report, ts_now)),
1239        OrderStatus::Expired => Some(create_reconciliation_expired(order, report, ts_now)),
1240
1241        OrderStatus::PartiallyFilled | OrderStatus::Filled => {
1242            reconcile_fill_quantity_mismatch(order, report, instrument, ts_now)
1243        }
1244
1245        // Pending states - venue will confirm, just log
1246        OrderStatus::PendingUpdate | OrderStatus::PendingCancel => {
1247            log::debug!(
1248                "Order {} in pending state: {:?}",
1249                order.client_order_id(),
1250                report.order_status
1251            );
1252            None
1253        }
1254
1255        // Internal states - should not appear in venue reports
1256        OrderStatus::Initialized
1257        | OrderStatus::Submitted
1258        | OrderStatus::Denied
1259        | OrderStatus::Emulated
1260        | OrderStatus::Released => {
1261            log::warn!(
1262                "Unexpected order status in venue report for {}: {:?}",
1263                order.client_order_id(),
1264                report.order_status
1265            );
1266            None
1267        }
1268    }
1269}
1270
1271/// Handles fill quantity mismatch between cached order and venue report.
1272///
1273/// Returns an inferred fill event if the venue reports more filled quantity than we have.
1274fn reconcile_fill_quantity_mismatch(
1275    order: &OrderAny,
1276    report: &OrderStatusReport,
1277    instrument: Option<&InstrumentAny>,
1278    ts_now: UnixNanos,
1279) -> Option<OrderEventAny> {
1280    let order_filled_qty = order.filled_qty();
1281    let report_filled_qty = report.filled_qty;
1282
1283    if report_filled_qty < order_filled_qty {
1284        // Venue reports less filled than we have - potential state corruption
1285        log::error!(
1286            "Fill qty mismatch for {}: cached={}, venue={} (venue < cached)",
1287            order.client_order_id(),
1288            order_filled_qty,
1289            report_filled_qty
1290        );
1291        return None;
1292    }
1293
1294    if report_filled_qty > order_filled_qty {
1295        // Venue has more fills - generate inferred fill for the difference
1296        let Some(instrument) = instrument else {
1297            log::warn!(
1298                "Cannot generate inferred fill for {}: instrument not available",
1299                order.client_order_id()
1300            );
1301            return None;
1302        };
1303
1304        let account_id = order.account_id()?;
1305        return create_incremental_inferred_fill(order, report, &account_id, instrument, ts_now);
1306    }
1307
1308    // Quantities match but status differs - potential state inconsistency
1309    if order.status() != report.order_status {
1310        log::warn!(
1311            "Status mismatch with matching fill qty for {}: local={:?}, venue={:?}, filled_qty={}",
1312            order.client_order_id(),
1313            order.status(),
1314            report.order_status,
1315            report.filled_qty
1316        );
1317    }
1318
1319    None
1320}
1321
1322/// Creates an inferred fill for the quantity difference between order and report.
1323///
1324/// This handles incremental fills where the order already has some filled quantity.
1325fn create_incremental_inferred_fill(
1326    order: &OrderAny,
1327    report: &OrderStatusReport,
1328    account_id: &AccountId,
1329    instrument: &InstrumentAny,
1330    ts_now: UnixNanos,
1331) -> Option<OrderEventAny> {
1332    let order_filled_qty = order.filled_qty();
1333    let last_qty = report.filled_qty - order_filled_qty;
1334
1335    if last_qty <= Quantity::zero(instrument.size_precision()) {
1336        return None;
1337    }
1338
1339    let liquidity_side = match order.order_type() {
1340        OrderType::Market | OrderType::StopMarket | OrderType::MarketToLimit => {
1341            LiquiditySide::Taker
1342        }
1343        _ if order.is_post_only() => LiquiditySide::Maker,
1344        _ => LiquiditySide::NoLiquiditySide,
1345    };
1346
1347    let last_px = calculate_incremental_fill_price(order, report, instrument)?;
1348
1349    let trade_id = TradeId::new(format!(
1350        "INFERRED-{}-{}",
1351        order.client_order_id(),
1352        ts_now.as_u64()
1353    ));
1354
1355    let venue_order_id = order.venue_order_id().unwrap_or(report.venue_order_id);
1356
1357    log::info!(
1358        color = LogColor::Blue as u8;
1359        "Generated inferred fill for {}: qty={}, px={}",
1360        order.client_order_id(),
1361        last_qty,
1362        last_px,
1363    );
1364
1365    Some(OrderEventAny::Filled(OrderFilled::new(
1366        order.trader_id(),
1367        order.strategy_id(),
1368        order.instrument_id(),
1369        order.client_order_id(),
1370        venue_order_id,
1371        *account_id,
1372        trade_id,
1373        order.order_side(),
1374        order.order_type(),
1375        last_qty,
1376        last_px,
1377        instrument.quote_currency(),
1378        liquidity_side,
1379        UUID4::new(),
1380        report.ts_last,
1381        ts_now,
1382        true, // reconciliation
1383        None, // venue_position_id
1384        None, // commission - unknown for inferred fills
1385    )))
1386}
1387
1388/// Calculates the fill price for an incremental inferred fill.
1389fn calculate_incremental_fill_price(
1390    order: &OrderAny,
1391    report: &OrderStatusReport,
1392    instrument: &InstrumentAny,
1393) -> Option<Price> {
1394    let order_filled_qty = order.filled_qty();
1395
1396    // First fill - use avg_px from report or order price
1397    if order_filled_qty.is_zero() {
1398        if let Some(avg_px) = report.avg_px {
1399            return Price::from_decimal_dp(avg_px, instrument.price_precision()).ok();
1400        }
1401        if let Some(price) = report.price {
1402            return Some(price);
1403        }
1404        if let Some(price) = order.price() {
1405            return Some(price);
1406        }
1407        log::warn!(
1408            "Cannot determine fill price for {}: no avg_px, report price, or order price",
1409            order.client_order_id()
1410        );
1411        return None;
1412    }
1413
1414    // Incremental fill - calculate price using weighted average
1415    if let Some(report_avg_px) = report.avg_px {
1416        let Some(order_avg_px) = order.avg_px() else {
1417            // No previous avg_px, use report avg_px
1418            return Price::from_decimal_dp(report_avg_px, instrument.price_precision()).ok();
1419        };
1420        let report_filled_qty = report.filled_qty;
1421        let last_qty = report_filled_qty - order_filled_qty;
1422
1423        let report_notional = report_avg_px * report_filled_qty.as_decimal();
1424        let order_notional = Decimal::from_f64_retain(order_avg_px).unwrap_or_default()
1425            * order_filled_qty.as_decimal();
1426        let last_notional = report_notional - order_notional;
1427        let last_px_decimal = last_notional / last_qty.as_decimal();
1428
1429        return Price::from_decimal_dp(last_px_decimal, instrument.price_precision()).ok();
1430    }
1431
1432    // Fallback to report price or order price
1433    if let Some(price) = report.price {
1434        return Some(price);
1435    }
1436
1437    order.price()
1438}
1439
1440#[cfg(test)]
1441mod tests {
1442    use nautilus_model::{
1443        instruments::stubs::{audusd_sim, crypto_perpetual_ethusdt},
1444        orders::OrderTestBuilder,
1445        reports::OrderStatusReport,
1446    };
1447    use rstest::{fixture, rstest};
1448    use rust_decimal_macros::dec;
1449
1450    use super::*;
1451
1452    #[fixture]
1453    fn instrument() -> InstrumentAny {
1454        InstrumentAny::CurrencyPair(audusd_sim())
1455    }
1456
1457    fn create_test_venue_order_id(value: &str) -> VenueOrderId {
1458        VenueOrderId::new(value)
1459    }
1460
1461    #[rstest]
1462    fn test_fill_snapshot_direction() {
1463        let venue_order_id = create_test_venue_order_id("ORDER1");
1464        let buy_fill = FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id);
1465        assert_eq!(buy_fill.direction(), 1);
1466
1467        let sell_fill =
1468            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id);
1469        assert_eq!(sell_fill.direction(), -1);
1470    }
1471
1472    #[rstest]
1473    fn test_simulate_position_accumulate_long() {
1474        let venue_order_id = create_test_venue_order_id("ORDER1");
1475        let fills = vec![
1476            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1477            FillSnapshot::new(2000, OrderSide::Buy, dec!(5), dec!(102), venue_order_id),
1478        ];
1479
1480        let (qty, value) = simulate_position(&fills);
1481        assert_eq!(qty, dec!(15));
1482        assert_eq!(value, dec!(1510)); // 10*100 + 5*102
1483    }
1484
1485    #[rstest]
1486    fn test_simulate_position_close_and_flip() {
1487        let venue_order_id = create_test_venue_order_id("ORDER1");
1488        let fills = vec![
1489            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1490            FillSnapshot::new(2000, OrderSide::Sell, dec!(15), dec!(102), venue_order_id),
1491        ];
1492
1493        let (qty, value) = simulate_position(&fills);
1494        assert_eq!(qty, dec!(-5)); // Flipped from +10 to -5
1495        assert_eq!(value, dec!(510)); // Remaining 5 @ 102
1496    }
1497
1498    #[rstest]
1499    fn test_simulate_position_partial_close() {
1500        let venue_order_id = create_test_venue_order_id("ORDER1");
1501        let fills = vec![
1502            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1503            FillSnapshot::new(2000, OrderSide::Sell, dec!(5), dec!(102), venue_order_id),
1504        ];
1505
1506        let (qty, value) = simulate_position(&fills);
1507        assert_eq!(qty, dec!(5));
1508        assert_eq!(value, dec!(500)); // Reduced proportionally: 1000 * (1 - 5/10) = 500
1509
1510        // Verify average price is maintained
1511        let avg_px = value / qty;
1512        assert_eq!(avg_px, dec!(100));
1513    }
1514
1515    #[rstest]
1516    fn test_simulate_position_multiple_partial_closes() {
1517        let venue_order_id = create_test_venue_order_id("ORDER1");
1518        let fills = vec![
1519            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(10.0), venue_order_id),
1520            FillSnapshot::new(2000, OrderSide::Sell, dec!(25), dec!(11.0), venue_order_id), // Close 25%
1521            FillSnapshot::new(3000, OrderSide::Sell, dec!(25), dec!(12.0), venue_order_id), // Close another 25%
1522        ];
1523
1524        let (qty, value) = simulate_position(&fills);
1525        assert_eq!(qty, dec!(50));
1526        // After first close: value = 1000 * (1 - 25/100) = 1000 * 0.75 = 750
1527        // After second close: value = 750 * (1 - 25/75) = 750 * (50/75) = 500
1528        // Due to decimal precision, we check it's close to 500
1529        assert!((value - dec!(500)).abs() < dec!(0.01));
1530
1531        // Verify average price is maintained at 10.0
1532        let avg_px = value / qty;
1533        assert!((avg_px - dec!(10.0)).abs() < dec!(0.01));
1534    }
1535
1536    #[rstest]
1537    fn test_simulate_position_short_partial_close() {
1538        let venue_order_id = create_test_venue_order_id("ORDER1");
1539        let fills = vec![
1540            FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
1541            FillSnapshot::new(2000, OrderSide::Buy, dec!(5), dec!(98), venue_order_id), // Partial close
1542        ];
1543
1544        let (qty, value) = simulate_position(&fills);
1545        assert_eq!(qty, dec!(-5));
1546        assert_eq!(value, dec!(500)); // Reduced proportionally: 1000 * (1 - 5/10) = 500
1547
1548        // Verify average price is maintained
1549        let avg_px = value / qty.abs();
1550        assert_eq!(avg_px, dec!(100));
1551    }
1552
1553    #[rstest]
1554    fn test_detect_zero_crossings() {
1555        let venue_order_id = create_test_venue_order_id("ORDER1");
1556        let fills = vec![
1557            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1558            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), venue_order_id), // Close to zero
1559            FillSnapshot::new(3000, OrderSide::Buy, dec!(5), dec!(103), venue_order_id),
1560            FillSnapshot::new(4000, OrderSide::Sell, dec!(5), dec!(104), venue_order_id), // Close to zero again
1561        ];
1562
1563        let crossings = detect_zero_crossings(&fills);
1564        assert_eq!(crossings.len(), 2);
1565        assert_eq!(crossings[0], 2000);
1566        assert_eq!(crossings[1], 4000);
1567    }
1568
1569    #[rstest]
1570    fn test_check_position_match_exact() {
1571        let result = check_position_match(dec!(10), dec!(1000), dec!(10), dec!(100), dec!(0.0001));
1572        assert!(result);
1573    }
1574
1575    #[rstest]
1576    fn test_check_position_match_within_tolerance() {
1577        // Simulated avg px = 1000/10 = 100, venue = 100.005
1578        // Relative diff = 0.005 / 100.005 = 0.00004999 < 0.0001
1579        let result =
1580            check_position_match(dec!(10), dec!(1000), dec!(10), dec!(100.005), dec!(0.0001));
1581        assert!(result);
1582    }
1583
1584    #[rstest]
1585    fn test_check_position_match_qty_mismatch() {
1586        let result = check_position_match(dec!(10), dec!(1000), dec!(11), dec!(100), dec!(0.0001));
1587        assert!(!result);
1588    }
1589
1590    #[rstest]
1591    fn test_check_position_match_both_flat() {
1592        let result = check_position_match(dec!(0), dec!(0), dec!(0), dec!(0), dec!(0.0001));
1593        assert!(result);
1594    }
1595
1596    #[rstest]
1597    fn test_reconciliation_price_flat_to_long(_instrument: InstrumentAny) {
1598        let result = calculate_reconciliation_price(dec!(0), None, dec!(10), Some(dec!(100)));
1599        assert!(result.is_some());
1600        assert_eq!(result.unwrap(), dec!(100));
1601    }
1602
1603    #[rstest]
1604    fn test_reconciliation_price_no_target_avg_px(_instrument: InstrumentAny) {
1605        let result = calculate_reconciliation_price(dec!(5), Some(dec!(100)), dec!(10), None);
1606        assert!(result.is_none());
1607    }
1608
1609    #[rstest]
1610    fn test_reconciliation_price_no_quantity_change(_instrument: InstrumentAny) {
1611        let result =
1612            calculate_reconciliation_price(dec!(10), Some(dec!(100)), dec!(10), Some(dec!(105)));
1613        assert!(result.is_none());
1614    }
1615
1616    #[rstest]
1617    fn test_reconciliation_price_long_position_increase(_instrument: InstrumentAny) {
1618        let result =
1619            calculate_reconciliation_price(dec!(10), Some(dec!(100)), dec!(15), Some(dec!(102)));
1620        assert!(result.is_some());
1621        // Expected: (15 * 102 - 10 * 100) / 5 = (1530 - 1000) / 5 = 106
1622        assert_eq!(result.unwrap(), dec!(106));
1623    }
1624
1625    #[rstest]
1626    fn test_reconciliation_price_flat_to_short(_instrument: InstrumentAny) {
1627        let result = calculate_reconciliation_price(dec!(0), None, dec!(-10), Some(dec!(100)));
1628        assert!(result.is_some());
1629        assert_eq!(result.unwrap(), dec!(100));
1630    }
1631
1632    #[rstest]
1633    fn test_reconciliation_price_long_to_flat(_instrument: InstrumentAny) {
1634        // Close long position to flat: 100 @ 1.20 to 0
1635        // When closing to flat, reconciliation price equals current average price
1636        let result =
1637            calculate_reconciliation_price(dec!(100), Some(dec!(1.20)), dec!(0), Some(dec!(0)));
1638        assert!(result.is_some());
1639        assert_eq!(result.unwrap(), dec!(1.20));
1640    }
1641
1642    #[rstest]
1643    fn test_reconciliation_price_short_to_flat(_instrument: InstrumentAny) {
1644        // Close short position to flat: -50 @ 2.50 to 0
1645        // When closing to flat, reconciliation price equals current average price
1646        let result = calculate_reconciliation_price(dec!(-50), Some(dec!(2.50)), dec!(0), None);
1647        assert!(result.is_some());
1648        assert_eq!(result.unwrap(), dec!(2.50));
1649    }
1650
1651    #[rstest]
1652    fn test_reconciliation_price_short_position_increase(_instrument: InstrumentAny) {
1653        // Short position increase: -100 @ 1.30 to -200 @ 1.28
1654        // (−200 × 1.28) = (−100 × 1.30) + (−100 × reconciliation_px)
1655        // −256 = −130 + (−100 × reconciliation_px)
1656        // reconciliation_px = 1.26
1657        let result = calculate_reconciliation_price(
1658            dec!(-100),
1659            Some(dec!(1.30)),
1660            dec!(-200),
1661            Some(dec!(1.28)),
1662        );
1663        assert!(result.is_some());
1664        assert_eq!(result.unwrap(), dec!(1.26));
1665    }
1666
1667    #[rstest]
1668    fn test_reconciliation_price_long_position_decrease(_instrument: InstrumentAny) {
1669        // Long position decrease: 200 @ 1.20 to 100 @ 1.20
1670        let result = calculate_reconciliation_price(
1671            dec!(200),
1672            Some(dec!(1.20)),
1673            dec!(100),
1674            Some(dec!(1.20)),
1675        );
1676        assert!(result.is_some());
1677        assert_eq!(result.unwrap(), dec!(1.20));
1678    }
1679
1680    #[rstest]
1681    fn test_reconciliation_price_long_to_short_flip(_instrument: InstrumentAny) {
1682        // Long to short flip: 100 @ 1.20 to -100 @ 1.25
1683        // Due to netting simulation resetting value on flip, reconciliation_px = target_avg_px
1684        let result = calculate_reconciliation_price(
1685            dec!(100),
1686            Some(dec!(1.20)),
1687            dec!(-100),
1688            Some(dec!(1.25)),
1689        );
1690        assert!(result.is_some());
1691        assert_eq!(result.unwrap(), dec!(1.25));
1692    }
1693
1694    #[rstest]
1695    fn test_reconciliation_price_short_to_long_flip(_instrument: InstrumentAny) {
1696        // Short to long flip: -100 @ 1.30 to 100 @ 1.25
1697        // Due to netting simulation resetting value on flip, reconciliation_px = target_avg_px
1698        let result = calculate_reconciliation_price(
1699            dec!(-100),
1700            Some(dec!(1.30)),
1701            dec!(100),
1702            Some(dec!(1.25)),
1703        );
1704        assert!(result.is_some());
1705        assert_eq!(result.unwrap(), dec!(1.25));
1706    }
1707
1708    #[rstest]
1709    fn test_reconciliation_price_complex_scenario(_instrument: InstrumentAny) {
1710        // Complex: 150 @ 1.23456 to 250 @ 1.24567
1711        // (250 × 1.24567) = (150 × 1.23456) + (100 × reconciliation_px)
1712        // 311.4175 = 185.184 + (100 × reconciliation_px)
1713        // reconciliation_px = 1.262335
1714        let result = calculate_reconciliation_price(
1715            dec!(150),
1716            Some(dec!(1.23456)),
1717            dec!(250),
1718            Some(dec!(1.24567)),
1719        );
1720        assert!(result.is_some());
1721        assert_eq!(result.unwrap(), dec!(1.262335));
1722    }
1723
1724    #[rstest]
1725    fn test_reconciliation_price_zero_target_avg_px(_instrument: InstrumentAny) {
1726        let result =
1727            calculate_reconciliation_price(dec!(100), Some(dec!(1.20)), dec!(200), Some(dec!(0)));
1728        assert!(result.is_none());
1729    }
1730
1731    #[rstest]
1732    fn test_reconciliation_price_negative_price(_instrument: InstrumentAny) {
1733        // Negative price calculation: 100 @ 2.00 to 200 @ 1.00
1734        // (200 × 1.00) = (100 × 2.00) + (100 × reconciliation_px)
1735        // 200 = 200 + (100 × reconciliation_px)
1736        // reconciliation_px = 0 (should return None as price must be positive)
1737        let result = calculate_reconciliation_price(
1738            dec!(100),
1739            Some(dec!(2.00)),
1740            dec!(200),
1741            Some(dec!(1.00)),
1742        );
1743        assert!(result.is_none());
1744    }
1745
1746    #[rstest]
1747    fn test_reconciliation_price_flip_simulation_compatibility() {
1748        let venue_order_id = create_test_venue_order_id("ORDER1");
1749        // Start with long position: 100 @ 1.20
1750        // Target: -100 @ 1.25
1751        // Calculate reconciliation price
1752        let recon_px = calculate_reconciliation_price(
1753            dec!(100),
1754            Some(dec!(1.20)),
1755            dec!(-100),
1756            Some(dec!(1.25)),
1757        )
1758        .expect("reconciliation price");
1759
1760        assert_eq!(recon_px, dec!(1.25));
1761
1762        // Simulate the flip with reconciliation fill (sell 200 to go from +100 to -100)
1763        let fills = vec![
1764            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
1765            FillSnapshot::new(2000, OrderSide::Sell, dec!(200), recon_px, venue_order_id),
1766        ];
1767
1768        let (final_qty, final_value) = simulate_position(&fills);
1769        assert_eq!(final_qty, dec!(-100));
1770        let final_avg = final_value / final_qty.abs();
1771        assert_eq!(final_avg, dec!(1.25), "Final average should match target");
1772    }
1773
1774    #[rstest]
1775    fn test_reconciliation_price_accumulation_simulation_compatibility() {
1776        let venue_order_id = create_test_venue_order_id("ORDER1");
1777        // Start with long position: 100 @ 1.20
1778        // Target: 200 @ 1.22
1779        let recon_px = calculate_reconciliation_price(
1780            dec!(100),
1781            Some(dec!(1.20)),
1782            dec!(200),
1783            Some(dec!(1.22)),
1784        )
1785        .expect("reconciliation price");
1786
1787        // Simulate accumulation with reconciliation fill
1788        let fills = vec![
1789            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
1790            FillSnapshot::new(2000, OrderSide::Buy, dec!(100), recon_px, venue_order_id),
1791        ];
1792
1793        let (final_qty, final_value) = simulate_position(&fills);
1794        assert_eq!(final_qty, dec!(200));
1795        let final_avg = final_value / final_qty.abs();
1796        assert_eq!(final_avg, dec!(1.22), "Final average should match target");
1797    }
1798
1799    #[rstest]
1800    fn test_simulate_position_accumulate_short() {
1801        let venue_order_id = create_test_venue_order_id("ORDER1");
1802        let fills = vec![
1803            FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
1804            FillSnapshot::new(2000, OrderSide::Sell, dec!(5), dec!(98), venue_order_id),
1805        ];
1806
1807        let (qty, value) = simulate_position(&fills);
1808        assert_eq!(qty, dec!(-15));
1809        assert_eq!(value, dec!(1490)); // 10*100 + 5*98
1810    }
1811
1812    #[rstest]
1813    fn test_simulate_position_short_to_long_flip() {
1814        let venue_order_id = create_test_venue_order_id("ORDER1");
1815        let fills = vec![
1816            FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
1817            FillSnapshot::new(2000, OrderSide::Buy, dec!(15), dec!(102), venue_order_id),
1818        ];
1819
1820        let (qty, value) = simulate_position(&fills);
1821        assert_eq!(qty, dec!(5)); // Flipped from -10 to +5
1822        assert_eq!(value, dec!(510)); // Remaining 5 @ 102
1823    }
1824
1825    #[rstest]
1826    fn test_simulate_position_multiple_flips() {
1827        let venue_order_id = create_test_venue_order_id("ORDER1");
1828        let fills = vec![
1829            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1830            FillSnapshot::new(2000, OrderSide::Sell, dec!(15), dec!(105), venue_order_id), // Flip to -5
1831            FillSnapshot::new(3000, OrderSide::Buy, dec!(10), dec!(110), venue_order_id), // Flip to +5
1832        ];
1833
1834        let (qty, value) = simulate_position(&fills);
1835        assert_eq!(qty, dec!(5)); // Final position: +5
1836        assert_eq!(value, dec!(550)); // 5 @ 110
1837    }
1838
1839    #[rstest]
1840    fn test_simulate_position_empty_fills() {
1841        let fills: Vec<FillSnapshot> = vec![];
1842        let (qty, value) = simulate_position(&fills);
1843        assert_eq!(qty, dec!(0));
1844        assert_eq!(value, dec!(0));
1845    }
1846
1847    #[rstest]
1848    fn test_detect_zero_crossings_no_crossings() {
1849        let venue_order_id = create_test_venue_order_id("ORDER1");
1850        let fills = vec![
1851            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1852            FillSnapshot::new(2000, OrderSide::Buy, dec!(5), dec!(102), venue_order_id),
1853        ];
1854
1855        let crossings = detect_zero_crossings(&fills);
1856        assert_eq!(crossings.len(), 0);
1857    }
1858
1859    #[rstest]
1860    fn test_detect_zero_crossings_single_crossing() {
1861        let venue_order_id = create_test_venue_order_id("ORDER1");
1862        let fills = vec![
1863            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1864            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), venue_order_id), // Close to zero
1865        ];
1866
1867        let crossings = detect_zero_crossings(&fills);
1868        assert_eq!(crossings.len(), 1);
1869        assert_eq!(crossings[0], 2000);
1870    }
1871
1872    #[rstest]
1873    fn test_detect_zero_crossings_empty_fills() {
1874        let fills: Vec<FillSnapshot> = vec![];
1875        let crossings = detect_zero_crossings(&fills);
1876        assert_eq!(crossings.len(), 0);
1877    }
1878
1879    #[rstest]
1880    fn test_detect_zero_crossings_long_to_short_flip() {
1881        let venue_order_id = create_test_venue_order_id("ORDER1");
1882        // Buy 10, then Sell 15 -> flip from +10 to -5
1883        let fills = vec![
1884            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1885            FillSnapshot::new(2000, OrderSide::Sell, dec!(15), dec!(102), venue_order_id), // Flip
1886        ];
1887
1888        let crossings = detect_zero_crossings(&fills);
1889        assert_eq!(crossings.len(), 1);
1890        assert_eq!(crossings[0], 2000); // Detected the flip
1891    }
1892
1893    #[rstest]
1894    fn test_detect_zero_crossings_short_to_long_flip() {
1895        let venue_order_id = create_test_venue_order_id("ORDER1");
1896        // Sell 10, then Buy 20 -> flip from -10 to +10
1897        let fills = vec![
1898            FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
1899            FillSnapshot::new(2000, OrderSide::Buy, dec!(20), dec!(102), venue_order_id), // Flip
1900        ];
1901
1902        let crossings = detect_zero_crossings(&fills);
1903        assert_eq!(crossings.len(), 1);
1904        assert_eq!(crossings[0], 2000);
1905    }
1906
1907    #[rstest]
1908    fn test_detect_zero_crossings_multiple_flips() {
1909        let venue_order_id = create_test_venue_order_id("ORDER1");
1910        let fills = vec![
1911            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1912            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), venue_order_id), // Land on zero
1913            FillSnapshot::new(3000, OrderSide::Sell, dec!(5), dec!(103), venue_order_id), // Go short
1914            FillSnapshot::new(4000, OrderSide::Buy, dec!(15), dec!(104), venue_order_id), // Flip to long
1915        ];
1916
1917        let crossings = detect_zero_crossings(&fills);
1918        assert_eq!(crossings.len(), 2);
1919        assert_eq!(crossings[0], 2000); // First zero-crossing (land on zero)
1920        assert_eq!(crossings[1], 4000); // Second zero-crossing (flip)
1921    }
1922
1923    #[rstest]
1924    fn test_check_position_match_outside_tolerance() {
1925        // Simulated avg px = 1000/10 = 100, venue = 101
1926        // Relative diff = 1 / 101 = 0.0099 > 0.0001
1927        let result = check_position_match(dec!(10), dec!(1000), dec!(10), dec!(101), dec!(0.0001));
1928        assert!(!result);
1929    }
1930
1931    #[rstest]
1932    fn test_check_position_match_edge_of_tolerance() {
1933        // Simulated avg px = 1000/10 = 100, venue = 100.01
1934        // Relative diff = 0.01 / 100.01 = 0.00009999 < 0.0001
1935        let result =
1936            check_position_match(dec!(10), dec!(1000), dec!(10), dec!(100.01), dec!(0.0001));
1937        assert!(result);
1938    }
1939
1940    #[rstest]
1941    fn test_check_position_match_zero_venue_avg_px() {
1942        let result = check_position_match(dec!(10), dec!(1000), dec!(10), dec!(0), dec!(0.0001));
1943        assert!(!result); // Should fail because relative diff calculation with zero denominator
1944    }
1945
1946    #[rstest]
1947    fn test_adjust_fills_no_fills() {
1948        let venue_position = VenuePositionSnapshot {
1949            side: OrderSide::Buy,
1950            qty: dec!(0.02),
1951            avg_px: dec!(4100.00),
1952        };
1953        let instrument = instrument();
1954        let result =
1955            adjust_fills_for_partial_window(&[], &venue_position, &instrument, dec!(0.0001));
1956        assert!(matches!(result, FillAdjustmentResult::NoAdjustment));
1957    }
1958
1959    #[rstest]
1960    fn test_adjust_fills_flat_position() {
1961        let venue_order_id = create_test_venue_order_id("ORDER1");
1962        let fills = vec![FillSnapshot::new(
1963            1000,
1964            OrderSide::Buy,
1965            dec!(0.01),
1966            dec!(4100.00),
1967            venue_order_id,
1968        )];
1969        let venue_position = VenuePositionSnapshot {
1970            side: OrderSide::Buy,
1971            qty: dec!(0),
1972            avg_px: dec!(0),
1973        };
1974        let instrument = instrument();
1975        let result =
1976            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1977        assert!(matches!(result, FillAdjustmentResult::NoAdjustment));
1978    }
1979
1980    #[rstest]
1981    fn test_adjust_fills_complete_lifecycle_no_adjustment() {
1982        let venue_order_id = create_test_venue_order_id("ORDER1");
1983        let venue_order_id2 = create_test_venue_order_id("ORDER2");
1984        let fills = vec![
1985            FillSnapshot::new(
1986                1000,
1987                OrderSide::Buy,
1988                dec!(0.01),
1989                dec!(4100.00),
1990                venue_order_id,
1991            ),
1992            FillSnapshot::new(
1993                2000,
1994                OrderSide::Buy,
1995                dec!(0.01),
1996                dec!(4100.00),
1997                venue_order_id2,
1998            ),
1999        ];
2000        let venue_position = VenuePositionSnapshot {
2001            side: OrderSide::Buy,
2002            qty: dec!(0.02),
2003            avg_px: dec!(4100.00),
2004        };
2005        let instrument = instrument();
2006        let result =
2007            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2008        assert!(matches!(result, FillAdjustmentResult::NoAdjustment));
2009    }
2010
2011    #[rstest]
2012    fn test_adjust_fills_incomplete_lifecycle_adds_synthetic() {
2013        let venue_order_id = create_test_venue_order_id("ORDER1");
2014        // Window only sees +0.02 @ 4200, but venue has 0.04 @ 4100
2015        let fills = vec![FillSnapshot::new(
2016            2000,
2017            OrderSide::Buy,
2018            dec!(0.02),
2019            dec!(4200.00),
2020            venue_order_id,
2021        )];
2022        let venue_position = VenuePositionSnapshot {
2023            side: OrderSide::Buy,
2024            qty: dec!(0.04),
2025            avg_px: dec!(4100.00),
2026        };
2027        let instrument = instrument();
2028        let result =
2029            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2030
2031        match result {
2032            FillAdjustmentResult::AddSyntheticOpening {
2033                synthetic_fill,
2034                existing_fills,
2035            } => {
2036                assert_eq!(synthetic_fill.side, OrderSide::Buy);
2037                assert_eq!(synthetic_fill.qty, dec!(0.02)); // Missing 0.02
2038                assert_eq!(existing_fills.len(), 1);
2039            }
2040            _ => panic!("Expected AddSyntheticOpening"),
2041        }
2042    }
2043
2044    #[rstest]
2045    fn test_adjust_fills_with_zero_crossings() {
2046        let venue_order_id1 = create_test_venue_order_id("ORDER1");
2047        let venue_order_id2 = create_test_venue_order_id("ORDER2");
2048        let venue_order_id3 = create_test_venue_order_id("ORDER3");
2049
2050        // Lifecycle 1: LONG 0.02 -> FLAT (zero-crossing at 2000)
2051        // Lifecycle 2: LONG 0.03 (current)
2052        let fills = vec![
2053            FillSnapshot::new(
2054                1000,
2055                OrderSide::Buy,
2056                dec!(0.02),
2057                dec!(4100.00),
2058                venue_order_id1,
2059            ),
2060            FillSnapshot::new(
2061                2000,
2062                OrderSide::Sell,
2063                dec!(0.02),
2064                dec!(4150.00),
2065                venue_order_id2,
2066            ), // Zero-crossing
2067            FillSnapshot::new(
2068                3000,
2069                OrderSide::Buy,
2070                dec!(0.03),
2071                dec!(4200.00),
2072                venue_order_id3,
2073            ), // Current lifecycle
2074        ];
2075
2076        let venue_position = VenuePositionSnapshot {
2077            side: OrderSide::Buy,
2078            qty: dec!(0.03),
2079            avg_px: dec!(4200.00),
2080        };
2081
2082        let instrument = instrument();
2083        let result =
2084            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2085
2086        // Should filter to current lifecycle only
2087        match result {
2088            FillAdjustmentResult::FilterToCurrentLifecycle {
2089                last_zero_crossing_ts,
2090                current_lifecycle_fills,
2091            } => {
2092                assert_eq!(last_zero_crossing_ts, 2000);
2093                assert_eq!(current_lifecycle_fills.len(), 1);
2094                assert_eq!(current_lifecycle_fills[0].venue_order_id, venue_order_id3);
2095            }
2096            _ => panic!("Expected FilterToCurrentLifecycle, was {result:?}"),
2097        }
2098    }
2099
2100    #[rstest]
2101    fn test_adjust_fills_multiple_zero_crossings_mismatch() {
2102        let venue_order_id1 = create_test_venue_order_id("ORDER1");
2103        let venue_order_id2 = create_test_venue_order_id("ORDER2");
2104        let _venue_order_id3 = create_test_venue_order_id("ORDER3");
2105        let venue_order_id4 = create_test_venue_order_id("ORDER4");
2106        let venue_order_id5 = create_test_venue_order_id("ORDER5");
2107
2108        // Lifecycle 1: LONG 0.05 -> FLAT
2109        // Lifecycle 2: Current fills produce 0.10 @ 4050, but venue has 0.05 @ 4142.04
2110        let fills = vec![
2111            FillSnapshot::new(
2112                1000,
2113                OrderSide::Buy,
2114                dec!(0.05),
2115                dec!(4000.00),
2116                venue_order_id1,
2117            ),
2118            FillSnapshot::new(
2119                2000,
2120                OrderSide::Sell,
2121                dec!(0.05),
2122                dec!(4050.00),
2123                venue_order_id2,
2124            ), // Zero-crossing
2125            FillSnapshot::new(
2126                3000,
2127                OrderSide::Buy,
2128                dec!(0.05),
2129                dec!(4000.00),
2130                venue_order_id4,
2131            ), // Current lifecycle
2132            FillSnapshot::new(
2133                4000,
2134                OrderSide::Buy,
2135                dec!(0.05),
2136                dec!(4100.00),
2137                venue_order_id5,
2138            ), // Current lifecycle
2139        ];
2140
2141        let venue_position = VenuePositionSnapshot {
2142            side: OrderSide::Buy,
2143            qty: dec!(0.05),
2144            avg_px: dec!(4142.04),
2145        };
2146
2147        let instrument = instrument();
2148        let result =
2149            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2150
2151        // Should replace current lifecycle with synthetic
2152        match result {
2153            FillAdjustmentResult::ReplaceCurrentLifecycle {
2154                synthetic_fill,
2155                first_venue_order_id,
2156            } => {
2157                assert_eq!(synthetic_fill.qty, dec!(0.05));
2158                assert_eq!(synthetic_fill.px, dec!(4142.04));
2159                assert_eq!(synthetic_fill.side, OrderSide::Buy);
2160                assert_eq!(first_venue_order_id, venue_order_id4);
2161            }
2162            _ => panic!("Expected ReplaceCurrentLifecycle, was {result:?}"),
2163        }
2164    }
2165
2166    #[rstest]
2167    fn test_adjust_fills_short_position() {
2168        let venue_order_id = create_test_venue_order_id("ORDER1");
2169
2170        // Window only sees SELL 0.02 @ 4120, but venue has -0.05 @ 4100
2171        let fills = vec![FillSnapshot::new(
2172            1000,
2173            OrderSide::Sell,
2174            dec!(0.02),
2175            dec!(4120.00),
2176            venue_order_id,
2177        )];
2178
2179        let venue_position = VenuePositionSnapshot {
2180            side: OrderSide::Sell,
2181            qty: dec!(0.05),
2182            avg_px: dec!(4100.00),
2183        };
2184
2185        let instrument = instrument();
2186        let result =
2187            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2188
2189        // Should add synthetic opening SHORT fill
2190        match result {
2191            FillAdjustmentResult::AddSyntheticOpening {
2192                synthetic_fill,
2193                existing_fills,
2194            } => {
2195                assert_eq!(synthetic_fill.side, OrderSide::Sell);
2196                assert_eq!(synthetic_fill.qty, dec!(0.03)); // Missing 0.03
2197                assert_eq!(existing_fills.len(), 1);
2198            }
2199            _ => panic!("Expected AddSyntheticOpening, was {result:?}"),
2200        }
2201    }
2202
2203    #[rstest]
2204    fn test_adjust_fills_timestamp_underflow_protection() {
2205        let venue_order_id = create_test_venue_order_id("ORDER1");
2206
2207        // First fill at timestamp 0 - saturating_sub should prevent underflow
2208        let fills = vec![FillSnapshot::new(
2209            0,
2210            OrderSide::Buy,
2211            dec!(0.01),
2212            dec!(4100.00),
2213            venue_order_id,
2214        )];
2215
2216        let venue_position = VenuePositionSnapshot {
2217            side: OrderSide::Buy,
2218            qty: dec!(0.02),
2219            avg_px: dec!(4100.00),
2220        };
2221
2222        let instrument = instrument();
2223        let result =
2224            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2225
2226        // Should add synthetic fill with timestamp 0 (not u64::MAX)
2227        match result {
2228            FillAdjustmentResult::AddSyntheticOpening { synthetic_fill, .. } => {
2229                assert_eq!(synthetic_fill.ts_event, 0); // saturating_sub(1) from 0 = 0
2230            }
2231            _ => panic!("Expected AddSyntheticOpening, was {result:?}"),
2232        }
2233    }
2234
2235    #[rstest]
2236    fn test_adjust_fills_with_flip_scenario() {
2237        let venue_order_id1 = create_test_venue_order_id("ORDER1");
2238        let venue_order_id2 = create_test_venue_order_id("ORDER2");
2239
2240        // Long 10 @ 100, then Sell 20 @ 105 -> flip to Short 10 @ 105
2241        let fills = vec![
2242            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id1),
2243            FillSnapshot::new(2000, OrderSide::Sell, dec!(20), dec!(105), venue_order_id2), // Flip
2244        ];
2245
2246        let venue_position = VenuePositionSnapshot {
2247            side: OrderSide::Sell,
2248            qty: dec!(10),
2249            avg_px: dec!(105),
2250        };
2251
2252        let instrument = instrument();
2253        let result =
2254            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2255
2256        // Should recognize the flip and match correctly
2257        match result {
2258            FillAdjustmentResult::NoAdjustment => {
2259                // Verify simulation matches
2260                let (qty, value) = simulate_position(&fills);
2261                assert_eq!(qty, dec!(-10));
2262                let avg = value / qty.abs();
2263                assert_eq!(avg, dec!(105));
2264            }
2265            _ => panic!("Expected NoAdjustment for matching flip, was {result:?}"),
2266        }
2267    }
2268
2269    #[rstest]
2270    fn test_detect_zero_crossings_complex_lifecycle() {
2271        let venue_order_id = create_test_venue_order_id("ORDER1");
2272        // Complex scenario with multiple lifecycles
2273        let fills = vec![
2274            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
2275            FillSnapshot::new(2000, OrderSide::Sell, dec!(50), dec!(1.25), venue_order_id), // Reduce
2276            FillSnapshot::new(3000, OrderSide::Sell, dec!(100), dec!(1.30), venue_order_id), // Flip to -50
2277            FillSnapshot::new(4000, OrderSide::Buy, dec!(50), dec!(1.28), venue_order_id), // Close to zero
2278            FillSnapshot::new(5000, OrderSide::Buy, dec!(75), dec!(1.22), venue_order_id), // Open long
2279            FillSnapshot::new(6000, OrderSide::Sell, dec!(150), dec!(1.24), venue_order_id), // Flip to -75
2280        ];
2281
2282        let crossings = detect_zero_crossings(&fills);
2283        assert_eq!(crossings.len(), 3);
2284        assert_eq!(crossings[0], 3000); // First flip
2285        assert_eq!(crossings[1], 4000); // Close to zero
2286        assert_eq!(crossings[2], 6000); // Second flip
2287    }
2288
2289    #[rstest]
2290    fn test_reconciliation_price_partial_close() {
2291        let venue_order_id = create_test_venue_order_id("ORDER1");
2292        // Partial close scenario: 100 @ 1.20 to 50 @ 1.20
2293        let recon_px =
2294            calculate_reconciliation_price(dec!(100), Some(dec!(1.20)), dec!(50), Some(dec!(1.20)))
2295                .expect("reconciliation price");
2296
2297        // Simulate partial close
2298        let fills = vec![
2299            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
2300            FillSnapshot::new(2000, OrderSide::Sell, dec!(50), recon_px, venue_order_id),
2301        ];
2302
2303        let (final_qty, final_value) = simulate_position(&fills);
2304        assert_eq!(final_qty, dec!(50));
2305        let final_avg = final_value / final_qty.abs();
2306        assert_eq!(final_avg, dec!(1.20), "Average should be maintained");
2307    }
2308
2309    #[rstest]
2310    fn test_detect_zero_crossings_identical_timestamps() {
2311        let venue_order_id1 = create_test_venue_order_id("ORDER1");
2312        let venue_order_id2 = create_test_venue_order_id("ORDER2");
2313
2314        // Two fills with identical timestamps - should process deterministically
2315        let fills = vec![
2316            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id1),
2317            FillSnapshot::new(2000, OrderSide::Sell, dec!(5), dec!(102), venue_order_id1),
2318            FillSnapshot::new(2000, OrderSide::Sell, dec!(5), dec!(103), venue_order_id2), // Same ts
2319        ];
2320
2321        let crossings = detect_zero_crossings(&fills);
2322
2323        // Position: +10 -> +5 -> 0 (zero crossing at last fill)
2324        assert_eq!(crossings.len(), 1);
2325        assert_eq!(crossings[0], 2000);
2326
2327        // Verify final position is flat
2328        let (qty, _) = simulate_position(&fills);
2329        assert_eq!(qty, dec!(0));
2330    }
2331
2332    #[rstest]
2333    fn test_detect_zero_crossings_five_lifecycles() {
2334        let venue_order_id = create_test_venue_order_id("ORDER1");
2335
2336        // Five complete position lifecycles: open->close repeated 5 times
2337        let fills = vec![
2338            // Lifecycle 1: Long
2339            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2340            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(101), venue_order_id),
2341            // Lifecycle 2: Short
2342            FillSnapshot::new(3000, OrderSide::Sell, dec!(20), dec!(102), venue_order_id),
2343            FillSnapshot::new(4000, OrderSide::Buy, dec!(20), dec!(101), venue_order_id),
2344            // Lifecycle 3: Long
2345            FillSnapshot::new(5000, OrderSide::Buy, dec!(15), dec!(103), venue_order_id),
2346            FillSnapshot::new(6000, OrderSide::Sell, dec!(15), dec!(104), venue_order_id),
2347            // Lifecycle 4: Short
2348            FillSnapshot::new(7000, OrderSide::Sell, dec!(25), dec!(105), venue_order_id),
2349            FillSnapshot::new(8000, OrderSide::Buy, dec!(25), dec!(104), venue_order_id),
2350            // Lifecycle 5: Long (still open)
2351            FillSnapshot::new(9000, OrderSide::Buy, dec!(30), dec!(106), venue_order_id),
2352        ];
2353
2354        let crossings = detect_zero_crossings(&fills);
2355
2356        // Should detect 4 zero-crossings (positions closing to flat)
2357        assert_eq!(crossings.len(), 4);
2358        assert_eq!(crossings[0], 2000);
2359        assert_eq!(crossings[1], 4000);
2360        assert_eq!(crossings[2], 6000);
2361        assert_eq!(crossings[3], 8000);
2362
2363        // Final position should be +30
2364        let (qty, _) = simulate_position(&fills);
2365        assert_eq!(qty, dec!(30));
2366    }
2367
2368    #[rstest]
2369    fn test_adjust_fills_five_zero_crossings(instrument: InstrumentAny) {
2370        let venue_order_id = create_test_venue_order_id("ORDER1");
2371
2372        // Complex scenario: 4 complete lifecycles + current open position
2373        let fills = vec![
2374            // Old lifecycles (should be filtered out)
2375            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2376            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(101), venue_order_id),
2377            FillSnapshot::new(3000, OrderSide::Sell, dec!(20), dec!(102), venue_order_id),
2378            FillSnapshot::new(4000, OrderSide::Buy, dec!(20), dec!(101), venue_order_id),
2379            FillSnapshot::new(5000, OrderSide::Buy, dec!(15), dec!(103), venue_order_id),
2380            FillSnapshot::new(6000, OrderSide::Sell, dec!(15), dec!(104), venue_order_id),
2381            FillSnapshot::new(7000, OrderSide::Sell, dec!(25), dec!(105), venue_order_id),
2382            FillSnapshot::new(8000, OrderSide::Buy, dec!(25), dec!(104), venue_order_id),
2383            // Current lifecycle (should be kept)
2384            FillSnapshot::new(9000, OrderSide::Buy, dec!(30), dec!(106), venue_order_id),
2385        ];
2386
2387        let venue_position = VenuePositionSnapshot {
2388            side: OrderSide::Buy,
2389            qty: dec!(30),
2390            avg_px: dec!(106),
2391        };
2392
2393        let result =
2394            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2395
2396        // Should filter to current lifecycle only (after last zero-crossing at 8000)
2397        match result {
2398            FillAdjustmentResult::FilterToCurrentLifecycle {
2399                last_zero_crossing_ts,
2400                current_lifecycle_fills,
2401            } => {
2402                assert_eq!(last_zero_crossing_ts, 8000);
2403                assert_eq!(current_lifecycle_fills.len(), 1);
2404                assert_eq!(current_lifecycle_fills[0].ts_event, 9000);
2405                assert_eq!(current_lifecycle_fills[0].qty, dec!(30));
2406            }
2407            _ => panic!("Expected FilterToCurrentLifecycle, was {result:?}"),
2408        }
2409    }
2410
2411    #[rstest]
2412    fn test_adjust_fills_alternating_long_short_positions(instrument: InstrumentAny) {
2413        let venue_order_id = create_test_venue_order_id("ORDER1");
2414
2415        // Alternating: Long -> Short -> Long -> Short -> Long
2416        // These are flips (sign changes) but never go to exactly zero
2417        let fills = vec![
2418            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2419            FillSnapshot::new(2000, OrderSide::Sell, dec!(20), dec!(102), venue_order_id), // Flip to -10
2420            FillSnapshot::new(3000, OrderSide::Buy, dec!(20), dec!(101), venue_order_id), // Flip to +10
2421            FillSnapshot::new(4000, OrderSide::Sell, dec!(20), dec!(103), venue_order_id), // Flip to -10
2422            FillSnapshot::new(5000, OrderSide::Buy, dec!(20), dec!(102), venue_order_id), // Flip to +10
2423        ];
2424
2425        // Current position: +10 @ 102
2426        let venue_position = VenuePositionSnapshot {
2427            side: OrderSide::Buy,
2428            qty: dec!(10),
2429            avg_px: dec!(102),
2430        };
2431
2432        let result =
2433            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2434
2435        // Position never went flat (0), just flipped sides. This is treated as one
2436        // continuous lifecycle since no explicit close occurred. The final position
2437        // matches so no adjustment needed.
2438        assert!(
2439            matches!(result, FillAdjustmentResult::NoAdjustment),
2440            "Expected NoAdjustment (continuous lifecycle with matching position), was {result:?}"
2441        );
2442    }
2443
2444    #[rstest]
2445    fn test_adjust_fills_with_flat_crossings(instrument: InstrumentAny) {
2446        let venue_order_id = create_test_venue_order_id("ORDER1");
2447
2448        // Proper lifecycle boundaries with flat crossings (position goes to exactly 0)
2449        let fills = vec![
2450            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2451            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), venue_order_id), // Close to 0
2452            FillSnapshot::new(3000, OrderSide::Sell, dec!(10), dec!(101), venue_order_id), // New short
2453            FillSnapshot::new(4000, OrderSide::Buy, dec!(10), dec!(99), venue_order_id), // Close to 0
2454            FillSnapshot::new(5000, OrderSide::Buy, dec!(10), dec!(98), venue_order_id), // New long
2455        ];
2456
2457        // Current position: +10 @ 98
2458        let venue_position = VenuePositionSnapshot {
2459            side: OrderSide::Buy,
2460            qty: dec!(10),
2461            avg_px: dec!(98),
2462        };
2463
2464        let result =
2465            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2466
2467        // Position went flat at ts=2000 and ts=4000
2468        // Current lifecycle starts after last flat (4000)
2469        match result {
2470            FillAdjustmentResult::FilterToCurrentLifecycle {
2471                last_zero_crossing_ts,
2472                current_lifecycle_fills,
2473            } => {
2474                assert_eq!(last_zero_crossing_ts, 4000);
2475                assert_eq!(current_lifecycle_fills.len(), 1);
2476                assert_eq!(current_lifecycle_fills[0].ts_event, 5000);
2477                assert_eq!(current_lifecycle_fills[0].qty, dec!(10));
2478            }
2479            _ => panic!("Expected FilterToCurrentLifecycle, was {result:?}"),
2480        }
2481    }
2482
2483    #[rstest]
2484    fn test_replace_current_lifecycle_uses_first_venue_order_id(instrument: InstrumentAny) {
2485        let order_id_1 = create_test_venue_order_id("ORDER1");
2486        let order_id_2 = create_test_venue_order_id("ORDER2");
2487        let order_id_3 = create_test_venue_order_id("ORDER3");
2488
2489        // Previous lifecycle closes, then current lifecycle has fills from multiple orders
2490        let fills = vec![
2491            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), order_id_1),
2492            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), order_id_1), // Close to 0
2493            // Current lifecycle: fills from different venue order IDs
2494            FillSnapshot::new(3000, OrderSide::Buy, dec!(5), dec!(103), order_id_2),
2495            FillSnapshot::new(4000, OrderSide::Buy, dec!(5), dec!(104), order_id_3),
2496        ];
2497
2498        // Venue position differs from simulated (+10 @ 103.5) to trigger replacement
2499        let venue_position = VenuePositionSnapshot {
2500            side: OrderSide::Buy,
2501            qty: dec!(15),
2502            avg_px: dec!(105),
2503        };
2504
2505        let result =
2506            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2507
2508        // Should replace with synthetic fill using first fill's venue_order_id (order_id_2)
2509        match result {
2510            FillAdjustmentResult::ReplaceCurrentLifecycle {
2511                synthetic_fill,
2512                first_venue_order_id,
2513            } => {
2514                assert_eq!(first_venue_order_id, order_id_2);
2515                assert_eq!(synthetic_fill.venue_order_id, order_id_2);
2516                assert_eq!(synthetic_fill.qty, dec!(15));
2517                assert_eq!(synthetic_fill.px, dec!(105));
2518            }
2519            _ => panic!("Expected ReplaceCurrentLifecycle, was {result:?}"),
2520        }
2521    }
2522
2523    fn make_test_report(
2524        instrument_id: InstrumentId,
2525        order_type: OrderType,
2526        status: OrderStatus,
2527        filled_qty: &str,
2528        post_only: bool,
2529    ) -> OrderStatusReport {
2530        let account_id = AccountId::from("TEST-001");
2531        let mut report = OrderStatusReport::new(
2532            account_id,
2533            instrument_id,
2534            None,
2535            VenueOrderId::from("V-001"),
2536            OrderSide::Buy,
2537            order_type,
2538            TimeInForce::Gtc,
2539            status,
2540            Quantity::from("1.0"),
2541            Quantity::from(filled_qty),
2542            UnixNanos::from(1_000_000),
2543            UnixNanos::from(1_000_000),
2544            UnixNanos::from(1_000_000),
2545            None,
2546        )
2547        .with_price(Price::from("100.00"))
2548        .with_avg_px(100.0)
2549        .unwrap();
2550        report.post_only = post_only;
2551        report
2552    }
2553
2554    #[rstest]
2555    #[case::accepted(OrderStatus::Accepted, "0", 1, "Accepted")]
2556    #[case::triggered(OrderStatus::Triggered, "0", 1, "Accepted")]
2557    #[case::canceled(OrderStatus::Canceled, "0", 2, "Canceled")]
2558    #[case::expired(OrderStatus::Expired, "0", 2, "Expired")]
2559    #[case::filled(OrderStatus::Filled, "1.0", 2, "Filled")]
2560    #[case::partially_filled(OrderStatus::PartiallyFilled, "0.5", 2, "Filled")]
2561    #[case::rejected(OrderStatus::Rejected, "0", 1, "Rejected")]
2562    fn test_external_order_status_event_generation(
2563        #[case] status: OrderStatus,
2564        #[case] filled_qty: &str,
2565        #[case] expected_events: usize,
2566        #[case] last_event_type: &str,
2567    ) {
2568        let instrument = crypto_perpetual_ethusdt();
2569        let order = OrderTestBuilder::new(OrderType::Limit)
2570            .instrument_id(instrument.id())
2571            .side(OrderSide::Buy)
2572            .quantity(Quantity::from("1.0"))
2573            .price(Price::from("100.00"))
2574            .build();
2575        let report = make_test_report(instrument.id(), OrderType::Limit, status, filled_qty, false);
2576
2577        let events = generate_external_order_status_events(
2578            &order,
2579            &report,
2580            &AccountId::from("TEST-001"),
2581            &InstrumentAny::CryptoPerpetual(instrument),
2582            UnixNanos::from(2_000_000),
2583        );
2584
2585        assert_eq!(events.len(), expected_events, "status={status}");
2586        let last = events.last().unwrap();
2587        let actual_type = match last {
2588            OrderEventAny::Accepted(_) => "Accepted",
2589            OrderEventAny::Canceled(_) => "Canceled",
2590            OrderEventAny::Expired(_) => "Expired",
2591            OrderEventAny::Filled(_) => "Filled",
2592            OrderEventAny::Rejected(_) => "Rejected",
2593            _ => "Other",
2594        };
2595        assert_eq!(actual_type, last_event_type, "status={status}");
2596    }
2597
2598    #[rstest]
2599    #[case::market(OrderType::Market, false, LiquiditySide::Taker)]
2600    #[case::stop_market(OrderType::StopMarket, false, LiquiditySide::Taker)]
2601    #[case::trailing_stop_market(OrderType::TrailingStopMarket, false, LiquiditySide::Taker)]
2602    #[case::limit_post_only(OrderType::Limit, true, LiquiditySide::Maker)]
2603    #[case::limit_default(OrderType::Limit, false, LiquiditySide::NoLiquiditySide)]
2604    fn test_inferred_fill_liquidity_side(
2605        #[case] order_type: OrderType,
2606        #[case] post_only: bool,
2607        #[case] expected: LiquiditySide,
2608    ) {
2609        let instrument = crypto_perpetual_ethusdt();
2610        let order = match order_type {
2611            OrderType::Limit => OrderTestBuilder::new(order_type)
2612                .instrument_id(instrument.id())
2613                .side(OrderSide::Buy)
2614                .quantity(Quantity::from("1.0"))
2615                .price(Price::from("100.00"))
2616                .build(),
2617            OrderType::StopMarket => OrderTestBuilder::new(order_type)
2618                .instrument_id(instrument.id())
2619                .side(OrderSide::Buy)
2620                .quantity(Quantity::from("1.0"))
2621                .trigger_price(Price::from("100.00"))
2622                .build(),
2623            OrderType::TrailingStopMarket => OrderTestBuilder::new(order_type)
2624                .instrument_id(instrument.id())
2625                .side(OrderSide::Buy)
2626                .quantity(Quantity::from("1.0"))
2627                .trigger_price(Price::from("100.00"))
2628                .trailing_offset(dec!(1.0))
2629                .build(),
2630            _ => OrderTestBuilder::new(order_type)
2631                .instrument_id(instrument.id())
2632                .side(OrderSide::Buy)
2633                .quantity(Quantity::from("1.0"))
2634                .build(),
2635        };
2636        let report = make_test_report(
2637            instrument.id(),
2638            order_type,
2639            OrderStatus::Filled,
2640            "1.0",
2641            post_only,
2642        );
2643
2644        let fill = create_inferred_fill(
2645            &order,
2646            &report,
2647            &AccountId::from("TEST-001"),
2648            &InstrumentAny::CryptoPerpetual(instrument),
2649            UnixNanos::from(2_000_000),
2650        );
2651
2652        let filled = match fill.unwrap() {
2653            OrderEventAny::Filled(f) => f,
2654            _ => panic!("Expected Filled event"),
2655        };
2656        assert_eq!(
2657            filled.liquidity_side, expected,
2658            "order_type={order_type}, post_only={post_only}"
2659        );
2660    }
2661
2662    #[rstest]
2663    fn test_inferred_fill_no_price_returns_none() {
2664        let instrument = crypto_perpetual_ethusdt();
2665        let order = OrderTestBuilder::new(OrderType::Market)
2666            .instrument_id(instrument.id())
2667            .side(OrderSide::Buy)
2668            .quantity(Quantity::from("1.0"))
2669            .build();
2670
2671        let report = OrderStatusReport::new(
2672            AccountId::from("TEST-001"),
2673            instrument.id(),
2674            None,
2675            VenueOrderId::from("V-001"),
2676            OrderSide::Buy,
2677            OrderType::Market,
2678            TimeInForce::Ioc,
2679            OrderStatus::Filled,
2680            Quantity::from("1.0"),
2681            Quantity::from("1.0"),
2682            UnixNanos::from(1_000_000),
2683            UnixNanos::from(1_000_000),
2684            UnixNanos::from(1_000_000),
2685            None,
2686        );
2687
2688        let fill = create_inferred_fill(
2689            &order,
2690            &report,
2691            &AccountId::from("TEST-001"),
2692            &InstrumentAny::CryptoPerpetual(instrument),
2693            UnixNanos::from(2_000_000),
2694        );
2695
2696        assert!(fill.is_none());
2697    }
2698}