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////////////////////////////////////////////////////////////////////////////////
523// Tests
524////////////////////////////////////////////////////////////////////////////////
525
526#[cfg(test)]
527mod tests {
528    use nautilus_model::instruments::stubs::audusd_sim;
529    use rstest::{fixture, rstest};
530    use rust_decimal_macros::dec;
531
532    use super::*;
533
534    #[fixture]
535    fn instrument() -> InstrumentAny {
536        InstrumentAny::CurrencyPair(audusd_sim())
537    }
538
539    fn create_test_venue_order_id(value: &str) -> VenueOrderId {
540        VenueOrderId::new(value)
541    }
542
543    #[rstest]
544    fn test_fill_snapshot_direction() {
545        let venue_order_id = create_test_venue_order_id("ORDER1");
546        let buy_fill = FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id);
547        assert_eq!(buy_fill.direction(), 1);
548
549        let sell_fill =
550            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id);
551        assert_eq!(sell_fill.direction(), -1);
552    }
553
554    #[rstest]
555    fn test_simulate_position_accumulate_long() {
556        let venue_order_id = create_test_venue_order_id("ORDER1");
557        let fills = vec![
558            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
559            FillSnapshot::new(2000, OrderSide::Buy, dec!(5), dec!(102), venue_order_id),
560        ];
561
562        let (qty, value) = simulate_position(&fills);
563        assert_eq!(qty, dec!(15));
564        assert_eq!(value, dec!(1510)); // 10*100 + 5*102
565    }
566
567    #[rstest]
568    fn test_simulate_position_close_and_flip() {
569        let venue_order_id = create_test_venue_order_id("ORDER1");
570        let fills = vec![
571            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
572            FillSnapshot::new(2000, OrderSide::Sell, dec!(15), dec!(102), venue_order_id),
573        ];
574
575        let (qty, value) = simulate_position(&fills);
576        assert_eq!(qty, dec!(-5)); // Flipped from +10 to -5
577        assert_eq!(value, dec!(510)); // Remaining 5 @ 102
578    }
579
580    #[rstest]
581    fn test_simulate_position_partial_close() {
582        let venue_order_id = create_test_venue_order_id("ORDER1");
583        let fills = vec![
584            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
585            FillSnapshot::new(2000, OrderSide::Sell, dec!(5), dec!(102), venue_order_id),
586        ];
587
588        let (qty, value) = simulate_position(&fills);
589        assert_eq!(qty, dec!(5));
590        assert_eq!(value, dec!(500)); // Reduced proportionally: 1000 * (1 - 5/10) = 500
591
592        // Verify average price is maintained
593        let avg_px = value / qty;
594        assert_eq!(avg_px, dec!(100));
595    }
596
597    #[rstest]
598    fn test_simulate_position_multiple_partial_closes() {
599        let venue_order_id = create_test_venue_order_id("ORDER1");
600        let fills = vec![
601            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(10.0), venue_order_id),
602            FillSnapshot::new(2000, OrderSide::Sell, dec!(25), dec!(11.0), venue_order_id), // Close 25%
603            FillSnapshot::new(3000, OrderSide::Sell, dec!(25), dec!(12.0), venue_order_id), // Close another 25%
604        ];
605
606        let (qty, value) = simulate_position(&fills);
607        assert_eq!(qty, dec!(50));
608        // After first close: value = 1000 * (1 - 25/100) = 1000 * 0.75 = 750
609        // After second close: value = 750 * (1 - 25/75) = 750 * (50/75) = 500
610        // Due to decimal precision, we check it's close to 500
611        assert!((value - dec!(500)).abs() < dec!(0.01));
612
613        // Verify average price is maintained at 10.0
614        let avg_px = value / qty;
615        assert!((avg_px - dec!(10.0)).abs() < dec!(0.01));
616    }
617
618    #[rstest]
619    fn test_simulate_position_short_partial_close() {
620        let venue_order_id = create_test_venue_order_id("ORDER1");
621        let fills = vec![
622            FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
623            FillSnapshot::new(2000, OrderSide::Buy, dec!(5), dec!(98), venue_order_id), // Partial close
624        ];
625
626        let (qty, value) = simulate_position(&fills);
627        assert_eq!(qty, dec!(-5));
628        assert_eq!(value, dec!(500)); // Reduced proportionally: 1000 * (1 - 5/10) = 500
629
630        // Verify average price is maintained
631        let avg_px = value / qty.abs();
632        assert_eq!(avg_px, dec!(100));
633    }
634
635    #[rstest]
636    fn test_detect_zero_crossings() {
637        let venue_order_id = create_test_venue_order_id("ORDER1");
638        let fills = vec![
639            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
640            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), venue_order_id), // Close to zero
641            FillSnapshot::new(3000, OrderSide::Buy, dec!(5), dec!(103), venue_order_id),
642            FillSnapshot::new(4000, OrderSide::Sell, dec!(5), dec!(104), venue_order_id), // Close to zero again
643        ];
644
645        let crossings = detect_zero_crossings(&fills);
646        assert_eq!(crossings.len(), 2);
647        assert_eq!(crossings[0], 2000);
648        assert_eq!(crossings[1], 4000);
649    }
650
651    #[rstest]
652    fn test_check_position_match_exact() {
653        let result = check_position_match(dec!(10), dec!(1000), dec!(10), dec!(100), dec!(0.0001));
654        assert!(result);
655    }
656
657    #[rstest]
658    fn test_check_position_match_within_tolerance() {
659        // Simulated avg px = 1000/10 = 100, venue = 100.005
660        // Relative diff = 0.005 / 100.005 = 0.00004999 < 0.0001
661        let result =
662            check_position_match(dec!(10), dec!(1000), dec!(10), dec!(100.005), dec!(0.0001));
663        assert!(result);
664    }
665
666    #[rstest]
667    fn test_check_position_match_qty_mismatch() {
668        let result = check_position_match(dec!(10), dec!(1000), dec!(11), dec!(100), dec!(0.0001));
669        assert!(!result);
670    }
671
672    #[rstest]
673    fn test_check_position_match_both_flat() {
674        let result = check_position_match(dec!(0), dec!(0), dec!(0), dec!(0), dec!(0.0001));
675        assert!(result);
676    }
677
678    #[rstest]
679    fn test_reconciliation_price_flat_to_long(_instrument: InstrumentAny) {
680        let result = calculate_reconciliation_price(dec!(0), None, dec!(10), Some(dec!(100)));
681        assert!(result.is_some());
682        assert_eq!(result.unwrap(), dec!(100));
683    }
684
685    #[rstest]
686    fn test_reconciliation_price_no_target_avg_px(_instrument: InstrumentAny) {
687        let result = calculate_reconciliation_price(dec!(5), Some(dec!(100)), dec!(10), None);
688        assert!(result.is_none());
689    }
690
691    #[rstest]
692    fn test_reconciliation_price_no_quantity_change(_instrument: InstrumentAny) {
693        let result =
694            calculate_reconciliation_price(dec!(10), Some(dec!(100)), dec!(10), Some(dec!(105)));
695        assert!(result.is_none());
696    }
697
698    #[rstest]
699    fn test_reconciliation_price_long_position_increase(_instrument: InstrumentAny) {
700        let result =
701            calculate_reconciliation_price(dec!(10), Some(dec!(100)), dec!(15), Some(dec!(102)));
702        assert!(result.is_some());
703        // Expected: (15 * 102 - 10 * 100) / 5 = (1530 - 1000) / 5 = 106
704        assert_eq!(result.unwrap(), dec!(106));
705    }
706
707    #[rstest]
708    fn test_reconciliation_price_flat_to_short(_instrument: InstrumentAny) {
709        let result = calculate_reconciliation_price(dec!(0), None, dec!(-10), Some(dec!(100)));
710        assert!(result.is_some());
711        assert_eq!(result.unwrap(), dec!(100));
712    }
713
714    #[rstest]
715    fn test_reconciliation_price_long_to_flat(_instrument: InstrumentAny) {
716        // Close long position to flat: 100 @ 1.20 to 0
717        // When closing to flat, reconciliation price equals current average price
718        let result =
719            calculate_reconciliation_price(dec!(100), Some(dec!(1.20)), dec!(0), Some(dec!(0)));
720        assert!(result.is_some());
721        assert_eq!(result.unwrap(), dec!(1.20));
722    }
723
724    #[rstest]
725    fn test_reconciliation_price_short_to_flat(_instrument: InstrumentAny) {
726        // Close short position to flat: -50 @ 2.50 to 0
727        // When closing to flat, reconciliation price equals current average price
728        let result = calculate_reconciliation_price(dec!(-50), Some(dec!(2.50)), dec!(0), None);
729        assert!(result.is_some());
730        assert_eq!(result.unwrap(), dec!(2.50));
731    }
732
733    #[rstest]
734    fn test_reconciliation_price_short_position_increase(_instrument: InstrumentAny) {
735        // Short position increase: -100 @ 1.30 to -200 @ 1.28
736        // (−200 × 1.28) = (−100 × 1.30) + (−100 × reconciliation_px)
737        // −256 = −130 + (−100 × reconciliation_px)
738        // reconciliation_px = 1.26
739        let result = calculate_reconciliation_price(
740            dec!(-100),
741            Some(dec!(1.30)),
742            dec!(-200),
743            Some(dec!(1.28)),
744        );
745        assert!(result.is_some());
746        assert_eq!(result.unwrap(), dec!(1.26));
747    }
748
749    #[rstest]
750    fn test_reconciliation_price_long_position_decrease(_instrument: InstrumentAny) {
751        // Long position decrease: 200 @ 1.20 to 100 @ 1.20
752        let result = calculate_reconciliation_price(
753            dec!(200),
754            Some(dec!(1.20)),
755            dec!(100),
756            Some(dec!(1.20)),
757        );
758        assert!(result.is_some());
759        assert_eq!(result.unwrap(), dec!(1.20));
760    }
761
762    #[rstest]
763    fn test_reconciliation_price_long_to_short_flip(_instrument: InstrumentAny) {
764        // Long to short flip: 100 @ 1.20 to -100 @ 1.25
765        // Due to netting simulation resetting value on flip, reconciliation_px = target_avg_px
766        let result = calculate_reconciliation_price(
767            dec!(100),
768            Some(dec!(1.20)),
769            dec!(-100),
770            Some(dec!(1.25)),
771        );
772        assert!(result.is_some());
773        assert_eq!(result.unwrap(), dec!(1.25));
774    }
775
776    #[rstest]
777    fn test_reconciliation_price_short_to_long_flip(_instrument: InstrumentAny) {
778        // Short to long flip: -100 @ 1.30 to 100 @ 1.25
779        // Due to netting simulation resetting value on flip, reconciliation_px = target_avg_px
780        let result = calculate_reconciliation_price(
781            dec!(-100),
782            Some(dec!(1.30)),
783            dec!(100),
784            Some(dec!(1.25)),
785        );
786        assert!(result.is_some());
787        assert_eq!(result.unwrap(), dec!(1.25));
788    }
789
790    #[rstest]
791    fn test_reconciliation_price_complex_scenario(_instrument: InstrumentAny) {
792        // Complex: 150 @ 1.23456 to 250 @ 1.24567
793        // (250 × 1.24567) = (150 × 1.23456) + (100 × reconciliation_px)
794        // 311.4175 = 185.184 + (100 × reconciliation_px)
795        // reconciliation_px = 1.262335
796        let result = calculate_reconciliation_price(
797            dec!(150),
798            Some(dec!(1.23456)),
799            dec!(250),
800            Some(dec!(1.24567)),
801        );
802        assert!(result.is_some());
803        assert_eq!(result.unwrap(), dec!(1.262335));
804    }
805
806    #[rstest]
807    fn test_reconciliation_price_zero_target_avg_px(_instrument: InstrumentAny) {
808        let result =
809            calculate_reconciliation_price(dec!(100), Some(dec!(1.20)), dec!(200), Some(dec!(0)));
810        assert!(result.is_none());
811    }
812
813    #[rstest]
814    fn test_reconciliation_price_negative_price(_instrument: InstrumentAny) {
815        // Negative price calculation: 100 @ 2.00 to 200 @ 1.00
816        // (200 × 1.00) = (100 × 2.00) + (100 × reconciliation_px)
817        // 200 = 200 + (100 × reconciliation_px)
818        // reconciliation_px = 0 (should return None as price must be positive)
819        let result = calculate_reconciliation_price(
820            dec!(100),
821            Some(dec!(2.00)),
822            dec!(200),
823            Some(dec!(1.00)),
824        );
825        assert!(result.is_none());
826    }
827
828    #[rstest]
829    fn test_reconciliation_price_flip_simulation_compatibility() {
830        let venue_order_id = create_test_venue_order_id("ORDER1");
831        // Start with long position: 100 @ 1.20
832        // Target: -100 @ 1.25
833        // Calculate reconciliation price
834        let recon_px = calculate_reconciliation_price(
835            dec!(100),
836            Some(dec!(1.20)),
837            dec!(-100),
838            Some(dec!(1.25)),
839        )
840        .expect("reconciliation price");
841
842        assert_eq!(recon_px, dec!(1.25));
843
844        // Simulate the flip with reconciliation fill (sell 200 to go from +100 to -100)
845        let fills = vec![
846            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
847            FillSnapshot::new(2000, OrderSide::Sell, dec!(200), recon_px, venue_order_id),
848        ];
849
850        let (final_qty, final_value) = simulate_position(&fills);
851        assert_eq!(final_qty, dec!(-100));
852        let final_avg = final_value / final_qty.abs();
853        assert_eq!(final_avg, dec!(1.25), "Final average should match target");
854    }
855
856    #[rstest]
857    fn test_reconciliation_price_accumulation_simulation_compatibility() {
858        let venue_order_id = create_test_venue_order_id("ORDER1");
859        // Start with long position: 100 @ 1.20
860        // Target: 200 @ 1.22
861        let recon_px = calculate_reconciliation_price(
862            dec!(100),
863            Some(dec!(1.20)),
864            dec!(200),
865            Some(dec!(1.22)),
866        )
867        .expect("reconciliation price");
868
869        // Simulate accumulation with reconciliation fill
870        let fills = vec![
871            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
872            FillSnapshot::new(2000, OrderSide::Buy, dec!(100), recon_px, venue_order_id),
873        ];
874
875        let (final_qty, final_value) = simulate_position(&fills);
876        assert_eq!(final_qty, dec!(200));
877        let final_avg = final_value / final_qty.abs();
878        assert_eq!(final_avg, dec!(1.22), "Final average should match target");
879    }
880
881    #[rstest]
882    fn test_simulate_position_accumulate_short() {
883        let venue_order_id = create_test_venue_order_id("ORDER1");
884        let fills = vec![
885            FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
886            FillSnapshot::new(2000, OrderSide::Sell, dec!(5), dec!(98), venue_order_id),
887        ];
888
889        let (qty, value) = simulate_position(&fills);
890        assert_eq!(qty, dec!(-15));
891        assert_eq!(value, dec!(1490)); // 10*100 + 5*98
892    }
893
894    #[rstest]
895    fn test_simulate_position_short_to_long_flip() {
896        let venue_order_id = create_test_venue_order_id("ORDER1");
897        let fills = vec![
898            FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
899            FillSnapshot::new(2000, OrderSide::Buy, dec!(15), dec!(102), venue_order_id),
900        ];
901
902        let (qty, value) = simulate_position(&fills);
903        assert_eq!(qty, dec!(5)); // Flipped from -10 to +5
904        assert_eq!(value, dec!(510)); // Remaining 5 @ 102
905    }
906
907    #[rstest]
908    fn test_simulate_position_multiple_flips() {
909        let venue_order_id = create_test_venue_order_id("ORDER1");
910        let fills = vec![
911            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
912            FillSnapshot::new(2000, OrderSide::Sell, dec!(15), dec!(105), venue_order_id), // Flip to -5
913            FillSnapshot::new(3000, OrderSide::Buy, dec!(10), dec!(110), venue_order_id), // Flip to +5
914        ];
915
916        let (qty, value) = simulate_position(&fills);
917        assert_eq!(qty, dec!(5)); // Final position: +5
918        assert_eq!(value, dec!(550)); // 5 @ 110
919    }
920
921    #[rstest]
922    fn test_simulate_position_empty_fills() {
923        let fills: Vec<FillSnapshot> = vec![];
924        let (qty, value) = simulate_position(&fills);
925        assert_eq!(qty, dec!(0));
926        assert_eq!(value, dec!(0));
927    }
928
929    #[rstest]
930    fn test_detect_zero_crossings_no_crossings() {
931        let venue_order_id = create_test_venue_order_id("ORDER1");
932        let fills = vec![
933            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
934            FillSnapshot::new(2000, OrderSide::Buy, dec!(5), dec!(102), venue_order_id),
935        ];
936
937        let crossings = detect_zero_crossings(&fills);
938        assert_eq!(crossings.len(), 0);
939    }
940
941    #[rstest]
942    fn test_detect_zero_crossings_single_crossing() {
943        let venue_order_id = create_test_venue_order_id("ORDER1");
944        let fills = vec![
945            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
946            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), venue_order_id), // Close to zero
947        ];
948
949        let crossings = detect_zero_crossings(&fills);
950        assert_eq!(crossings.len(), 1);
951        assert_eq!(crossings[0], 2000);
952    }
953
954    #[rstest]
955    fn test_detect_zero_crossings_empty_fills() {
956        let fills: Vec<FillSnapshot> = vec![];
957        let crossings = detect_zero_crossings(&fills);
958        assert_eq!(crossings.len(), 0);
959    }
960
961    #[rstest]
962    fn test_detect_zero_crossings_long_to_short_flip() {
963        let venue_order_id = create_test_venue_order_id("ORDER1");
964        // Buy 10, then Sell 15 -> flip from +10 to -5
965        let fills = vec![
966            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
967            FillSnapshot::new(2000, OrderSide::Sell, dec!(15), dec!(102), venue_order_id), // Flip
968        ];
969
970        let crossings = detect_zero_crossings(&fills);
971        assert_eq!(crossings.len(), 1);
972        assert_eq!(crossings[0], 2000); // Detected the flip
973    }
974
975    #[rstest]
976    fn test_detect_zero_crossings_short_to_long_flip() {
977        let venue_order_id = create_test_venue_order_id("ORDER1");
978        // Sell 10, then Buy 20 -> flip from -10 to +10
979        let fills = vec![
980            FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
981            FillSnapshot::new(2000, OrderSide::Buy, dec!(20), dec!(102), venue_order_id), // Flip
982        ];
983
984        let crossings = detect_zero_crossings(&fills);
985        assert_eq!(crossings.len(), 1);
986        assert_eq!(crossings[0], 2000);
987    }
988
989    #[rstest]
990    fn test_detect_zero_crossings_multiple_flips() {
991        let venue_order_id = create_test_venue_order_id("ORDER1");
992        let fills = vec![
993            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
994            FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), venue_order_id), // Land on zero
995            FillSnapshot::new(3000, OrderSide::Sell, dec!(5), dec!(103), venue_order_id), // Go short
996            FillSnapshot::new(4000, OrderSide::Buy, dec!(15), dec!(104), venue_order_id), // Flip to long
997        ];
998
999        let crossings = detect_zero_crossings(&fills);
1000        assert_eq!(crossings.len(), 2);
1001        assert_eq!(crossings[0], 2000); // First zero-crossing (land on zero)
1002        assert_eq!(crossings[1], 4000); // Second zero-crossing (flip)
1003    }
1004
1005    #[rstest]
1006    fn test_check_position_match_outside_tolerance() {
1007        // Simulated avg px = 1000/10 = 100, venue = 101
1008        // Relative diff = 1 / 101 = 0.0099 > 0.0001
1009        let result = check_position_match(dec!(10), dec!(1000), dec!(10), dec!(101), dec!(0.0001));
1010        assert!(!result);
1011    }
1012
1013    #[rstest]
1014    fn test_check_position_match_edge_of_tolerance() {
1015        // Simulated avg px = 1000/10 = 100, venue = 100.01
1016        // Relative diff = 0.01 / 100.01 = 0.00009999 < 0.0001
1017        let result =
1018            check_position_match(dec!(10), dec!(1000), dec!(10), dec!(100.01), dec!(0.0001));
1019        assert!(result);
1020    }
1021
1022    #[rstest]
1023    fn test_check_position_match_zero_venue_avg_px() {
1024        let result = check_position_match(dec!(10), dec!(1000), dec!(10), dec!(0), dec!(0.0001));
1025        assert!(!result); // Should fail because relative diff calculation with zero denominator
1026    }
1027
1028    // Tests for adjust_fills_for_partial_window
1029
1030    #[rstest]
1031    fn test_adjust_fills_no_fills() {
1032        let venue_position = VenuePositionSnapshot {
1033            side: OrderSide::Buy,
1034            qty: dec!(0.02),
1035            avg_px: dec!(4100.00),
1036        };
1037        let instrument = instrument();
1038        let result =
1039            adjust_fills_for_partial_window(&[], &venue_position, &instrument, dec!(0.0001));
1040        assert!(matches!(result, FillAdjustmentResult::NoAdjustment));
1041    }
1042
1043    #[rstest]
1044    fn test_adjust_fills_flat_position() {
1045        let venue_order_id = create_test_venue_order_id("ORDER1");
1046        let fills = vec![FillSnapshot::new(
1047            1000,
1048            OrderSide::Buy,
1049            dec!(0.01),
1050            dec!(4100.00),
1051            venue_order_id,
1052        )];
1053        let venue_position = VenuePositionSnapshot {
1054            side: OrderSide::Buy,
1055            qty: dec!(0),
1056            avg_px: dec!(0),
1057        };
1058        let instrument = instrument();
1059        let result =
1060            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1061        assert!(matches!(result, FillAdjustmentResult::NoAdjustment));
1062    }
1063
1064    #[rstest]
1065    fn test_adjust_fills_complete_lifecycle_no_adjustment() {
1066        let venue_order_id = create_test_venue_order_id("ORDER1");
1067        let venue_order_id2 = create_test_venue_order_id("ORDER2");
1068        let fills = vec![
1069            FillSnapshot::new(
1070                1000,
1071                OrderSide::Buy,
1072                dec!(0.01),
1073                dec!(4100.00),
1074                venue_order_id,
1075            ),
1076            FillSnapshot::new(
1077                2000,
1078                OrderSide::Buy,
1079                dec!(0.01),
1080                dec!(4100.00),
1081                venue_order_id2,
1082            ),
1083        ];
1084        let venue_position = VenuePositionSnapshot {
1085            side: OrderSide::Buy,
1086            qty: dec!(0.02),
1087            avg_px: dec!(4100.00),
1088        };
1089        let instrument = instrument();
1090        let result =
1091            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1092        assert!(matches!(result, FillAdjustmentResult::NoAdjustment));
1093    }
1094
1095    #[rstest]
1096    fn test_adjust_fills_incomplete_lifecycle_adds_synthetic() {
1097        let venue_order_id = create_test_venue_order_id("ORDER1");
1098        // Window only sees +0.02 @ 4200, but venue has 0.04 @ 4100
1099        let fills = vec![FillSnapshot::new(
1100            2000,
1101            OrderSide::Buy,
1102            dec!(0.02),
1103            dec!(4200.00),
1104            venue_order_id,
1105        )];
1106        let venue_position = VenuePositionSnapshot {
1107            side: OrderSide::Buy,
1108            qty: dec!(0.04),
1109            avg_px: dec!(4100.00),
1110        };
1111        let instrument = instrument();
1112        let result =
1113            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1114
1115        match result {
1116            FillAdjustmentResult::AddSyntheticOpening {
1117                synthetic_fill,
1118                existing_fills,
1119            } => {
1120                assert_eq!(synthetic_fill.side, OrderSide::Buy);
1121                assert_eq!(synthetic_fill.qty, dec!(0.02)); // Missing 0.02
1122                assert_eq!(existing_fills.len(), 1);
1123            }
1124            _ => panic!("Expected AddSyntheticOpening"),
1125        }
1126    }
1127
1128    #[rstest]
1129    fn test_adjust_fills_with_zero_crossings() {
1130        let venue_order_id1 = create_test_venue_order_id("ORDER1");
1131        let venue_order_id2 = create_test_venue_order_id("ORDER2");
1132        let venue_order_id3 = create_test_venue_order_id("ORDER3");
1133
1134        // Lifecycle 1: LONG 0.02 -> FLAT (zero-crossing at 2000)
1135        // Lifecycle 2: LONG 0.03 (current)
1136        let fills = vec![
1137            FillSnapshot::new(
1138                1000,
1139                OrderSide::Buy,
1140                dec!(0.02),
1141                dec!(4100.00),
1142                venue_order_id1,
1143            ),
1144            FillSnapshot::new(
1145                2000,
1146                OrderSide::Sell,
1147                dec!(0.02),
1148                dec!(4150.00),
1149                venue_order_id2,
1150            ), // Zero-crossing
1151            FillSnapshot::new(
1152                3000,
1153                OrderSide::Buy,
1154                dec!(0.03),
1155                dec!(4200.00),
1156                venue_order_id3,
1157            ), // Current lifecycle
1158        ];
1159
1160        let venue_position = VenuePositionSnapshot {
1161            side: OrderSide::Buy,
1162            qty: dec!(0.03),
1163            avg_px: dec!(4200.00),
1164        };
1165
1166        let instrument = instrument();
1167        let result =
1168            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1169
1170        // Should filter to current lifecycle only
1171        match result {
1172            FillAdjustmentResult::FilterToCurrentLifecycle {
1173                last_zero_crossing_ts,
1174                current_lifecycle_fills,
1175            } => {
1176                assert_eq!(last_zero_crossing_ts, 2000);
1177                assert_eq!(current_lifecycle_fills.len(), 1);
1178                assert_eq!(current_lifecycle_fills[0].venue_order_id, venue_order_id3);
1179            }
1180            _ => panic!("Expected FilterToCurrentLifecycle, was {:?}", result),
1181        }
1182    }
1183
1184    #[rstest]
1185    fn test_adjust_fills_multiple_zero_crossings_mismatch() {
1186        let venue_order_id1 = create_test_venue_order_id("ORDER1");
1187        let venue_order_id2 = create_test_venue_order_id("ORDER2");
1188        let _venue_order_id3 = create_test_venue_order_id("ORDER3");
1189        let venue_order_id4 = create_test_venue_order_id("ORDER4");
1190        let venue_order_id5 = create_test_venue_order_id("ORDER5");
1191
1192        // Lifecycle 1: LONG 0.05 -> FLAT
1193        // Lifecycle 2: Current fills produce 0.10 @ 4050, but venue has 0.05 @ 4142.04
1194        let fills = vec![
1195            FillSnapshot::new(
1196                1000,
1197                OrderSide::Buy,
1198                dec!(0.05),
1199                dec!(4000.00),
1200                venue_order_id1,
1201            ),
1202            FillSnapshot::new(
1203                2000,
1204                OrderSide::Sell,
1205                dec!(0.05),
1206                dec!(4050.00),
1207                venue_order_id2,
1208            ), // Zero-crossing
1209            FillSnapshot::new(
1210                3000,
1211                OrderSide::Buy,
1212                dec!(0.05),
1213                dec!(4000.00),
1214                venue_order_id4,
1215            ), // Current lifecycle
1216            FillSnapshot::new(
1217                4000,
1218                OrderSide::Buy,
1219                dec!(0.05),
1220                dec!(4100.00),
1221                venue_order_id5,
1222            ), // Current lifecycle
1223        ];
1224
1225        let venue_position = VenuePositionSnapshot {
1226            side: OrderSide::Buy,
1227            qty: dec!(0.05),
1228            avg_px: dec!(4142.04),
1229        };
1230
1231        let instrument = instrument();
1232        let result =
1233            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1234
1235        // Should replace current lifecycle with synthetic
1236        match result {
1237            FillAdjustmentResult::ReplaceCurrentLifecycle {
1238                synthetic_fill,
1239                first_venue_order_id,
1240            } => {
1241                assert_eq!(synthetic_fill.qty, dec!(0.05));
1242                assert_eq!(synthetic_fill.px, dec!(4142.04));
1243                assert_eq!(synthetic_fill.side, OrderSide::Buy);
1244                assert_eq!(first_venue_order_id, venue_order_id4);
1245            }
1246            _ => panic!("Expected ReplaceCurrentLifecycle, was {:?}", result),
1247        }
1248    }
1249
1250    #[rstest]
1251    fn test_adjust_fills_short_position() {
1252        let venue_order_id = create_test_venue_order_id("ORDER1");
1253
1254        // Window only sees SELL 0.02 @ 4120, but venue has -0.05 @ 4100
1255        let fills = vec![FillSnapshot::new(
1256            1000,
1257            OrderSide::Sell,
1258            dec!(0.02),
1259            dec!(4120.00),
1260            venue_order_id,
1261        )];
1262
1263        let venue_position = VenuePositionSnapshot {
1264            side: OrderSide::Sell,
1265            qty: dec!(0.05),
1266            avg_px: dec!(4100.00),
1267        };
1268
1269        let instrument = instrument();
1270        let result =
1271            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1272
1273        // Should add synthetic opening SHORT fill
1274        match result {
1275            FillAdjustmentResult::AddSyntheticOpening {
1276                synthetic_fill,
1277                existing_fills,
1278            } => {
1279                assert_eq!(synthetic_fill.side, OrderSide::Sell);
1280                assert_eq!(synthetic_fill.qty, dec!(0.03)); // Missing 0.03
1281                assert_eq!(existing_fills.len(), 1);
1282            }
1283            _ => panic!("Expected AddSyntheticOpening, was {:?}", result),
1284        }
1285    }
1286
1287    #[rstest]
1288    fn test_adjust_fills_timestamp_underflow_protection() {
1289        let venue_order_id = create_test_venue_order_id("ORDER1");
1290
1291        // First fill at timestamp 0 - saturating_sub should prevent underflow
1292        let fills = vec![FillSnapshot::new(
1293            0,
1294            OrderSide::Buy,
1295            dec!(0.01),
1296            dec!(4100.00),
1297            venue_order_id,
1298        )];
1299
1300        let venue_position = VenuePositionSnapshot {
1301            side: OrderSide::Buy,
1302            qty: dec!(0.02),
1303            avg_px: dec!(4100.00),
1304        };
1305
1306        let instrument = instrument();
1307        let result =
1308            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1309
1310        // Should add synthetic fill with timestamp 0 (not u64::MAX)
1311        match result {
1312            FillAdjustmentResult::AddSyntheticOpening { synthetic_fill, .. } => {
1313                assert_eq!(synthetic_fill.ts_event, 0); // saturating_sub(1) from 0 = 0
1314            }
1315            _ => panic!("Expected AddSyntheticOpening, was {:?}", result),
1316        }
1317    }
1318
1319    #[rstest]
1320    fn test_adjust_fills_with_flip_scenario() {
1321        let venue_order_id1 = create_test_venue_order_id("ORDER1");
1322        let venue_order_id2 = create_test_venue_order_id("ORDER2");
1323
1324        // Long 10 @ 100, then Sell 20 @ 105 -> flip to Short 10 @ 105
1325        let fills = vec![
1326            FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id1),
1327            FillSnapshot::new(2000, OrderSide::Sell, dec!(20), dec!(105), venue_order_id2), // Flip
1328        ];
1329
1330        let venue_position = VenuePositionSnapshot {
1331            side: OrderSide::Sell,
1332            qty: dec!(10),
1333            avg_px: dec!(105),
1334        };
1335
1336        let instrument = instrument();
1337        let result =
1338            adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1339
1340        // Should recognize the flip and match correctly
1341        match result {
1342            FillAdjustmentResult::NoAdjustment => {
1343                // Verify simulation matches
1344                let (qty, value) = simulate_position(&fills);
1345                assert_eq!(qty, dec!(-10));
1346                let avg = value / qty.abs();
1347                assert_eq!(avg, dec!(105));
1348            }
1349            _ => panic!("Expected NoAdjustment for matching flip, was {:?}", result),
1350        }
1351    }
1352
1353    #[rstest]
1354    fn test_detect_zero_crossings_complex_lifecycle() {
1355        let venue_order_id = create_test_venue_order_id("ORDER1");
1356        // Complex scenario with multiple lifecycles
1357        let fills = vec![
1358            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
1359            FillSnapshot::new(2000, OrderSide::Sell, dec!(50), dec!(1.25), venue_order_id), // Reduce
1360            FillSnapshot::new(3000, OrderSide::Sell, dec!(100), dec!(1.30), venue_order_id), // Flip to -50
1361            FillSnapshot::new(4000, OrderSide::Buy, dec!(50), dec!(1.28), venue_order_id), // Close to zero
1362            FillSnapshot::new(5000, OrderSide::Buy, dec!(75), dec!(1.22), venue_order_id), // Open long
1363            FillSnapshot::new(6000, OrderSide::Sell, dec!(150), dec!(1.24), venue_order_id), // Flip to -75
1364        ];
1365
1366        let crossings = detect_zero_crossings(&fills);
1367        assert_eq!(crossings.len(), 3);
1368        assert_eq!(crossings[0], 3000); // First flip
1369        assert_eq!(crossings[1], 4000); // Close to zero
1370        assert_eq!(crossings[2], 6000); // Second flip
1371    }
1372
1373    #[rstest]
1374    fn test_reconciliation_price_partial_close() {
1375        let venue_order_id = create_test_venue_order_id("ORDER1");
1376        // Partial close scenario: 100 @ 1.20 to 50 @ 1.20
1377        let recon_px =
1378            calculate_reconciliation_price(dec!(100), Some(dec!(1.20)), dec!(50), Some(dec!(1.20)))
1379                .expect("reconciliation price");
1380
1381        // Simulate partial close
1382        let fills = vec![
1383            FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
1384            FillSnapshot::new(2000, OrderSide::Sell, dec!(50), recon_px, venue_order_id),
1385        ];
1386
1387        let (final_qty, final_value) = simulate_position(&fills);
1388        assert_eq!(final_qty, dec!(50));
1389        let final_avg = final_value / final_qty.abs();
1390        assert_eq!(final_avg, dec!(1.20), "Average should be maintained");
1391    }
1392}