Skip to main content

nautilus_execution/reconciliation/
positions.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Position-state reconciliation.
17//!
18//! Position simulation, partial-window fill reconstruction, mass-status processing,
19//! and final position-match checks. The core invariant maintained here is that the
20//! reconstructed position matches the venue's reported position within tolerance
21//! (default 0.01%) after reconciliation is applied.
22
23use indexmap::IndexMap;
24use nautilus_core::UnixNanos;
25use nautilus_model::{
26    enums::{LiquiditySide, OrderSide, OrderStatus, OrderType, PositionSideSpecified, TimeInForce},
27    identifiers::{AccountId, InstrumentId, VenueOrderId},
28    instruments::{Instrument, InstrumentAny},
29    reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
30    types::{Money, Price, Quantity},
31};
32use rust_decimal::{Decimal, RoundingStrategy};
33
34use super::{
35    ids::{create_synthetic_trade_id, create_synthetic_venue_order_id},
36    types::{FillAdjustmentResult, FillSnapshot, ReconciliationResult, VenuePositionSnapshot},
37};
38
39const DEFAULT_TOLERANCE: Decimal = Decimal::from_parts(1, 0, 0, false, 4); // 0.0001
40
41/// Process fill reports from a mass status for position reconciliation.
42///
43/// This is the main entry point for position reconciliation. It:
44/// 1. Extracts fills and position for the given instrument
45/// 2. Detects position discrepancies
46/// 3. Returns adjusted order/fill reports ready for processing
47///
48/// # Errors
49///
50/// Returns an error if synthetic report creation fails.
51pub fn process_mass_status_for_reconciliation(
52    mass_status: &ExecutionMassStatus,
53    instrument: &InstrumentAny,
54    tolerance: Option<Decimal>,
55) -> anyhow::Result<ReconciliationResult> {
56    let instrument_id = instrument.id();
57    let account_id = mass_status.account_id;
58    let tol = tolerance.unwrap_or(DEFAULT_TOLERANCE);
59
60    // Get position report for this instrument
61    let position_reports = mass_status.position_reports();
62    let venue_position = match position_reports.get(&instrument_id).and_then(|r| r.first()) {
63        Some(report) => position_report_to_snapshot(report),
64        None => {
65            // No position report - return orders/fills unchanged
66            return Ok(extract_instrument_reports(mass_status, instrument_id));
67        }
68    };
69
70    // Extract and convert fills to snapshots
71    let extracted = extract_fills_for_instrument(mass_status, instrument_id);
72    let fill_snapshots = extracted.snapshots;
73    let mut order_map = extracted.orders;
74    let mut fill_map = extracted.fills;
75
76    if fill_snapshots.is_empty() {
77        return Ok(ReconciliationResult {
78            orders: order_map,
79            fills: fill_map,
80        });
81    }
82
83    // Run adjustment logic
84    let result = adjust_fills_for_partial_window(&fill_snapshots, &venue_position, tol);
85
86    // Apply adjustments
87    match result {
88        FillAdjustmentResult::NoAdjustment => {}
89
90        FillAdjustmentResult::AddSyntheticOpening {
91            synthetic_fill,
92            existing_fills: _,
93        } => {
94            let venue_order_id = create_synthetic_venue_order_id(&synthetic_fill, instrument_id);
95            let order = create_synthetic_order_report(
96                &synthetic_fill,
97                account_id,
98                instrument_id,
99                instrument,
100                venue_order_id,
101            )?;
102            let fill = create_synthetic_fill_report(
103                &synthetic_fill,
104                account_id,
105                instrument_id,
106                instrument,
107                venue_order_id,
108            )?;
109
110            order_map.insert(venue_order_id, order);
111            fill_map.entry(venue_order_id).or_default().insert(0, fill);
112        }
113
114        FillAdjustmentResult::ReplaceCurrentLifecycle {
115            synthetic_fill,
116            first_venue_order_id,
117        } => {
118            let order = create_synthetic_order_report(
119                &synthetic_fill,
120                account_id,
121                instrument_id,
122                instrument,
123                first_venue_order_id,
124            )?;
125            let fill = create_synthetic_fill_report(
126                &synthetic_fill,
127                account_id,
128                instrument_id,
129                instrument,
130                first_venue_order_id,
131            )?;
132
133            // Replace with only synthetic
134            order_map.clear();
135            fill_map.clear();
136            order_map.insert(first_venue_order_id, order);
137            fill_map.insert(first_venue_order_id, vec![fill]);
138        }
139
140        FillAdjustmentResult::FilterToCurrentLifecycle {
141            last_zero_crossing_ts,
142            current_lifecycle_fills: _,
143        } => {
144            // Filter fills to current lifecycle
145            for fills in fill_map.values_mut() {
146                fills.retain(|f| f.ts_event.as_u64() > last_zero_crossing_ts);
147            }
148            fill_map.retain(|_, fills| !fills.is_empty());
149
150            // Keep only orders that have fills or are still working
151            let orders_with_fills: ahash::AHashSet<VenueOrderId> =
152                fill_map.keys().copied().collect();
153            order_map.retain(|id, order| {
154                orders_with_fills.contains(id)
155                    || !matches!(
156                        order.order_status,
157                        OrderStatus::Denied
158                            | OrderStatus::Rejected
159                            | OrderStatus::Canceled
160                            | OrderStatus::Expired
161                            | OrderStatus::Filled
162                    )
163            });
164        }
165    }
166
167    Ok(ReconciliationResult {
168        orders: order_map,
169        fills: fill_map,
170    })
171}
172
173/// Adjust fills for partial reconciliation window to handle incomplete position lifecycles.
174///
175/// This function analyzes fills and determines if adjustments are needed when the reconciliation
176/// window doesn't capture the complete position history (missing opening fills).
177///
178/// # Returns
179///
180/// Returns `FillAdjustmentResult` indicating what adjustments (if any) are needed.
181///
182#[must_use]
183pub(super) fn adjust_fills_for_partial_window(
184    fills: &[FillSnapshot],
185    venue_position: &VenuePositionSnapshot,
186    tolerance: Decimal,
187) -> FillAdjustmentResult {
188    // If no fills, nothing to adjust
189    if fills.is_empty() {
190        return FillAdjustmentResult::NoAdjustment;
191    }
192
193    // If venue position is FLAT, return unchanged
194    if venue_position.qty == Decimal::ZERO {
195        return FillAdjustmentResult::NoAdjustment;
196    }
197
198    // Detect zero-crossings
199    let zero_crossings = detect_zero_crossings(fills);
200
201    // Convert venue position to signed quantity
202    let venue_qty_signed = match venue_position.side {
203        PositionSideSpecified::Long => venue_position.qty,
204        PositionSideSpecified::Short => -venue_position.qty,
205        PositionSideSpecified::Flat => Decimal::ZERO,
206    };
207
208    // Case 1: Has zero-crossings - focus on current lifecycle after last zero-crossing
209    if !zero_crossings.is_empty() {
210        // Find the last zero-crossing that lands on FLAT (qty==0)
211        // This separates lifecycles; flips within a lifecycle don't count
212        let mut last_flat_crossing_ts = None;
213        let mut running_qty = Decimal::ZERO;
214
215        for fill in fills {
216            let prev_qty = running_qty;
217            running_qty += Decimal::from(fill.direction()) * fill.qty;
218
219            if prev_qty != Decimal::ZERO && running_qty == Decimal::ZERO {
220                last_flat_crossing_ts = Some(fill.ts_event);
221            }
222        }
223
224        let lifecycle_boundary_ts =
225            last_flat_crossing_ts.unwrap_or(*zero_crossings.last().unwrap());
226
227        // Get fills from current lifecycle (after lifecycle boundary)
228        let current_lifecycle_fills: Vec<FillSnapshot> = fills
229            .iter()
230            .filter(|f| f.ts_event > lifecycle_boundary_ts)
231            .cloned()
232            .collect();
233
234        if current_lifecycle_fills.is_empty() {
235            return FillAdjustmentResult::NoAdjustment;
236        }
237
238        // Simulate current lifecycle
239        let (current_qty, current_value) = simulate_position(&current_lifecycle_fills);
240
241        // Check if current lifecycle matches venue
242        if check_position_match(
243            current_qty,
244            current_value,
245            venue_qty_signed,
246            venue_position.avg_px,
247            tolerance,
248        ) {
249            // Current lifecycle matches - filter out old lifecycles
250            return FillAdjustmentResult::FilterToCurrentLifecycle {
251                last_zero_crossing_ts: lifecycle_boundary_ts,
252                current_lifecycle_fills,
253            };
254        }
255
256        // Current lifecycle doesn't match - replace with synthetic fill
257        if let Some(first_fill) = current_lifecycle_fills.first() {
258            let synthetic_fill = FillSnapshot::new(
259                first_fill.venue_order_id,
260                position_to_order_side(venue_position.side),
261                venue_position.qty,
262                venue_position.avg_px,
263                first_fill.ts_event.saturating_sub(1), // Timestamp before first fill
264            );
265
266            return FillAdjustmentResult::ReplaceCurrentLifecycle {
267                synthetic_fill,
268                first_venue_order_id: first_fill.venue_order_id,
269            };
270        }
271
272        return FillAdjustmentResult::NoAdjustment;
273    }
274
275    // Case 2: Single lifecycle or one zero-crossing
276    // Determine which fills to analyze
277    let oldest_lifecycle_fills: Vec<FillSnapshot> =
278        if let Some(&first_zero_crossing_ts) = zero_crossings.first() {
279            // Get fills before first zero-crossing
280            fills
281                .iter()
282                .filter(|f| f.ts_event <= first_zero_crossing_ts)
283                .cloned()
284                .collect()
285        } else {
286            // No zero-crossings - all fills are in single lifecycle
287            fills.to_vec()
288        };
289
290    if oldest_lifecycle_fills.is_empty() {
291        return FillAdjustmentResult::NoAdjustment;
292    }
293
294    // Simulate oldest lifecycle
295    let (oldest_qty, oldest_value) = simulate_position(&oldest_lifecycle_fills);
296
297    // If single lifecycle (no zero-crossings)
298    if zero_crossings.is_empty() {
299        // Check if simulated position matches venue
300        if check_position_match(
301            oldest_qty,
302            oldest_value,
303            venue_qty_signed,
304            venue_position.avg_px,
305            tolerance,
306        ) {
307            return FillAdjustmentResult::NoAdjustment;
308        }
309
310        // Doesn't match - need to add synthetic opening fill
311        if let Some(first_fill) = oldest_lifecycle_fills.first() {
312            // Calculate what opening fill is needed
313            // Use simulated position as current, venue position as target
314            let oldest_avg_px = if oldest_qty == Decimal::ZERO {
315                None
316            } else {
317                Some(oldest_value / oldest_qty.abs())
318            };
319
320            let reconciliation_price = calculate_reconciliation_price(
321                oldest_qty,
322                oldest_avg_px,
323                venue_qty_signed,
324                Some(venue_position.avg_px),
325            );
326
327            if let Some(opening_px) = reconciliation_price {
328                // Calculate opening quantity needed
329                let opening_qty = if oldest_qty == Decimal::ZERO {
330                    venue_qty_signed
331                } else {
332                    // Work backwards: venue = opening + current fills
333                    venue_qty_signed - oldest_qty
334                };
335
336                if opening_qty.abs() > Decimal::ZERO {
337                    let synthetic_side = if opening_qty > Decimal::ZERO {
338                        OrderSide::Buy
339                    } else {
340                        OrderSide::Sell
341                    };
342
343                    let synthetic_fill = FillSnapshot::new(
344                        first_fill.venue_order_id,
345                        synthetic_side,
346                        opening_qty.abs(),
347                        opening_px,
348                        first_fill.ts_event.saturating_sub(1),
349                    );
350
351                    return FillAdjustmentResult::AddSyntheticOpening {
352                        synthetic_fill,
353                        existing_fills: oldest_lifecycle_fills,
354                    };
355                }
356            }
357        }
358
359        return FillAdjustmentResult::NoAdjustment;
360    }
361
362    // Has one zero-crossing - check if oldest lifecycle closes at zero
363    if oldest_qty == Decimal::ZERO {
364        // Lifecycle closes correctly - no adjustment needed
365        return FillAdjustmentResult::NoAdjustment;
366    }
367
368    // Oldest lifecycle doesn't close at zero - add synthetic opening fill
369    if !oldest_lifecycle_fills.is_empty()
370        && let Some(&first_zero_crossing_ts) = zero_crossings.first()
371    {
372        // Need to add opening fill that makes position close at zero-crossing
373        let current_lifecycle_fills: Vec<FillSnapshot> = fills
374            .iter()
375            .filter(|f| f.ts_event > first_zero_crossing_ts)
376            .cloned()
377            .collect();
378
379        if !current_lifecycle_fills.is_empty()
380            && let Some(first_current_fill) = current_lifecycle_fills.first()
381        {
382            let synthetic_fill = FillSnapshot::new(
383                first_current_fill.venue_order_id,
384                position_to_order_side(venue_position.side),
385                venue_position.qty,
386                venue_position.avg_px,
387                first_current_fill.ts_event.saturating_sub(1),
388            );
389
390            return FillAdjustmentResult::AddSyntheticOpening {
391                synthetic_fill,
392                existing_fills: oldest_lifecycle_fills,
393            };
394        }
395    }
396
397    FillAdjustmentResult::NoAdjustment
398}
399
400/// Check if a cached position matches the venue position report within tolerance.
401///
402/// Compares the cached signed quantity against the venue-reported signed quantity,
403/// allowing a single-unit tolerance at the instrument's size precision when provided.
404///
405/// # Returns
406///
407/// Returns `true` when positions match (within tolerance), `false` otherwise. Logs
408/// a debug message when values differ but fall within tolerance, and a warning
409/// when they fail to reconcile.
410pub fn check_position_reconciliation(
411    report: &PositionStatusReport,
412    cached_signed_qty: Decimal,
413    size_precision: Option<u8>,
414) -> bool {
415    let venue_signed_qty = report.signed_decimal_qty;
416
417    if venue_signed_qty == Decimal::ZERO && cached_signed_qty == Decimal::ZERO {
418        return true;
419    }
420
421    if let Some(precision) = size_precision
422        && is_within_single_unit_tolerance(cached_signed_qty, venue_signed_qty, precision)
423    {
424        log::debug!(
425            "Position for {} within tolerance: cached={}, venue={}",
426            report.instrument_id,
427            cached_signed_qty,
428            venue_signed_qty
429        );
430        return true;
431    }
432
433    if cached_signed_qty == venue_signed_qty {
434        return true;
435    }
436
437    log::warn!(
438        "Position discrepancy for {}: cached={}, venue={}",
439        report.instrument_id,
440        cached_signed_qty,
441        venue_signed_qty
442    );
443
444    false
445}
446
447/// Caps a price at the instrument's maximum price.
448///
449/// Reconciliation derives synthetic prices by dividing a notional by a
450/// quantity; when that quantity is a dust rounding residual the result blows up
451/// far above the instrument's tradeable range. Capping at `max_price` keeps
452/// synthetic reports, and the positions derived from them, within range.
453/// Because the blown-up price always pairs with a dust quantity (the
454/// denominator) the PnL impact is negligible. Only the upper bound is enforced:
455/// the blow-up is always on the high side, so the lower bound is left untouched
456/// and legitimate negative-price and zero-cost fills pass through unchanged. A
457/// missing `max_price` leaves the price unmodified.
458///
459/// The cap is `max_price` floored to the instrument's price precision, so that
460/// rebuilding a `Price` at that precision (which rounds) cannot lift the result
461/// back above `max_price`. For example a 2 dp instrument with `max_price = 0.999`
462/// caps at `0.99`, since `0.999` (or any value in `(0.99, 1.00)`) rebuilt at 2 dp
463/// would round to `1.00`.
464pub(super) fn cap_price_at_instrument_max(px: Decimal, instrument: &InstrumentAny) -> Decimal {
465    let Some(max) = instrument.max_price() else {
466        return px;
467    };
468    let cap = max.as_decimal().round_dp_with_strategy(
469        u32::from(instrument.price_precision()),
470        RoundingStrategy::ToZero,
471    );
472    px.min(cap)
473}
474
475/// Create a synthetic `OrderStatusReport` from a `FillSnapshot`.
476///
477/// Populates `avg_px` from the fill's price so downstream reconciliation paths
478/// (e.g. [`crate::reconciliation::orders::create_inferred_fill`]) can resolve a
479/// fill price without falling back to the "no `avg_px` or price available" warning.
480///
481/// # Errors
482///
483/// Returns an error if the fill quantity cannot be converted to f64.
484pub(super) fn create_synthetic_order_report(
485    fill: &FillSnapshot,
486    account_id: AccountId,
487    instrument_id: InstrumentId,
488    instrument: &InstrumentAny,
489    venue_order_id: VenueOrderId,
490) -> anyhow::Result<OrderStatusReport> {
491    let order_qty = Quantity::from_decimal_dp(fill.qty, instrument.size_precision())?;
492
493    let mut report = OrderStatusReport::new(
494        account_id,
495        instrument_id,
496        None, // client_order_id
497        venue_order_id,
498        fill.side,
499        OrderType::Market,
500        TimeInForce::Gtc,
501        OrderStatus::Filled,
502        order_qty,
503        order_qty, // filled_qty = order_qty (fully filled)
504        UnixNanos::from(fill.ts_event),
505        UnixNanos::from(fill.ts_event),
506        UnixNanos::from(fill.ts_event),
507        None, // report_id
508    );
509    report.avg_px = Some(cap_price_at_instrument_max(fill.px, instrument));
510    Ok(report)
511}
512
513/// Create a synthetic `FillReport` from a `FillSnapshot`.
514///
515/// # Errors
516///
517/// Returns an error if the fill quantity or price cannot be converted.
518pub(super) fn create_synthetic_fill_report(
519    fill: &FillSnapshot,
520    account_id: AccountId,
521    instrument_id: InstrumentId,
522    instrument: &InstrumentAny,
523    venue_order_id: VenueOrderId,
524) -> anyhow::Result<FillReport> {
525    let trade_id = create_synthetic_trade_id(fill);
526    let qty = Quantity::from_decimal_dp(fill.qty, instrument.size_precision())?;
527    let px = Price::from_decimal_dp(
528        cap_price_at_instrument_max(fill.px, instrument),
529        instrument.price_precision(),
530    )?;
531
532    Ok(FillReport::new(
533        account_id,
534        instrument_id,
535        venue_order_id,
536        trade_id,
537        fill.side,
538        qty,
539        px,
540        Money::zero(instrument.quote_currency()),
541        LiquiditySide::NoLiquiditySide,
542        None, // client_order_id
543        None, // venue_position_id
544        fill.ts_event.into(),
545        fill.ts_event.into(),
546        None, // report_id
547    ))
548}
549
550/// Convert a position status report to a venue position snapshot.
551fn position_report_to_snapshot(report: &PositionStatusReport) -> VenuePositionSnapshot {
552    VenuePositionSnapshot {
553        side: report.position_side,
554        qty: report.quantity.into(),
555        avg_px: report.avg_px_open.unwrap_or(Decimal::ZERO),
556    }
557}
558
559/// Map a position side to the order side that would open it.
560///
561/// Callers must guard `Flat` upstream; reaching it here means a flat venue
562/// position slipped past the qty==0 early returns in
563/// [`adjust_fills_for_partial_window`].
564fn position_to_order_side(side: PositionSideSpecified) -> OrderSide {
565    match side {
566        PositionSideSpecified::Long => OrderSide::Buy,
567        PositionSideSpecified::Short => OrderSide::Sell,
568        PositionSideSpecified::Flat => {
569            unreachable!("flat venue position must be guarded by an earlier check")
570        }
571    }
572}
573
574/// Extract orders and fills for a specific instrument from mass status.
575fn extract_instrument_reports(
576    mass_status: &ExecutionMassStatus,
577    instrument_id: InstrumentId,
578) -> ReconciliationResult {
579    let mut orders = IndexMap::new();
580    let mut fills = IndexMap::new();
581
582    for (id, order) in mass_status.order_reports() {
583        if order.instrument_id == instrument_id {
584            orders.insert(id, order.clone());
585        }
586    }
587
588    for (id, fill_list) in mass_status.fill_reports() {
589        let filtered: Vec<_> = fill_list
590            .iter()
591            .filter(|f| f.instrument_id == instrument_id)
592            .cloned()
593            .collect();
594
595        if !filtered.is_empty() {
596            fills.insert(id, filtered);
597        }
598    }
599
600    ReconciliationResult { orders, fills }
601}
602
603/// Extracted fills and reports for an instrument.
604struct ExtractedFills {
605    snapshots: Vec<FillSnapshot>,
606    orders: IndexMap<VenueOrderId, OrderStatusReport>,
607    fills: IndexMap<VenueOrderId, Vec<FillReport>>,
608}
609
610/// Extract fills for an instrument and convert to snapshots.
611fn extract_fills_for_instrument(
612    mass_status: &ExecutionMassStatus,
613    instrument_id: InstrumentId,
614) -> ExtractedFills {
615    let mut snapshots = Vec::new();
616    let mut order_map = IndexMap::new();
617    let mut fill_map = IndexMap::new();
618
619    // Seed order_map
620    for (id, order) in mass_status.order_reports() {
621        if order.instrument_id == instrument_id {
622            order_map.insert(id, order.clone());
623        }
624    }
625
626    // Extract fills
627    for (venue_order_id, fill_reports) in mass_status.fill_reports() {
628        for fill in fill_reports {
629            if fill.instrument_id == instrument_id {
630                let side = mass_status
631                    .order_reports()
632                    .get(&venue_order_id)
633                    .map_or(fill.order_side, |o| o.order_side);
634
635                snapshots.push(FillSnapshot::new(
636                    venue_order_id,
637                    side,
638                    fill.last_qty.into(),
639                    fill.last_px.into(),
640                    fill.ts_event.as_u64(),
641                ));
642
643                fill_map
644                    .entry(venue_order_id)
645                    .or_insert_with(Vec::new)
646                    .push(fill.clone());
647            }
648        }
649    }
650
651    // Sort chronologically
652    snapshots.sort_by_key(|f| f.ts_event);
653
654    ExtractedFills {
655        snapshots,
656        orders: order_map,
657        fills: fill_map,
658    }
659}
660
661/// Simulate position from chronologically ordered fills using netting logic.
662///
663/// # Returns
664///
665/// Returns a tuple of (quantity, value) after applying all fills.
666#[must_use]
667pub(super) fn simulate_position(fills: &[FillSnapshot]) -> (Decimal, Decimal) {
668    let mut qty = Decimal::ZERO;
669    let mut value = Decimal::ZERO;
670
671    for fill in fills {
672        debug_assert!(
673            fill.qty > Decimal::ZERO,
674            "fill snapshot qty must be positive, received {}",
675            fill.qty,
676        );
677        let direction = Decimal::from(fill.direction());
678        let new_qty = qty + (direction * fill.qty);
679
680        // Check if we're accumulating or crossing zero (flip/close)
681        if (qty >= Decimal::ZERO && direction > Decimal::ZERO)
682            || (qty <= Decimal::ZERO && direction < Decimal::ZERO)
683        {
684            // Accumulating in same direction
685            value += fill.qty * fill.px;
686            qty = new_qty;
687        } else {
688            // Closing or flipping position
689            if qty.abs() >= fill.qty {
690                // Partial close - maintain average price by reducing value proportionally
691                let close_ratio = fill.qty / qty.abs();
692                value *= Decimal::ONE - close_ratio;
693                qty = new_qty;
694            } else {
695                // Close and flip - reset value to opening position
696                let remaining = fill.qty - qty.abs();
697                qty = direction * remaining;
698                value = remaining * fill.px;
699            }
700        }
701    }
702
703    debug_assert!(
704        value >= Decimal::ZERO,
705        "simulated position value must be non-negative, was {value}",
706    );
707    debug_assert!(
708        !(qty != Decimal::ZERO && value.is_sign_negative()),
709        "simulated avg price invariant: qty={qty}, value={value}",
710    );
711
712    (qty, value)
713}
714
715/// Detect zero-crossing timestamps in a sequence of fills.
716///
717/// A zero-crossing occurs when position quantity crosses through zero (FLAT).
718/// This includes both landing exactly on zero and flipping from long to short or vice versa.
719///
720/// # Returns
721///
722/// Returns a list of timestamps where position crosses through zero.
723#[must_use]
724pub(super) fn detect_zero_crossings(fills: &[FillSnapshot]) -> Vec<u64> {
725    let mut running_qty = Decimal::ZERO;
726    let mut zero_crossings = Vec::new();
727
728    for fill in fills {
729        let prev_qty = running_qty;
730        running_qty += Decimal::from(fill.direction()) * fill.qty;
731
732        // Detect when position crosses zero
733        if prev_qty != Decimal::ZERO {
734            if running_qty == Decimal::ZERO {
735                // Landed exactly on zero
736                zero_crossings.push(fill.ts_event);
737            } else if (prev_qty > Decimal::ZERO) != (running_qty > Decimal::ZERO) {
738                // Sign changed - crossed through zero (flip)
739                zero_crossings.push(fill.ts_event);
740            }
741        }
742    }
743
744    zero_crossings
745}
746
747/// Check if simulated position matches venue position within tolerance.
748///
749/// # Returns
750///
751/// Returns true if quantities and average prices match within tolerance.
752#[must_use]
753pub(super) fn check_position_match(
754    simulated_qty: Decimal,
755    simulated_value: Decimal,
756    venue_qty: Decimal,
757    venue_avg_px: Decimal,
758    tolerance: Decimal,
759) -> bool {
760    if simulated_qty != venue_qty {
761        return false;
762    }
763
764    if simulated_qty == Decimal::ZERO {
765        return true; // Both FLAT
766    }
767
768    // Guard against division by zero
769    let abs_qty = simulated_qty.abs();
770    if abs_qty == Decimal::ZERO {
771        return false;
772    }
773
774    let simulated_avg_px = simulated_value / abs_qty;
775
776    // If venue avg px is zero, we cannot calculate relative difference
777    if venue_avg_px == Decimal::ZERO {
778        return false;
779    }
780
781    let relative_diff = (simulated_avg_px - venue_avg_px).abs() / venue_avg_px;
782
783    relative_diff <= tolerance
784}
785
786/// Calculate the price needed for a reconciliation order to achieve target position.
787///
788/// This is a pure function that calculates what price a fill would need to have
789/// to move from the current position state to the target position state with the
790/// correct average price, accounting for the netting simulation logic.
791///
792/// # Returns
793///
794/// Returns `Some(Decimal)` if a valid reconciliation price can be calculated, `None` otherwise.
795///
796/// # Notes
797///
798/// The function handles four scenarios:
799/// 1. Position to flat: `reconciliation_px` = `current_avg_px` (close at current average)
800/// 2. Flat to position: `reconciliation_px` = `target_avg_px`
801/// 3. Position flip (sign change): `reconciliation_px` = `target_avg_px` (due to value reset in simulation)
802/// 4. Accumulation/reduction: weighted average formula
803pub fn calculate_reconciliation_price(
804    current_position_qty: Decimal,
805    current_position_avg_px: Option<Decimal>,
806    target_position_qty: Decimal,
807    target_position_avg_px: Option<Decimal>,
808) -> Option<Decimal> {
809    let qty_diff = target_position_qty - current_position_qty;
810
811    if qty_diff == Decimal::ZERO {
812        return None; // No reconciliation needed
813    }
814
815    // Special case: closing to flat (target_position_qty == 0)
816    // When flattening, the reconciliation price equals the current position's average price
817    if target_position_qty == Decimal::ZERO {
818        return current_position_avg_px;
819    }
820
821    // If target average price is not provided or zero, we cannot calculate
822    let target_avg_px = target_position_avg_px?;
823    if target_avg_px == Decimal::ZERO {
824        return None;
825    }
826
827    // If current position is flat, the reconciliation price equals target avg price
828    if current_position_qty == Decimal::ZERO || current_position_avg_px.is_none() {
829        return Some(target_avg_px);
830    }
831
832    let current_avg_px = current_position_avg_px?;
833
834    // Check if this is a flip scenario (sign change)
835    // In simulation, flips reset value to remaining * px, so reconciliation_px = target_avg_px
836    let is_flip = (current_position_qty > Decimal::ZERO) != (target_position_qty > Decimal::ZERO)
837        && target_position_qty != Decimal::ZERO;
838
839    if is_flip {
840        return Some(target_avg_px);
841    }
842
843    // For accumulation or reduction (same side), use weighted average formula
844    // Formula: (target_qty * target_avg_px) = (current_qty * current_avg_px) + (qty_diff * reconciliation_px)
845    let target_value = target_position_qty * target_avg_px;
846    let current_value = current_position_qty * current_avg_px;
847    let diff_value = target_value - current_value;
848
849    // qty_diff is guaranteed non-zero by the early return above
850    let reconciliation_px = diff_value / qty_diff;
851
852    // Ensure price is positive
853    if reconciliation_px > Decimal::ZERO {
854        return Some(reconciliation_px);
855    }
856
857    None
858}
859
860/// Checks if two decimal values are within a single unit of tolerance for the given precision.
861///
862/// For integer precision (0), requires exact match.
863/// For fractional precision, allows difference of 1 unit at that precision.
864#[must_use]
865pub(super) fn is_within_single_unit_tolerance(
866    value1: Decimal,
867    value2: Decimal,
868    precision: u8,
869) -> bool {
870    if precision == 0 {
871        return value1 == value2;
872    }
873
874    let tolerance = Decimal::new(1, u32::from(precision));
875    let difference = (value1 - value2).abs();
876    difference <= tolerance
877}