nautilus_live/execution/
reconciliation.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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//! Reconciliation calculation functions for live trading.
17
18use nautilus_model::{enums::OrderSide, identifiers::VenueOrderId, instruments::InstrumentAny};
19use rust_decimal::Decimal;
20
21/// Immutable snapshot of fill data for position simulation.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct FillSnapshot {
24    /// The event timestamp (nanoseconds).
25    pub ts_event: u64,
26    /// The order side (BUY or SELL).
27    pub side: OrderSide,
28    /// The fill quantity.
29    pub qty: Decimal,
30    /// The fill price.
31    pub px: Decimal,
32    /// The venue order ID.
33    pub venue_order_id: VenueOrderId,
34}
35
36/// Represents a position snapshot from the venue.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct VenuePositionSnapshot {
39    /// The position side (LONG, SHORT, or FLAT).
40    pub side: OrderSide, // Using OrderSide to represent position side for simplicity
41    /// The position quantity (always positive, even for SHORT).
42    pub qty: Decimal,
43    /// The average entry price (can be zero for FLAT positions).
44    pub avg_px: Decimal,
45}
46
47/// Result of the fill adjustment process.
48#[derive(Debug, Clone, PartialEq)]
49pub enum FillAdjustmentResult {
50    /// No adjustment needed - return fills unchanged.
51    NoAdjustment,
52    /// Add synthetic opening fill to oldest lifecycle.
53    AddSyntheticOpening {
54        /// The synthetic fill to add at the beginning.
55        synthetic_fill: FillSnapshot,
56        /// All existing fills to keep.
57        existing_fills: Vec<FillSnapshot>,
58    },
59    /// Replace entire current lifecycle with single synthetic fill.
60    ReplaceCurrentLifecycle {
61        /// The single synthetic fill representing the entire position.
62        synthetic_fill: FillSnapshot,
63        /// The first venue order ID to use.
64        first_venue_order_id: VenueOrderId,
65    },
66    /// Filter fills to current lifecycle only (after last zero-crossing).
67    FilterToCurrentLifecycle {
68        /// Timestamp of the last zero-crossing.
69        last_zero_crossing_ts: u64,
70        /// Fills from current lifecycle.
71        current_lifecycle_fills: Vec<FillSnapshot>,
72    },
73}
74
75impl FillSnapshot {
76    /// Create a new fill snapshot.
77    #[must_use]
78    pub fn new(
79        ts_event: u64,
80        side: OrderSide,
81        qty: Decimal,
82        px: Decimal,
83        venue_order_id: VenueOrderId,
84    ) -> Self {
85        Self {
86            ts_event,
87            side,
88            qty,
89            px,
90            venue_order_id,
91        }
92    }
93
94    /// Return signed direction multiplier: +1 for BUY, -1 for SELL.
95    #[must_use]
96    pub fn direction(&self) -> i8 {
97        match self.side {
98            OrderSide::Buy => 1,
99            OrderSide::Sell => -1,
100            _ => 0,
101        }
102    }
103}
104
105/// Simulate position from chronologically ordered fills using netting logic.
106///
107/// # Returns
108///
109/// Returns a tuple of (quantity, value) after applying all fills.
110#[must_use]
111pub fn simulate_position(fills: &[FillSnapshot]) -> (Decimal, Decimal) {
112    let mut qty = Decimal::ZERO;
113    let mut value = Decimal::ZERO;
114
115    for fill in fills {
116        let direction = Decimal::from(fill.direction());
117        let new_qty = qty + (direction * fill.qty);
118
119        // Check if we're accumulating or crossing zero (flip/close)
120        if (qty >= Decimal::ZERO && direction > Decimal::ZERO)
121            || (qty <= Decimal::ZERO && direction < Decimal::ZERO)
122        {
123            // Accumulating in same direction
124            value += fill.qty * fill.px;
125            qty = new_qty;
126        } else {
127            // Closing or flipping position
128            if qty.abs() >= fill.qty {
129                // Partial close - maintain average price by reducing value proportionally
130                let close_ratio = fill.qty / qty.abs();
131                value *= Decimal::ONE - close_ratio;
132                qty = new_qty;
133            } else {
134                // Close and flip - reset value to opening position
135                let remaining = fill.qty - qty.abs();
136                qty = direction * remaining;
137                value = remaining * fill.px;
138            }
139        }
140    }
141
142    (qty, value)
143}
144
145/// Detect zero-crossing timestamps in a sequence of fills.
146///
147/// A zero-crossing occurs when position quantity crosses through zero (FLAT).
148/// This includes both landing exactly on zero and flipping from long to short or vice versa.
149///
150/// # Returns
151///
152/// Returns a list of timestamps where position crosses through zero.
153#[must_use]
154pub fn detect_zero_crossings(fills: &[FillSnapshot]) -> Vec<u64> {
155    let mut running_qty = Decimal::ZERO;
156    let mut zero_crossings = Vec::new();
157
158    for fill in fills {
159        let prev_qty = running_qty;
160        running_qty += Decimal::from(fill.direction()) * fill.qty;
161
162        // Detect when position crosses zero
163        if prev_qty != Decimal::ZERO {
164            if running_qty == Decimal::ZERO {
165                // Landed exactly on zero
166                zero_crossings.push(fill.ts_event);
167            } else if (prev_qty > Decimal::ZERO) != (running_qty > Decimal::ZERO) {
168                // Sign changed - crossed through zero (flip)
169                zero_crossings.push(fill.ts_event);
170            }
171        }
172    }
173
174    zero_crossings
175}
176
177/// Check if simulated position matches venue position within tolerance.
178///
179/// # Returns
180///
181/// Returns true if quantities and average prices match within tolerance.
182#[must_use]
183pub fn check_position_match(
184    simulated_qty: Decimal,
185    simulated_value: Decimal,
186    venue_qty: Decimal,
187    venue_avg_px: Decimal,
188    tolerance: Decimal,
189) -> bool {
190    if simulated_qty != venue_qty {
191        return false;
192    }
193
194    if simulated_qty == Decimal::ZERO {
195        return true; // Both FLAT
196    }
197
198    // Guard against division by zero
199    let abs_qty = simulated_qty.abs();
200    if abs_qty == Decimal::ZERO {
201        return false;
202    }
203
204    let simulated_avg_px = simulated_value / abs_qty;
205
206    // If venue avg px is zero, we cannot calculate relative difference
207    if venue_avg_px == Decimal::ZERO {
208        return false;
209    }
210
211    let relative_diff = (simulated_avg_px - venue_avg_px).abs() / venue_avg_px;
212
213    relative_diff <= tolerance
214}
215
216/// Calculate the price needed for a reconciliation order to achieve target position.
217///
218/// This is a pure function that calculates what price a fill would need to have
219/// to move from the current position state to the target position state with the
220/// correct average price, accounting for the netting simulation logic.
221///
222/// # Returns
223///
224/// Returns `Some(Decimal)` if a valid reconciliation price can be calculated, `None` otherwise.
225///
226/// # Notes
227///
228/// The function handles four scenarios:
229/// 1. Position to flat: reconciliation_px = current_avg_px (close at current average)
230/// 2. Flat to position: reconciliation_px = target_avg_px
231/// 3. Position flip (sign change): reconciliation_px = target_avg_px (due to value reset in simulation)
232/// 4. Accumulation/reduction: weighted average formula
233pub fn calculate_reconciliation_price(
234    current_position_qty: Decimal,
235    current_position_avg_px: Option<Decimal>,
236    target_position_qty: Decimal,
237    target_position_avg_px: Option<Decimal>,
238) -> Option<Decimal> {
239    // Calculate the difference in quantity
240    let qty_diff = target_position_qty - current_position_qty;
241
242    if qty_diff == Decimal::ZERO {
243        return None; // No reconciliation needed
244    }
245
246    // Special case: closing to flat (target_position_qty == 0)
247    // When flattening, the reconciliation price equals the current position's average price
248    if target_position_qty == Decimal::ZERO {
249        return current_position_avg_px;
250    }
251
252    // If target average price is not provided or zero, we cannot calculate
253    let target_avg_px = target_position_avg_px?;
254    if target_avg_px == Decimal::ZERO {
255        return None;
256    }
257
258    // If current position is flat, the reconciliation price equals target avg price
259    if current_position_qty == Decimal::ZERO || current_position_avg_px.is_none() {
260        return Some(target_avg_px);
261    }
262
263    let current_avg_px = current_position_avg_px?;
264
265    // Check if this is a flip scenario (sign change)
266    // In simulation, flips reset value to remaining * px, so reconciliation_px = target_avg_px
267    let is_flip = (current_position_qty > Decimal::ZERO) != (target_position_qty > Decimal::ZERO)
268        && target_position_qty != Decimal::ZERO;
269
270    if is_flip {
271        return Some(target_avg_px);
272    }
273
274    // For accumulation or reduction (same side), use weighted average formula
275    // Formula: (target_qty * target_avg_px) = (current_qty * current_avg_px) + (qty_diff * reconciliation_px)
276    let target_value = target_position_qty * target_avg_px;
277    let current_value = current_position_qty * current_avg_px;
278    let diff_value = target_value - current_value;
279
280    // qty_diff is guaranteed to be non-zero here due to early return at line 270
281    let reconciliation_px = diff_value / qty_diff;
282
283    // Ensure price is positive
284    if reconciliation_px > Decimal::ZERO {
285        return Some(reconciliation_px);
286    }
287
288    None
289}
290
291/// Adjust fills for partial reconciliation window to handle incomplete position lifecycles.
292///
293/// This function analyzes fills and determines if adjustments are needed when the reconciliation
294/// window doesn't capture the complete position history (missing opening fills).
295///
296/// # Returns
297///
298/// Returns `FillAdjustmentResult` indicating what adjustments (if any) are needed.
299///
300/// # Panics
301///
302/// This function does not panic under normal circumstances as all unwrap calls are guarded by prior checks.
303#[must_use]
304pub fn adjust_fills_for_partial_window(
305    fills: &[FillSnapshot],
306    venue_position: &VenuePositionSnapshot,
307    _instrument: &InstrumentAny,
308    tolerance: Decimal,
309) -> FillAdjustmentResult {
310    // If no fills, nothing to adjust
311    if fills.is_empty() {
312        return FillAdjustmentResult::NoAdjustment;
313    }
314
315    // If venue position is FLAT, return unchanged
316    if venue_position.qty == Decimal::ZERO {
317        return FillAdjustmentResult::NoAdjustment;
318    }
319
320    // Detect zero-crossings
321    let zero_crossings = detect_zero_crossings(fills);
322
323    // Convert venue position to signed quantity
324    let venue_qty_signed = match venue_position.side {
325        OrderSide::Buy => venue_position.qty,
326        OrderSide::Sell => -venue_position.qty,
327        _ => Decimal::ZERO,
328    };
329
330    // Case 1: Has zero-crossings - focus on current lifecycle after last zero-crossing
331    if !zero_crossings.is_empty() {
332        // Find the last zero-crossing that lands on FLAT (qty==0)
333        // This separates lifecycles; flips within a lifecycle don't count
334        let mut last_flat_crossing_ts = None;
335        let mut running_qty = Decimal::ZERO;
336
337        for fill in fills {
338            let prev_qty = running_qty;
339            running_qty += Decimal::from(fill.direction()) * fill.qty;
340
341            if prev_qty != Decimal::ZERO && running_qty == Decimal::ZERO {
342                last_flat_crossing_ts = Some(fill.ts_event);
343            }
344        }
345
346        let lifecycle_boundary_ts =
347            last_flat_crossing_ts.unwrap_or(*zero_crossings.last().unwrap());
348
349        // Get fills from current lifecycle (after lifecycle boundary)
350        let current_lifecycle_fills: Vec<FillSnapshot> = fills
351            .iter()
352            .filter(|f| f.ts_event > lifecycle_boundary_ts)
353            .cloned()
354            .collect();
355
356        if current_lifecycle_fills.is_empty() {
357            return FillAdjustmentResult::NoAdjustment;
358        }
359
360        // Simulate current lifecycle
361        let (current_qty, current_value) = simulate_position(&current_lifecycle_fills);
362
363        // Check if current lifecycle matches venue
364        if check_position_match(
365            current_qty,
366            current_value,
367            venue_qty_signed,
368            venue_position.avg_px,
369            tolerance,
370        ) {
371            // Current lifecycle matches - filter out old lifecycles
372            return FillAdjustmentResult::FilterToCurrentLifecycle {
373                last_zero_crossing_ts: lifecycle_boundary_ts,
374                current_lifecycle_fills,
375            };
376        }
377
378        // Current lifecycle doesn't match - replace with synthetic fill
379        if let Some(first_fill) = current_lifecycle_fills.first() {
380            let synthetic_fill = FillSnapshot::new(
381                first_fill.ts_event.saturating_sub(1), // Timestamp before first fill
382                venue_position.side,
383                venue_position.qty,
384                venue_position.avg_px,
385                first_fill.venue_order_id,
386            );
387
388            return FillAdjustmentResult::ReplaceCurrentLifecycle {
389                synthetic_fill,
390                first_venue_order_id: first_fill.venue_order_id,
391            };
392        }
393
394        return FillAdjustmentResult::NoAdjustment;
395    }
396
397    // Case 2: Single lifecycle or one zero-crossing
398    // Determine which fills to analyze
399    let oldest_lifecycle_fills: Vec<FillSnapshot> =
400        if let Some(&first_zero_crossing_ts) = zero_crossings.first() {
401            // Get fills before first zero-crossing
402            fills
403                .iter()
404                .filter(|f| f.ts_event <= first_zero_crossing_ts)
405                .cloned()
406                .collect()
407        } else {
408            // No zero-crossings - all fills are in single lifecycle
409            fills.to_vec()
410        };
411
412    if oldest_lifecycle_fills.is_empty() {
413        return FillAdjustmentResult::NoAdjustment;
414    }
415
416    // Simulate oldest lifecycle
417    let (oldest_qty, oldest_value) = simulate_position(&oldest_lifecycle_fills);
418
419    // If single lifecycle (no zero-crossings)
420    if zero_crossings.is_empty() {
421        // Check if simulated position matches venue
422        if check_position_match(
423            oldest_qty,
424            oldest_value,
425            venue_qty_signed,
426            venue_position.avg_px,
427            tolerance,
428        ) {
429            return FillAdjustmentResult::NoAdjustment;
430        }
431
432        // Doesn't match - need to add synthetic opening fill
433        if let Some(first_fill) = oldest_lifecycle_fills.first() {
434            // Calculate what opening fill is needed
435            // Use simulated position as current, venue position as target
436            let oldest_avg_px = if oldest_qty != Decimal::ZERO {
437                Some(oldest_value / oldest_qty.abs())
438            } else {
439                None
440            };
441
442            let reconciliation_price = calculate_reconciliation_price(
443                oldest_qty,
444                oldest_avg_px,
445                venue_qty_signed,
446                Some(venue_position.avg_px),
447            );
448
449            if let Some(opening_px) = reconciliation_price {
450                // Calculate opening quantity needed
451                let opening_qty = if oldest_qty != Decimal::ZERO {
452                    // Work backwards: venue = opening + current fills
453                    venue_qty_signed - oldest_qty
454                } else {
455                    venue_qty_signed
456                };
457
458                if opening_qty.abs() > Decimal::ZERO {
459                    let synthetic_side = if opening_qty > Decimal::ZERO {
460                        OrderSide::Buy
461                    } else {
462                        OrderSide::Sell
463                    };
464
465                    let synthetic_fill = FillSnapshot::new(
466                        first_fill.ts_event.saturating_sub(1),
467                        synthetic_side,
468                        opening_qty.abs(),
469                        opening_px,
470                        first_fill.venue_order_id,
471                    );
472
473                    return FillAdjustmentResult::AddSyntheticOpening {
474                        synthetic_fill,
475                        existing_fills: oldest_lifecycle_fills,
476                    };
477                }
478            }
479        }
480
481        return FillAdjustmentResult::NoAdjustment;
482    }
483
484    // Has one zero-crossing - check if oldest lifecycle closes at zero
485    if oldest_qty == Decimal::ZERO {
486        // Lifecycle closes correctly - no adjustment needed
487        return FillAdjustmentResult::NoAdjustment;
488    }
489
490    // Oldest lifecycle doesn't close at zero - add synthetic opening fill
491    if !oldest_lifecycle_fills.is_empty()
492        && let Some(&first_zero_crossing_ts) = zero_crossings.first()
493    {
494        // Need to add opening fill that makes position close at zero-crossing
495        let current_lifecycle_fills: Vec<FillSnapshot> = fills
496            .iter()
497            .filter(|f| f.ts_event > first_zero_crossing_ts)
498            .cloned()
499            .collect();
500
501        if !current_lifecycle_fills.is_empty()
502            && let Some(first_current_fill) = current_lifecycle_fills.first()
503        {
504            let synthetic_fill = FillSnapshot::new(
505                first_current_fill.ts_event.saturating_sub(1),
506                venue_position.side,
507                venue_position.qty,
508                venue_position.avg_px,
509                first_current_fill.venue_order_id,
510            );
511
512            return FillAdjustmentResult::AddSyntheticOpening {
513                synthetic_fill,
514                existing_fills: oldest_lifecycle_fills,
515            };
516        }
517    }
518
519    FillAdjustmentResult::NoAdjustment
520}
521
522#[cfg(test)]
523mod tests {
524    use nautilus_model::instruments::stubs::audusd_sim;
525    use rstest::{fixture, rstest};
526    use rust_decimal_macros::dec;
527
528    use super::*;
529
530    #[fixture]
531    fn instrument() -> InstrumentAny {
532        InstrumentAny::CurrencyPair(audusd_sim())
533    }
534
535    fn create_test_venue_order_id(value: &str) -> VenueOrderId {
536        VenueOrderId::new(value)
537    }
538
539    #[rstest]
540    fn test_fill_snapshot_direction() {
541        let venue_order_id = create_test_venue_order_id("ORDER1");
542        let buy_fill = FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id);
543        assert_eq!(buy_fill.direction(), 1);
544
545        let sell_fill =
546            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id);
547        assert_eq!(sell_fill.direction(), -1);
548    }
549
550    #[rstest]
551    fn test_simulate_position_accumulate_long() {
552        let venue_order_id = create_test_venue_order_id("ORDER1");
553        let fills = vec![
554            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
555            FillSnapshot::new(2000, OrderSide::Buy, dec!(5), dec!(102), venue_order_id),
556        ];
557
558        let (qty, value) = simulate_position(&fills);
559        assert_eq!(qty, dec!(15));
560        assert_eq!(value, dec!(1510)); // 10*100 + 5*102
561    }
562
563    #[rstest]
564    fn test_simulate_position_close_and_flip() {
565        let venue_order_id = create_test_venue_order_id("ORDER1");
566        let fills = vec![
567            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
568            FillSnapshot::new(2000, OrderSide::Sell, dec!(15), dec!(102), venue_order_id),
569        ];
570
571        let (qty, value) = simulate_position(&fills);
572        assert_eq!(qty, dec!(-5)); // Flipped from +10 to -5
573        assert_eq!(value, dec!(510)); // Remaining 5 @ 102
574    }
575
576    #[rstest]
577    fn test_simulate_position_partial_close() {
578        let venue_order_id = create_test_venue_order_id("ORDER1");
579        let fills = vec![
580            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
581            FillSnapshot::new(2000, OrderSide::Sell, dec!(5), dec!(102), venue_order_id),
582        ];
583
584        let (qty, value) = simulate_position(&fills);
585        assert_eq!(qty, dec!(5));
586        assert_eq!(value, dec!(500)); // Reduced proportionally: 1000 * (1 - 5/10) = 500
587
588        // Verify average price is maintained
589        let avg_px = value / qty;
590        assert_eq!(avg_px, dec!(100));
591    }
592
593    #[rstest]
594    fn test_simulate_position_multiple_partial_closes() {
595        let venue_order_id = create_test_venue_order_id("ORDER1");
596        let fills = vec![
597            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(10.0), venue_order_id),
598            FillSnapshot::new(2000, OrderSide::Sell, dec!(25), dec!(11.0), venue_order_id), // Close 25%
599            FillSnapshot::new(3000, OrderSide::Sell, dec!(25), dec!(12.0), venue_order_id), // Close another 25%
600        ];
601
602        let (qty, value) = simulate_position(&fills);
603        assert_eq!(qty, dec!(50));
604        // After first close: value = 1000 * (1 - 25/100) = 1000 * 0.75 = 750
605        // After second close: value = 750 * (1 - 25/75) = 750 * (50/75) = 500
606        // Due to decimal precision, we check it's close to 500
607        assert!((value - dec!(500)).abs() < dec!(0.01));
608
609        // Verify average price is maintained at 10.0
610        let avg_px = value / qty;
611        assert!((avg_px - dec!(10.0)).abs() < dec!(0.01));
612    }
613
614    #[rstest]
615    fn test_simulate_position_short_partial_close() {
616        let venue_order_id = create_test_venue_order_id("ORDER1");
617        let fills = vec![
618            FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
619            FillSnapshot::new(2000, OrderSide::Buy, dec!(5), dec!(98), venue_order_id), // Partial close
620        ];
621
622        let (qty, value) = simulate_position(&fills);
623        assert_eq!(qty, dec!(-5));
624        assert_eq!(value, dec!(500)); // Reduced proportionally: 1000 * (1 - 5/10) = 500
625
626        // Verify average price is maintained
627        let avg_px = value / qty.abs();
628        assert_eq!(avg_px, dec!(100));
629    }
630
631    #[rstest]
632    fn test_detect_zero_crossings() {
633        let venue_order_id = create_test_venue_order_id("ORDER1");
634        let fills = vec![
635            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
636            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), venue_order_id), // Close to zero
637            FillSnapshot::new(3000, OrderSide::Buy, dec!(5), dec!(103), venue_order_id),
638            FillSnapshot::new(4000, OrderSide::Sell, dec!(5), dec!(104), venue_order_id), // Close to zero again
639        ];
640
641        let crossings = detect_zero_crossings(&fills);
642        assert_eq!(crossings.len(), 2);
643        assert_eq!(crossings[0], 2000);
644        assert_eq!(crossings[1], 4000);
645    }
646
647    #[rstest]
648    fn test_check_position_match_exact() {
649        let result = check_position_match(dec!(10), dec!(1000), dec!(10), dec!(100), dec!(0.0001));
650        assert!(result);
651    }
652
653    #[rstest]
654    fn test_check_position_match_within_tolerance() {
655        // Simulated avg px = 1000/10 = 100, venue = 100.005
656        // Relative diff = 0.005 / 100.005 = 0.00004999 < 0.0001
657        let result =
658            check_position_match(dec!(10), dec!(1000), dec!(10), dec!(100.005), dec!(0.0001));
659        assert!(result);
660    }
661
662    #[rstest]
663    fn test_check_position_match_qty_mismatch() {
664        let result = check_position_match(dec!(10), dec!(1000), dec!(11), dec!(100), dec!(0.0001));
665        assert!(!result);
666    }
667
668    #[rstest]
669    fn test_check_position_match_both_flat() {
670        let result = check_position_match(dec!(0), dec!(0), dec!(0), dec!(0), dec!(0.0001));
671        assert!(result);
672    }
673
674    #[rstest]
675    fn test_reconciliation_price_flat_to_long(_instrument: InstrumentAny) {
676        let result = calculate_reconciliation_price(dec!(0), None, dec!(10), Some(dec!(100)));
677        assert!(result.is_some());
678        assert_eq!(result.unwrap(), dec!(100));
679    }
680
681    #[rstest]
682    fn test_reconciliation_price_no_target_avg_px(_instrument: InstrumentAny) {
683        let result = calculate_reconciliation_price(dec!(5), Some(dec!(100)), dec!(10), None);
684        assert!(result.is_none());
685    }
686
687    #[rstest]
688    fn test_reconciliation_price_no_quantity_change(_instrument: InstrumentAny) {
689        let result =
690            calculate_reconciliation_price(dec!(10), Some(dec!(100)), dec!(10), Some(dec!(105)));
691        assert!(result.is_none());
692    }
693
694    #[rstest]
695    fn test_reconciliation_price_long_position_increase(_instrument: InstrumentAny) {
696        let result =
697            calculate_reconciliation_price(dec!(10), Some(dec!(100)), dec!(15), Some(dec!(102)));
698        assert!(result.is_some());
699        // Expected: (15 * 102 - 10 * 100) / 5 = (1530 - 1000) / 5 = 106
700        assert_eq!(result.unwrap(), dec!(106));
701    }
702
703    #[rstest]
704    fn test_reconciliation_price_flat_to_short(_instrument: InstrumentAny) {
705        let result = calculate_reconciliation_price(dec!(0), None, dec!(-10), Some(dec!(100)));
706        assert!(result.is_some());
707        assert_eq!(result.unwrap(), dec!(100));
708    }
709
710    #[rstest]
711    fn test_reconciliation_price_long_to_flat(_instrument: InstrumentAny) {
712        // Close long position to flat: 100 @ 1.20 to 0
713        // When closing to flat, reconciliation price equals current average price
714        let result =
715            calculate_reconciliation_price(dec!(100), Some(dec!(1.20)), dec!(0), Some(dec!(0)));
716        assert!(result.is_some());
717        assert_eq!(result.unwrap(), dec!(1.20));
718    }
719
720    #[rstest]
721    fn test_reconciliation_price_short_to_flat(_instrument: InstrumentAny) {
722        // Close short position to flat: -50 @ 2.50 to 0
723        // When closing to flat, reconciliation price equals current average price
724        let result = calculate_reconciliation_price(dec!(-50), Some(dec!(2.50)), dec!(0), None);
725        assert!(result.is_some());
726        assert_eq!(result.unwrap(), dec!(2.50));
727    }
728
729    #[rstest]
730    fn test_reconciliation_price_short_position_increase(_instrument: InstrumentAny) {
731        // Short position increase: -100 @ 1.30 to -200 @ 1.28
732        // (−200 × 1.28) = (−100 × 1.30) + (−100 × reconciliation_px)
733        // −256 = −130 + (−100 × reconciliation_px)
734        // reconciliation_px = 1.26
735        let result = calculate_reconciliation_price(
736            dec!(-100),
737            Some(dec!(1.30)),
738            dec!(-200),
739            Some(dec!(1.28)),
740        );
741        assert!(result.is_some());
742        assert_eq!(result.unwrap(), dec!(1.26));
743    }
744
745    #[rstest]
746    fn test_reconciliation_price_long_position_decrease(_instrument: InstrumentAny) {
747        // Long position decrease: 200 @ 1.20 to 100 @ 1.20
748        let result = calculate_reconciliation_price(
749            dec!(200),
750            Some(dec!(1.20)),
751            dec!(100),
752            Some(dec!(1.20)),
753        );
754        assert!(result.is_some());
755        assert_eq!(result.unwrap(), dec!(1.20));
756    }
757
758    #[rstest]
759    fn test_reconciliation_price_long_to_short_flip(_instrument: InstrumentAny) {
760        // Long to short flip: 100 @ 1.20 to -100 @ 1.25
761        // Due to netting simulation resetting value on flip, reconciliation_px = target_avg_px
762        let result = calculate_reconciliation_price(
763            dec!(100),
764            Some(dec!(1.20)),
765            dec!(-100),
766            Some(dec!(1.25)),
767        );
768        assert!(result.is_some());
769        assert_eq!(result.unwrap(), dec!(1.25));
770    }
771
772    #[rstest]
773    fn test_reconciliation_price_short_to_long_flip(_instrument: InstrumentAny) {
774        // Short to long flip: -100 @ 1.30 to 100 @ 1.25
775        // Due to netting simulation resetting value on flip, reconciliation_px = target_avg_px
776        let result = calculate_reconciliation_price(
777            dec!(-100),
778            Some(dec!(1.30)),
779            dec!(100),
780            Some(dec!(1.25)),
781        );
782        assert!(result.is_some());
783        assert_eq!(result.unwrap(), dec!(1.25));
784    }
785
786    #[rstest]
787    fn test_reconciliation_price_complex_scenario(_instrument: InstrumentAny) {
788        // Complex: 150 @ 1.23456 to 250 @ 1.24567
789        // (250 × 1.24567) = (150 × 1.23456) + (100 × reconciliation_px)
790        // 311.4175 = 185.184 + (100 × reconciliation_px)
791        // reconciliation_px = 1.262335
792        let result = calculate_reconciliation_price(
793            dec!(150),
794            Some(dec!(1.23456)),
795            dec!(250),
796            Some(dec!(1.24567)),
797        );
798        assert!(result.is_some());
799        assert_eq!(result.unwrap(), dec!(1.262335));
800    }
801
802    #[rstest]
803    fn test_reconciliation_price_zero_target_avg_px(_instrument: InstrumentAny) {
804        let result =
805            calculate_reconciliation_price(dec!(100), Some(dec!(1.20)), dec!(200), Some(dec!(0)));
806        assert!(result.is_none());
807    }
808
809    #[rstest]
810    fn test_reconciliation_price_negative_price(_instrument: InstrumentAny) {
811        // Negative price calculation: 100 @ 2.00 to 200 @ 1.00
812        // (200 × 1.00) = (100 × 2.00) + (100 × reconciliation_px)
813        // 200 = 200 + (100 × reconciliation_px)
814        // reconciliation_px = 0 (should return None as price must be positive)
815        let result = calculate_reconciliation_price(
816            dec!(100),
817            Some(dec!(2.00)),
818            dec!(200),
819            Some(dec!(1.00)),
820        );
821        assert!(result.is_none());
822    }
823
824    #[rstest]
825    fn test_reconciliation_price_flip_simulation_compatibility() {
826        let venue_order_id = create_test_venue_order_id("ORDER1");
827        // Start with long position: 100 @ 1.20
828        // Target: -100 @ 1.25
829        // Calculate reconciliation price
830        let recon_px = calculate_reconciliation_price(
831            dec!(100),
832            Some(dec!(1.20)),
833            dec!(-100),
834            Some(dec!(1.25)),
835        )
836        .expect("reconciliation price");
837
838        assert_eq!(recon_px, dec!(1.25));
839
840        // Simulate the flip with reconciliation fill (sell 200 to go from +100 to -100)
841        let fills = vec![
842            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
843            FillSnapshot::new(2000, OrderSide::Sell, dec!(200), recon_px, venue_order_id),
844        ];
845
846        let (final_qty, final_value) = simulate_position(&fills);
847        assert_eq!(final_qty, dec!(-100));
848        let final_avg = final_value / final_qty.abs();
849        assert_eq!(final_avg, dec!(1.25), "Final average should match target");
850    }
851
852    #[rstest]
853    fn test_reconciliation_price_accumulation_simulation_compatibility() {
854        let venue_order_id = create_test_venue_order_id("ORDER1");
855        // Start with long position: 100 @ 1.20
856        // Target: 200 @ 1.22
857        let recon_px = calculate_reconciliation_price(
858            dec!(100),
859            Some(dec!(1.20)),
860            dec!(200),
861            Some(dec!(1.22)),
862        )
863        .expect("reconciliation price");
864
865        // Simulate accumulation with reconciliation fill
866        let fills = vec![
867            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
868            FillSnapshot::new(2000, OrderSide::Buy, dec!(100), recon_px, venue_order_id),
869        ];
870
871        let (final_qty, final_value) = simulate_position(&fills);
872        assert_eq!(final_qty, dec!(200));
873        let final_avg = final_value / final_qty.abs();
874        assert_eq!(final_avg, dec!(1.22), "Final average should match target");
875    }
876
877    #[rstest]
878    fn test_simulate_position_accumulate_short() {
879        let venue_order_id = create_test_venue_order_id("ORDER1");
880        let fills = vec![
881            FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
882            FillSnapshot::new(2000, OrderSide::Sell, dec!(5), dec!(98), venue_order_id),
883        ];
884
885        let (qty, value) = simulate_position(&fills);
886        assert_eq!(qty, dec!(-15));
887        assert_eq!(value, dec!(1490)); // 10*100 + 5*98
888    }
889
890    #[rstest]
891    fn test_simulate_position_short_to_long_flip() {
892        let venue_order_id = create_test_venue_order_id("ORDER1");
893        let fills = vec![
894            FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
895            FillSnapshot::new(2000, OrderSide::Buy, dec!(15), dec!(102), venue_order_id),
896        ];
897
898        let (qty, value) = simulate_position(&fills);
899        assert_eq!(qty, dec!(5)); // Flipped from -10 to +5
900        assert_eq!(value, dec!(510)); // Remaining 5 @ 102
901    }
902
903    #[rstest]
904    fn test_simulate_position_multiple_flips() {
905        let venue_order_id = create_test_venue_order_id("ORDER1");
906        let fills = vec![
907            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
908            FillSnapshot::new(2000, OrderSide::Sell, dec!(15), dec!(105), venue_order_id), // Flip to -5
909            FillSnapshot::new(3000, OrderSide::Buy, dec!(10), dec!(110), venue_order_id), // Flip to +5
910        ];
911
912        let (qty, value) = simulate_position(&fills);
913        assert_eq!(qty, dec!(5)); // Final position: +5
914        assert_eq!(value, dec!(550)); // 5 @ 110
915    }
916
917    #[rstest]
918    fn test_simulate_position_empty_fills() {
919        let fills: Vec<FillSnapshot> = vec![];
920        let (qty, value) = simulate_position(&fills);
921        assert_eq!(qty, dec!(0));
922        assert_eq!(value, dec!(0));
923    }
924
925    #[rstest]
926    fn test_detect_zero_crossings_no_crossings() {
927        let venue_order_id = create_test_venue_order_id("ORDER1");
928        let fills = vec![
929            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
930            FillSnapshot::new(2000, OrderSide::Buy, dec!(5), dec!(102), venue_order_id),
931        ];
932
933        let crossings = detect_zero_crossings(&fills);
934        assert_eq!(crossings.len(), 0);
935    }
936
937    #[rstest]
938    fn test_detect_zero_crossings_single_crossing() {
939        let venue_order_id = create_test_venue_order_id("ORDER1");
940        let fills = vec![
941            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
942            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), venue_order_id), // Close to zero
943        ];
944
945        let crossings = detect_zero_crossings(&fills);
946        assert_eq!(crossings.len(), 1);
947        assert_eq!(crossings[0], 2000);
948    }
949
950    #[rstest]
951    fn test_detect_zero_crossings_empty_fills() {
952        let fills: Vec<FillSnapshot> = vec![];
953        let crossings = detect_zero_crossings(&fills);
954        assert_eq!(crossings.len(), 0);
955    }
956
957    #[rstest]
958    fn test_detect_zero_crossings_long_to_short_flip() {
959        let venue_order_id = create_test_venue_order_id("ORDER1");
960        // Buy 10, then Sell 15 -> flip from +10 to -5
961        let fills = vec![
962            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
963            FillSnapshot::new(2000, OrderSide::Sell, dec!(15), dec!(102), venue_order_id), // Flip
964        ];
965
966        let crossings = detect_zero_crossings(&fills);
967        assert_eq!(crossings.len(), 1);
968        assert_eq!(crossings[0], 2000); // Detected the flip
969    }
970
971    #[rstest]
972    fn test_detect_zero_crossings_short_to_long_flip() {
973        let venue_order_id = create_test_venue_order_id("ORDER1");
974        // Sell 10, then Buy 20 -> flip from -10 to +10
975        let fills = vec![
976            FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
977            FillSnapshot::new(2000, OrderSide::Buy, dec!(20), dec!(102), venue_order_id), // Flip
978        ];
979
980        let crossings = detect_zero_crossings(&fills);
981        assert_eq!(crossings.len(), 1);
982        assert_eq!(crossings[0], 2000);
983    }
984
985    #[rstest]
986    fn test_detect_zero_crossings_multiple_flips() {
987        let venue_order_id = create_test_venue_order_id("ORDER1");
988        let fills = vec![
989            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
990            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), venue_order_id), // Land on zero
991            FillSnapshot::new(3000, OrderSide::Sell, dec!(5), dec!(103), venue_order_id), // Go short
992            FillSnapshot::new(4000, OrderSide::Buy, dec!(15), dec!(104), venue_order_id), // Flip to long
993        ];
994
995        let crossings = detect_zero_crossings(&fills);
996        assert_eq!(crossings.len(), 2);
997        assert_eq!(crossings[0], 2000); // First zero-crossing (land on zero)
998        assert_eq!(crossings[1], 4000); // Second zero-crossing (flip)
999    }
1000
1001    #[rstest]
1002    fn test_check_position_match_outside_tolerance() {
1003        // Simulated avg px = 1000/10 = 100, venue = 101
1004        // Relative diff = 1 / 101 = 0.0099 > 0.0001
1005        let result = check_position_match(dec!(10), dec!(1000), dec!(10), dec!(101), dec!(0.0001));
1006        assert!(!result);
1007    }
1008
1009    #[rstest]
1010    fn test_check_position_match_edge_of_tolerance() {
1011        // Simulated avg px = 1000/10 = 100, venue = 100.01
1012        // Relative diff = 0.01 / 100.01 = 0.00009999 < 0.0001
1013        let result =
1014            check_position_match(dec!(10), dec!(1000), dec!(10), dec!(100.01), dec!(0.0001));
1015        assert!(result);
1016    }
1017
1018    #[rstest]
1019    fn test_check_position_match_zero_venue_avg_px() {
1020        let result = check_position_match(dec!(10), dec!(1000), dec!(10), dec!(0), dec!(0.0001));
1021        assert!(!result); // Should fail because relative diff calculation with zero denominator
1022    }
1023
1024    #[rstest]
1025    fn test_adjust_fills_no_fills() {
1026        let venue_position = VenuePositionSnapshot {
1027            side: OrderSide::Buy,
1028            qty: dec!(0.02),
1029            avg_px: dec!(4100.00),
1030        };
1031        let instrument = instrument();
1032        let result =
1033            adjust_fills_for_partial_window(&[], &venue_position, &instrument, dec!(0.0001));
1034        assert!(matches!(result, FillAdjustmentResult::NoAdjustment));
1035    }
1036
1037    #[rstest]
1038    fn test_adjust_fills_flat_position() {
1039        let venue_order_id = create_test_venue_order_id("ORDER1");
1040        let fills = vec![FillSnapshot::new(
1041            1000,
1042            OrderSide::Buy,
1043            dec!(0.01),
1044            dec!(4100.00),
1045            venue_order_id,
1046        )];
1047        let venue_position = VenuePositionSnapshot {
1048            side: OrderSide::Buy,
1049            qty: dec!(0),
1050            avg_px: dec!(0),
1051        };
1052        let instrument = instrument();
1053        let result =
1054            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1055        assert!(matches!(result, FillAdjustmentResult::NoAdjustment));
1056    }
1057
1058    #[rstest]
1059    fn test_adjust_fills_complete_lifecycle_no_adjustment() {
1060        let venue_order_id = create_test_venue_order_id("ORDER1");
1061        let venue_order_id2 = create_test_venue_order_id("ORDER2");
1062        let fills = vec![
1063            FillSnapshot::new(
1064                1000,
1065                OrderSide::Buy,
1066                dec!(0.01),
1067                dec!(4100.00),
1068                venue_order_id,
1069            ),
1070            FillSnapshot::new(
1071                2000,
1072                OrderSide::Buy,
1073                dec!(0.01),
1074                dec!(4100.00),
1075                venue_order_id2,
1076            ),
1077        ];
1078        let venue_position = VenuePositionSnapshot {
1079            side: OrderSide::Buy,
1080            qty: dec!(0.02),
1081            avg_px: dec!(4100.00),
1082        };
1083        let instrument = instrument();
1084        let result =
1085            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1086        assert!(matches!(result, FillAdjustmentResult::NoAdjustment));
1087    }
1088
1089    #[rstest]
1090    fn test_adjust_fills_incomplete_lifecycle_adds_synthetic() {
1091        let venue_order_id = create_test_venue_order_id("ORDER1");
1092        // Window only sees +0.02 @ 4200, but venue has 0.04 @ 4100
1093        let fills = vec![FillSnapshot::new(
1094            2000,
1095            OrderSide::Buy,
1096            dec!(0.02),
1097            dec!(4200.00),
1098            venue_order_id,
1099        )];
1100        let venue_position = VenuePositionSnapshot {
1101            side: OrderSide::Buy,
1102            qty: dec!(0.04),
1103            avg_px: dec!(4100.00),
1104        };
1105        let instrument = instrument();
1106        let result =
1107            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1108
1109        match result {
1110            FillAdjustmentResult::AddSyntheticOpening {
1111                synthetic_fill,
1112                existing_fills,
1113            } => {
1114                assert_eq!(synthetic_fill.side, OrderSide::Buy);
1115                assert_eq!(synthetic_fill.qty, dec!(0.02)); // Missing 0.02
1116                assert_eq!(existing_fills.len(), 1);
1117            }
1118            _ => panic!("Expected AddSyntheticOpening"),
1119        }
1120    }
1121
1122    #[rstest]
1123    fn test_adjust_fills_with_zero_crossings() {
1124        let venue_order_id1 = create_test_venue_order_id("ORDER1");
1125        let venue_order_id2 = create_test_venue_order_id("ORDER2");
1126        let venue_order_id3 = create_test_venue_order_id("ORDER3");
1127
1128        // Lifecycle 1: LONG 0.02 -> FLAT (zero-crossing at 2000)
1129        // Lifecycle 2: LONG 0.03 (current)
1130        let fills = vec![
1131            FillSnapshot::new(
1132                1000,
1133                OrderSide::Buy,
1134                dec!(0.02),
1135                dec!(4100.00),
1136                venue_order_id1,
1137            ),
1138            FillSnapshot::new(
1139                2000,
1140                OrderSide::Sell,
1141                dec!(0.02),
1142                dec!(4150.00),
1143                venue_order_id2,
1144            ), // Zero-crossing
1145            FillSnapshot::new(
1146                3000,
1147                OrderSide::Buy,
1148                dec!(0.03),
1149                dec!(4200.00),
1150                venue_order_id3,
1151            ), // Current lifecycle
1152        ];
1153
1154        let venue_position = VenuePositionSnapshot {
1155            side: OrderSide::Buy,
1156            qty: dec!(0.03),
1157            avg_px: dec!(4200.00),
1158        };
1159
1160        let instrument = instrument();
1161        let result =
1162            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1163
1164        // Should filter to current lifecycle only
1165        match result {
1166            FillAdjustmentResult::FilterToCurrentLifecycle {
1167                last_zero_crossing_ts,
1168                current_lifecycle_fills,
1169            } => {
1170                assert_eq!(last_zero_crossing_ts, 2000);
1171                assert_eq!(current_lifecycle_fills.len(), 1);
1172                assert_eq!(current_lifecycle_fills[0].venue_order_id, venue_order_id3);
1173            }
1174            _ => panic!("Expected FilterToCurrentLifecycle, was {result:?}"),
1175        }
1176    }
1177
1178    #[rstest]
1179    fn test_adjust_fills_multiple_zero_crossings_mismatch() {
1180        let venue_order_id1 = create_test_venue_order_id("ORDER1");
1181        let venue_order_id2 = create_test_venue_order_id("ORDER2");
1182        let _venue_order_id3 = create_test_venue_order_id("ORDER3");
1183        let venue_order_id4 = create_test_venue_order_id("ORDER4");
1184        let venue_order_id5 = create_test_venue_order_id("ORDER5");
1185
1186        // Lifecycle 1: LONG 0.05 -> FLAT
1187        // Lifecycle 2: Current fills produce 0.10 @ 4050, but venue has 0.05 @ 4142.04
1188        let fills = vec![
1189            FillSnapshot::new(
1190                1000,
1191                OrderSide::Buy,
1192                dec!(0.05),
1193                dec!(4000.00),
1194                venue_order_id1,
1195            ),
1196            FillSnapshot::new(
1197                2000,
1198                OrderSide::Sell,
1199                dec!(0.05),
1200                dec!(4050.00),
1201                venue_order_id2,
1202            ), // Zero-crossing
1203            FillSnapshot::new(
1204                3000,
1205                OrderSide::Buy,
1206                dec!(0.05),
1207                dec!(4000.00),
1208                venue_order_id4,
1209            ), // Current lifecycle
1210            FillSnapshot::new(
1211                4000,
1212                OrderSide::Buy,
1213                dec!(0.05),
1214                dec!(4100.00),
1215                venue_order_id5,
1216            ), // Current lifecycle
1217        ];
1218
1219        let venue_position = VenuePositionSnapshot {
1220            side: OrderSide::Buy,
1221            qty: dec!(0.05),
1222            avg_px: dec!(4142.04),
1223        };
1224
1225        let instrument = instrument();
1226        let result =
1227            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1228
1229        // Should replace current lifecycle with synthetic
1230        match result {
1231            FillAdjustmentResult::ReplaceCurrentLifecycle {
1232                synthetic_fill,
1233                first_venue_order_id,
1234            } => {
1235                assert_eq!(synthetic_fill.qty, dec!(0.05));
1236                assert_eq!(synthetic_fill.px, dec!(4142.04));
1237                assert_eq!(synthetic_fill.side, OrderSide::Buy);
1238                assert_eq!(first_venue_order_id, venue_order_id4);
1239            }
1240            _ => panic!("Expected ReplaceCurrentLifecycle, was {result:?}"),
1241        }
1242    }
1243
1244    #[rstest]
1245    fn test_adjust_fills_short_position() {
1246        let venue_order_id = create_test_venue_order_id("ORDER1");
1247
1248        // Window only sees SELL 0.02 @ 4120, but venue has -0.05 @ 4100
1249        let fills = vec![FillSnapshot::new(
1250            1000,
1251            OrderSide::Sell,
1252            dec!(0.02),
1253            dec!(4120.00),
1254            venue_order_id,
1255        )];
1256
1257        let venue_position = VenuePositionSnapshot {
1258            side: OrderSide::Sell,
1259            qty: dec!(0.05),
1260            avg_px: dec!(4100.00),
1261        };
1262
1263        let instrument = instrument();
1264        let result =
1265            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1266
1267        // Should add synthetic opening SHORT fill
1268        match result {
1269            FillAdjustmentResult::AddSyntheticOpening {
1270                synthetic_fill,
1271                existing_fills,
1272            } => {
1273                assert_eq!(synthetic_fill.side, OrderSide::Sell);
1274                assert_eq!(synthetic_fill.qty, dec!(0.03)); // Missing 0.03
1275                assert_eq!(existing_fills.len(), 1);
1276            }
1277            _ => panic!("Expected AddSyntheticOpening, was {result:?}"),
1278        }
1279    }
1280
1281    #[rstest]
1282    fn test_adjust_fills_timestamp_underflow_protection() {
1283        let venue_order_id = create_test_venue_order_id("ORDER1");
1284
1285        // First fill at timestamp 0 - saturating_sub should prevent underflow
1286        let fills = vec![FillSnapshot::new(
1287            0,
1288            OrderSide::Buy,
1289            dec!(0.01),
1290            dec!(4100.00),
1291            venue_order_id,
1292        )];
1293
1294        let venue_position = VenuePositionSnapshot {
1295            side: OrderSide::Buy,
1296            qty: dec!(0.02),
1297            avg_px: dec!(4100.00),
1298        };
1299
1300        let instrument = instrument();
1301        let result =
1302            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1303
1304        // Should add synthetic fill with timestamp 0 (not u64::MAX)
1305        match result {
1306            FillAdjustmentResult::AddSyntheticOpening { synthetic_fill, .. } => {
1307                assert_eq!(synthetic_fill.ts_event, 0); // saturating_sub(1) from 0 = 0
1308            }
1309            _ => panic!("Expected AddSyntheticOpening, was {result:?}"),
1310        }
1311    }
1312
1313    #[rstest]
1314    fn test_adjust_fills_with_flip_scenario() {
1315        let venue_order_id1 = create_test_venue_order_id("ORDER1");
1316        let venue_order_id2 = create_test_venue_order_id("ORDER2");
1317
1318        // Long 10 @ 100, then Sell 20 @ 105 -> flip to Short 10 @ 105
1319        let fills = vec![
1320            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id1),
1321            FillSnapshot::new(2000, OrderSide::Sell, dec!(20), dec!(105), venue_order_id2), // Flip
1322        ];
1323
1324        let venue_position = VenuePositionSnapshot {
1325            side: OrderSide::Sell,
1326            qty: dec!(10),
1327            avg_px: dec!(105),
1328        };
1329
1330        let instrument = instrument();
1331        let result =
1332            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1333
1334        // Should recognize the flip and match correctly
1335        match result {
1336            FillAdjustmentResult::NoAdjustment => {
1337                // Verify simulation matches
1338                let (qty, value) = simulate_position(&fills);
1339                assert_eq!(qty, dec!(-10));
1340                let avg = value / qty.abs();
1341                assert_eq!(avg, dec!(105));
1342            }
1343            _ => panic!("Expected NoAdjustment for matching flip, was {result:?}"),
1344        }
1345    }
1346
1347    #[rstest]
1348    fn test_detect_zero_crossings_complex_lifecycle() {
1349        let venue_order_id = create_test_venue_order_id("ORDER1");
1350        // Complex scenario with multiple lifecycles
1351        let fills = vec![
1352            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
1353            FillSnapshot::new(2000, OrderSide::Sell, dec!(50), dec!(1.25), venue_order_id), // Reduce
1354            FillSnapshot::new(3000, OrderSide::Sell, dec!(100), dec!(1.30), venue_order_id), // Flip to -50
1355            FillSnapshot::new(4000, OrderSide::Buy, dec!(50), dec!(1.28), venue_order_id), // Close to zero
1356            FillSnapshot::new(5000, OrderSide::Buy, dec!(75), dec!(1.22), venue_order_id), // Open long
1357            FillSnapshot::new(6000, OrderSide::Sell, dec!(150), dec!(1.24), venue_order_id), // Flip to -75
1358        ];
1359
1360        let crossings = detect_zero_crossings(&fills);
1361        assert_eq!(crossings.len(), 3);
1362        assert_eq!(crossings[0], 3000); // First flip
1363        assert_eq!(crossings[1], 4000); // Close to zero
1364        assert_eq!(crossings[2], 6000); // Second flip
1365    }
1366
1367    #[rstest]
1368    fn test_reconciliation_price_partial_close() {
1369        let venue_order_id = create_test_venue_order_id("ORDER1");
1370        // Partial close scenario: 100 @ 1.20 to 50 @ 1.20
1371        let recon_px =
1372            calculate_reconciliation_price(dec!(100), Some(dec!(1.20)), dec!(50), Some(dec!(1.20)))
1373                .expect("reconciliation price");
1374
1375        // Simulate partial close
1376        let fills = vec![
1377            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
1378            FillSnapshot::new(2000, OrderSide::Sell, dec!(50), recon_px, venue_order_id),
1379        ];
1380
1381        let (final_qty, final_value) = simulate_position(&fills);
1382        assert_eq!(final_qty, dec!(50));
1383        let final_avg = final_value / final_qty.abs();
1384        assert_eq!(final_avg, dec!(1.20), "Average should be maintained");
1385    }
1386}