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, IndexSet};
24use nautilus_common::cache::Cache;
25use nautilus_core::UnixNanos;
26use nautilus_model::{
27    enums::{
28        AvgPxReconciliation, LiquiditySide, OrderSide, OrderStatus, OrderType, PositionSide,
29        TimeInForce,
30    },
31    identifiers::{AccountId, InstrumentId, VenueOrderId},
32    instruments::{Instrument, InstrumentAny},
33    orders::Order,
34    reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
35    types::{Money, Quantity},
36};
37use rust_decimal::{Decimal, RoundingStrategy};
38
39use super::{
40    RECONCILIATION_ORDER_TAG,
41    ids::{create_synthetic_trade_id, create_synthetic_venue_order_id},
42    reconciliation_price,
43    types::{FillAdjustmentResult, FillSnapshot, ReconciliationResult, VenuePositionSnapshot},
44};
45
46const DEFAULT_TOLERANCE: Decimal = Decimal::from_parts(1, 0, 0, false, 4); // 0.0001
47
48/// Process fill reports from a mass status for position reconciliation.
49///
50/// This is the main entry point for position reconciliation. It:
51/// 1. Extracts fills and position for the given instrument
52/// 2. Detects position discrepancies
53/// 3. Returns adjusted order/fill reports ready for processing
54///
55/// # Errors
56///
57/// Returns an error if synthetic report creation fails.
58pub fn process_mass_status_for_reconciliation(
59    mass_status: &ExecutionMassStatus,
60    instrument: &InstrumentAny,
61    tolerance: Option<Decimal>,
62) -> anyhow::Result<ReconciliationResult> {
63    process_mass_status_for_reconciliation_inner(mass_status, instrument, tolerance, true)
64}
65
66/// Process fill reports without generating synthetic order or fill reports.
67///
68/// Non-generating adjustments, such as filtering completed position lifecycles, are still applied.
69///
70/// # Errors
71///
72/// Returns an error if report processing fails.
73pub fn process_mass_status_for_reconciliation_without_synthetic_reports(
74    mass_status: &ExecutionMassStatus,
75    instrument: &InstrumentAny,
76    tolerance: Option<Decimal>,
77) -> anyhow::Result<ReconciliationResult> {
78    process_mass_status_for_reconciliation_inner(mass_status, instrument, tolerance, false)
79}
80
81fn process_mass_status_for_reconciliation_inner(
82    mass_status: &ExecutionMassStatus,
83    instrument: &InstrumentAny,
84    tolerance: Option<Decimal>,
85    generate_synthetic_reports: bool,
86) -> anyhow::Result<ReconciliationResult> {
87    let instrument_id = instrument.id();
88    let account_id = mass_status.account_id;
89    let tol = tolerance.unwrap_or(DEFAULT_TOLERANCE);
90
91    let position_reports = mass_status.position_reports();
92
93    let venue_position = match position_reports.get(&instrument_id).and_then(|r| r.first()) {
94        Some(report) if report.avg_px_open.is_some() => position_report_to_snapshot(report),
95        _ => {
96            // Preserve real history when the report cannot price synthetic recovery
97            return Ok(extract_instrument_reports(mass_status, instrument_id));
98        }
99    };
100
101    let extracted = extract_fills_for_instrument(mass_status, instrument_id);
102    let fill_snapshots = extracted.snapshots;
103    let mut order_map = extracted.orders;
104    let mut fill_map = extracted.fills;
105    let mut order_only_ids = IndexSet::new();
106    let mut order_only_fill_keys = IndexSet::new();
107
108    if fill_snapshots.is_empty() {
109        return Ok(ReconciliationResult {
110            orders: order_map,
111            fills: fill_map,
112            order_only_ids,
113            order_only_fill_keys,
114        });
115    }
116
117    let result = adjust_fills_for_partial_window(&fill_snapshots, &venue_position, tol);
118
119    match result {
120        FillAdjustmentResult::NoAdjustment => {}
121
122        FillAdjustmentResult::AddSyntheticOpening {
123            synthetic_fill,
124            existing_fills: _,
125        } if generate_synthetic_reports => {
126            let venue_order_id = create_synthetic_venue_order_id(&synthetic_fill, instrument_id);
127
128            let order = create_synthetic_order_report(
129                &synthetic_fill,
130                account_id,
131                instrument_id,
132                instrument,
133                venue_order_id,
134            )?;
135            let fill = create_synthetic_fill_report(
136                &synthetic_fill,
137                account_id,
138                instrument_id,
139                instrument,
140                venue_order_id,
141            )?;
142
143            order_map.insert(venue_order_id, order);
144            fill_map.entry(venue_order_id).or_default().insert(0, fill);
145        }
146
147        FillAdjustmentResult::ReplaceCurrentLifecycle { synthetic_fill }
148            if generate_synthetic_reports =>
149        {
150            let venue_order_id = create_synthetic_venue_order_id(&synthetic_fill, instrument_id);
151
152            let order = create_synthetic_order_report(
153                &synthetic_fill,
154                account_id,
155                instrument_id,
156                instrument,
157                venue_order_id,
158            )?;
159            let fill = create_synthetic_fill_report(
160                &synthetic_fill,
161                account_id,
162                instrument_id,
163                instrument,
164                venue_order_id,
165            )?;
166
167            // Preserve reported orders for replay deduplication, their prior fills
168            // must not add exposure on top of the synthetic fill.
169            order_only_ids.extend(order_map.keys().copied());
170            fill_map.retain(|id, _| order_only_ids.contains(id));
171            order_map.insert(venue_order_id, order);
172            fill_map.insert(venue_order_id, vec![fill]);
173        }
174
175        FillAdjustmentResult::FilterToCurrentLifecycle {
176            last_zero_crossing_ts,
177            current_lifecycle_fills: _,
178        } => {
179            // Drop orders filled entirely before the current lifecycle. A spanning order keeps
180            // its earlier fills as order-only: they settle its filled quantity, not the position
181            fill_map.retain(|_, fills| {
182                if !fills
183                    .iter()
184                    .any(|f| f.ts_event.as_u64() > last_zero_crossing_ts)
185                {
186                    return false;
187                }
188                order_only_fill_keys.extend(
189                    fills
190                        .iter()
191                        .filter(|f| f.ts_event.as_u64() <= last_zero_crossing_ts)
192                        .map(|f| (f.account_id, f.instrument_id, f.trade_id)),
193                );
194                true
195            });
196
197            // Keep only orders that have fills or are still working
198            let orders_with_fills: ahash::AHashSet<VenueOrderId> =
199                fill_map.keys().copied().collect();
200            order_map.retain(|id, order| {
201                orders_with_fills.contains(id)
202                    || !matches!(
203                        order.order_status,
204                        OrderStatus::Denied
205                            | OrderStatus::Rejected
206                            | OrderStatus::Canceled
207                            | OrderStatus::Expired
208                            | OrderStatus::Filled
209                    )
210            });
211        }
212
213        FillAdjustmentResult::AddSyntheticOpening { .. }
214        | FillAdjustmentResult::ReplaceCurrentLifecycle { .. } => {}
215    }
216
217    Ok(ReconciliationResult {
218        orders: order_map,
219        fills: fill_map,
220        order_only_ids,
221        order_only_fill_keys,
222    })
223}
224
225/// Adjust fills for partial reconciliation window to handle incomplete position lifecycles.
226///
227/// This function analyzes fills and determines if adjustments are needed when the reconciliation
228/// window doesn't capture the complete position history (missing opening fills).
229///
230/// # Returns
231///
232/// Returns `FillAdjustmentResult` indicating what adjustments (if any) are needed.
233#[must_use]
234pub(super) fn adjust_fills_for_partial_window(
235    fills: &[FillSnapshot],
236    venue_position: &VenuePositionSnapshot,
237    tolerance: Decimal,
238) -> FillAdjustmentResult {
239    if fills.is_empty() {
240        return FillAdjustmentResult::NoAdjustment;
241    }
242
243    if venue_position.qty == Decimal::ZERO {
244        return FillAdjustmentResult::NoAdjustment;
245    }
246
247    let zero_crossings = detect_zero_crossings(fills);
248    let venue_qty_signed = venue_position.signed_qty();
249
250    // Case 1: Has zero-crossings - focus on current lifecycle after last zero-crossing
251    if !zero_crossings.is_empty() {
252        // Find the last zero-crossing that lands on FLAT (qty==0)
253        // This separates lifecycles; flips within a lifecycle don't count
254        let mut last_flat_crossing_ts = None;
255        let mut running_qty = Decimal::ZERO;
256
257        for fill in fills {
258            let prev_qty = running_qty;
259            running_qty += Decimal::from(fill.direction()) * fill.qty;
260
261            if prev_qty != Decimal::ZERO && running_qty == Decimal::ZERO {
262                last_flat_crossing_ts = Some(fill.ts_event);
263            }
264        }
265
266        let lifecycle_boundary_ts =
267            last_flat_crossing_ts.unwrap_or(*zero_crossings.last().unwrap());
268
269        // Get fills from current lifecycle (after lifecycle boundary)
270        let current_lifecycle_fills: Vec<FillSnapshot> = fills
271            .iter()
272            .filter(|f| f.ts_event > lifecycle_boundary_ts)
273            .cloned()
274            .collect();
275
276        if current_lifecycle_fills.is_empty() {
277            return FillAdjustmentResult::NoAdjustment;
278        }
279
280        let (current_qty, current_value) = simulate_position(&current_lifecycle_fills);
281
282        if check_position_match(current_qty, current_value, venue_position, tolerance) {
283            // Current lifecycle matches - filter out old lifecycles
284            return FillAdjustmentResult::FilterToCurrentLifecycle {
285                last_zero_crossing_ts: lifecycle_boundary_ts,
286                current_lifecycle_fills,
287            };
288        }
289
290        // Current lifecycle doesn't match - replace with synthetic fill
291        if let Some(first_fill) = current_lifecycle_fills.first() {
292            let synthetic_fill = FillSnapshot::new(
293                first_fill.venue_order_id,
294                position_to_order_side(venue_position.side),
295                venue_position.qty,
296                venue_position.avg_px,
297                first_fill.ts_event.saturating_sub(1), // Timestamp before first fill
298            );
299
300            return FillAdjustmentResult::ReplaceCurrentLifecycle { synthetic_fill };
301        }
302
303        return FillAdjustmentResult::NoAdjustment;
304    }
305
306    // Case 2: Single lifecycle or one zero-crossing
307    // Determine which fills to analyze
308    let oldest_lifecycle_fills: Vec<FillSnapshot> =
309        if let Some(&first_zero_crossing_ts) = zero_crossings.first() {
310            // Get fills before first zero-crossing
311            fills
312                .iter()
313                .filter(|f| f.ts_event <= first_zero_crossing_ts)
314                .cloned()
315                .collect()
316        } else {
317            // No zero-crossings - all fills are in single lifecycle
318            fills.to_vec()
319        };
320
321    if oldest_lifecycle_fills.is_empty() {
322        return FillAdjustmentResult::NoAdjustment;
323    }
324
325    let (oldest_qty, oldest_value) = simulate_position(&oldest_lifecycle_fills);
326
327    // If single lifecycle (no zero-crossings)
328    if zero_crossings.is_empty() {
329        if check_position_match(oldest_qty, oldest_value, venue_position, tolerance) {
330            return FillAdjustmentResult::NoAdjustment;
331        }
332
333        if let Some(first_fill) = oldest_lifecycle_fills.first() {
334            // Calculate what opening fill is needed
335            // Use simulated position as current, venue position as target
336            let oldest_avg_px = if oldest_qty == Decimal::ZERO {
337                None
338            } else {
339                Some(oldest_value / oldest_qty.abs())
340            };
341
342            let reconciliation_price =
343                synthetic_opening_price(oldest_qty, oldest_avg_px, venue_position);
344
345            if let Some(opening_px) = reconciliation_price {
346                // Calculate opening quantity needed
347                let opening_qty = if oldest_qty == Decimal::ZERO {
348                    venue_qty_signed
349                } else {
350                    // Work backwards: venue = opening + current fills
351                    venue_qty_signed - oldest_qty
352                };
353
354                if opening_qty.abs() > Decimal::ZERO {
355                    let synthetic_side = if opening_qty > Decimal::ZERO {
356                        OrderSide::Buy
357                    } else {
358                        OrderSide::Sell
359                    };
360
361                    let synthetic_fill = FillSnapshot::new(
362                        first_fill.venue_order_id,
363                        synthetic_side,
364                        opening_qty.abs(),
365                        opening_px,
366                        first_fill.ts_event.saturating_sub(1),
367                    );
368
369                    return FillAdjustmentResult::AddSyntheticOpening {
370                        synthetic_fill,
371                        existing_fills: oldest_lifecycle_fills,
372                    };
373                }
374            }
375        }
376
377        return FillAdjustmentResult::NoAdjustment;
378    }
379
380    // Has one zero-crossing - check if oldest lifecycle closes at zero
381    if oldest_qty == Decimal::ZERO {
382        // Lifecycle closes correctly - no adjustment needed
383        return FillAdjustmentResult::NoAdjustment;
384    }
385
386    // Oldest lifecycle doesn't close at zero - add synthetic opening fill
387    if !oldest_lifecycle_fills.is_empty()
388        && let Some(&first_zero_crossing_ts) = zero_crossings.first()
389    {
390        // Need to add opening fill that makes position close at zero-crossing
391        let current_lifecycle_fills: Vec<FillSnapshot> = fills
392            .iter()
393            .filter(|f| f.ts_event > first_zero_crossing_ts)
394            .cloned()
395            .collect();
396
397        if !current_lifecycle_fills.is_empty()
398            && let Some(first_current_fill) = current_lifecycle_fills.first()
399        {
400            let synthetic_fill = FillSnapshot::new(
401                first_current_fill.venue_order_id,
402                position_to_order_side(venue_position.side),
403                venue_position.qty,
404                venue_position.avg_px,
405                first_current_fill.ts_event.saturating_sub(1),
406            );
407
408            return FillAdjustmentResult::AddSyntheticOpening {
409                synthetic_fill,
410                existing_fills: oldest_lifecycle_fills,
411            };
412        }
413    }
414
415    FillAdjustmentResult::NoAdjustment
416}
417
418fn synthetic_opening_price(
419    window_qty: Decimal,
420    window_avg_px: Option<Decimal>,
421    venue_position: &VenuePositionSnapshot,
422) -> Option<Decimal> {
423    match venue_position.avg_px_reconciliation {
424        // The synthetic fill opens the position before the window's fills
425        AvgPxReconciliation::OpeningOnly => Some(venue_position.avg_px),
426        AvgPxReconciliation::Match => calculate_reconciliation_price(
427            window_qty,
428            window_avg_px,
429            venue_position.signed_qty(),
430            Some(venue_position.avg_px),
431            AvgPxReconciliation::Match,
432            venue_position.avg_px_precision,
433        ),
434    }
435}
436
437/// Check if a cached position matches the venue position report within tolerance.
438///
439/// Compares the cached signed quantity against the venue-reported signed quantity,
440/// allowing a single-unit tolerance at the instrument's size precision when provided.
441///
442/// # Returns
443///
444/// Returns `true` when positions match (within tolerance), `false` otherwise. Logs
445/// a debug message when values differ but fall within tolerance, and a warning
446/// when they fail to reconcile.
447pub fn check_position_reconciliation(
448    report: &PositionStatusReport,
449    cached_signed_qty: Decimal,
450    size_precision: Option<u8>,
451) -> bool {
452    let venue_signed_qty = report.signed_decimal_qty;
453
454    if venue_signed_qty == Decimal::ZERO && cached_signed_qty == Decimal::ZERO {
455        return true;
456    }
457
458    if let Some(precision) = size_precision
459        && is_within_single_unit_tolerance(cached_signed_qty, venue_signed_qty, precision)
460    {
461        log::debug!(
462            "Position for {} within tolerance: cached={}, venue={}",
463            report.instrument_id,
464            cached_signed_qty,
465            venue_signed_qty
466        );
467        return true;
468    }
469
470    if cached_signed_qty == venue_signed_qty {
471        return true;
472    }
473
474    log::warn!(
475        "Position discrepancy for {}: cached={}, venue={}",
476        report.instrument_id,
477        cached_signed_qty,
478        venue_signed_qty
479    );
480
481    false
482}
483
484/// Returns whether `report` predates an open position for its instrument and account that a
485/// synthetic reconciliation order opened and that does not hold the fill's trade.
486///
487/// The venue position report behind such a position already includes the fill, so applying it
488/// to the position would count the execution twice.
489#[must_use]
490pub fn fill_precedes_snapshot_reconciled_position(cache: &Cache, report: &FillReport) -> bool {
491    cache
492        .positions_open(
493            None,
494            Some(&report.instrument_id),
495            None,
496            Some(&report.account_id),
497            None,
498        )
499        .into_iter()
500        .any(|position| {
501            report.ts_event < position.ts_opened
502                && !position.trade_ids.contains(&report.trade_id)
503                && cache
504                    .order(&position.opening_order_id)
505                    .is_some_and(|order| {
506                        order.tags().is_some_and(|tags| {
507                            tags.iter()
508                                .any(|tag| tag.as_str() == RECONCILIATION_ORDER_TAG)
509                        })
510                    })
511        })
512}
513
514/// Caps a price at the instrument's maximum price.
515///
516/// Reconciliation derives synthetic prices by dividing a notional by a
517/// quantity; when that quantity is a dust rounding residual the result blows up
518/// far above the instrument's tradeable range. Capping at `max_price` keeps
519/// synthetic reports, and the positions derived from them, within range.
520/// Because the blown-up price always pairs with a dust quantity (the
521/// denominator) the PnL impact is negligible. Only the upper bound is enforced:
522/// the blow-up is always on the high side, so the lower bound is left untouched
523/// and legitimate negative-price and zero-cost fills pass through unchanged. A
524/// missing `max_price` leaves the price unmodified.
525///
526/// The cap is `max_price` floored to the instrument's price precision, so that
527/// rebuilding a `Price` at that precision (which rounds) cannot lift the result
528/// back above `max_price`. For example a 2 dp instrument with `max_price = 0.999`
529/// caps at `0.99`, since `0.999` (or any value in `(0.99, 1.00)`) rebuilt at 2 dp
530/// would round to `1.00`.
531pub(super) fn cap_price_at_instrument_max(px: Decimal, instrument: &InstrumentAny) -> Decimal {
532    let Some(max) = instrument.max_price() else {
533        return px;
534    };
535    let cap = max.as_decimal().round_dp_with_strategy(
536        u32::from(instrument.price_precision()),
537        RoundingStrategy::ToZero,
538    );
539    px.min(cap)
540}
541
542/// Create a synthetic `OrderStatusReport` from a `FillSnapshot`.
543///
544/// Populates `avg_px` from the fill's price so downstream reconciliation paths
545/// (e.g. [`crate::reconciliation::orders::create_inferred_fill`]) can resolve a
546/// fill price without falling back to the "no `avg_px`, report price, or order price" warning.
547///
548/// # Errors
549///
550/// Returns an error if the fill quantity cannot be converted to f64.
551pub(super) fn create_synthetic_order_report(
552    fill: &FillSnapshot,
553    account_id: AccountId,
554    instrument_id: InstrumentId,
555    instrument: &InstrumentAny,
556    venue_order_id: VenueOrderId,
557) -> anyhow::Result<OrderStatusReport> {
558    let order_qty = Quantity::from_decimal_dp(fill.qty, instrument.size_precision())?;
559
560    let mut report = OrderStatusReport::new(
561        account_id,
562        instrument_id,
563        None, // client_order_id
564        venue_order_id,
565        fill.side.into(),
566        OrderType::Market,
567        TimeInForce::Gtc,
568        OrderStatus::Filled,
569        order_qty,
570        order_qty, // filled_qty = order_qty (fully filled)
571        UnixNanos::from(fill.ts_event),
572        UnixNanos::from(fill.ts_event),
573        UnixNanos::from(fill.ts_event),
574        None, // report_id
575    );
576    report.avg_px = Some(cap_price_at_instrument_max(fill.px, instrument));
577    Ok(report)
578}
579
580/// Create a synthetic `FillReport` from a `FillSnapshot`.
581///
582/// # Errors
583///
584/// Returns an error if the fill quantity or price cannot be converted.
585pub(super) fn create_synthetic_fill_report(
586    fill: &FillSnapshot,
587    account_id: AccountId,
588    instrument_id: InstrumentId,
589    instrument: &InstrumentAny,
590    venue_order_id: VenueOrderId,
591) -> anyhow::Result<FillReport> {
592    let trade_id = create_synthetic_trade_id(fill);
593    let qty = Quantity::from_decimal_dp(fill.qty, instrument.size_precision())?;
594    let px = reconciliation_price(cap_price_at_instrument_max(fill.px, instrument), instrument)?;
595
596    Ok(FillReport::new(
597        account_id,
598        instrument_id,
599        venue_order_id,
600        trade_id,
601        fill.side,
602        qty,
603        px,
604        Money::zero(instrument.quote_currency()),
605        LiquiditySide::NoLiquiditySide,
606        None, // client_order_id
607        None, // venue_position_id
608        fill.ts_event.into(),
609        fill.ts_event.into(),
610        None, // report_id
611    ))
612}
613
614/// Convert a position status report to a venue position snapshot.
615fn position_report_to_snapshot(report: &PositionStatusReport) -> VenuePositionSnapshot {
616    VenuePositionSnapshot {
617        side: report.position_side,
618        qty: report.quantity.into(),
619        avg_px: report.avg_px_open.unwrap_or(Decimal::ZERO),
620        avg_px_reconciliation: report.avg_px_open_reconciliation,
621        avg_px_precision: report.avg_px_open_precision,
622    }
623}
624
625/// Map a position side to the order side that would open it.
626///
627/// Callers must guard `Flat` upstream; reaching it here means a flat venue
628/// position slipped past the qty==0 early returns in
629/// [`adjust_fills_for_partial_window`].
630fn position_to_order_side(side: PositionSide) -> OrderSide {
631    match side {
632        PositionSide::Long => OrderSide::Buy,
633        PositionSide::Short => OrderSide::Sell,
634        PositionSide::Flat => {
635            unreachable!("flat venue position must be guarded by an earlier check")
636        }
637    }
638}
639
640/// Extract orders and fills for a specific instrument from mass status.
641fn extract_instrument_reports(
642    mass_status: &ExecutionMassStatus,
643    instrument_id: InstrumentId,
644) -> ReconciliationResult {
645    let mut orders = IndexMap::new();
646    let mut fills = IndexMap::new();
647
648    for (id, order) in mass_status.order_reports() {
649        if order.instrument_id == instrument_id {
650            orders.insert(id, order.clone());
651        }
652    }
653
654    for (id, fill_list) in mass_status.fill_reports() {
655        let filtered: Vec<_> = fill_list
656            .iter()
657            .filter(|f| {
658                if f.instrument_id != instrument_id {
659                    return false;
660                }
661
662                if f.last_qty.is_zero() {
663                    log::warn!("Skipping zero-quantity fill report: {f}");
664                    return false;
665                }
666
667                true
668            })
669            .cloned()
670            .collect();
671
672        if !filtered.is_empty() {
673            fills.insert(id, filtered);
674        }
675    }
676
677    ReconciliationResult {
678        orders,
679        fills,
680        order_only_ids: IndexSet::new(),
681        order_only_fill_keys: IndexSet::new(),
682    }
683}
684
685/// Extracted fills and reports for an instrument.
686struct ExtractedFills {
687    snapshots: Vec<FillSnapshot>,
688    orders: IndexMap<VenueOrderId, OrderStatusReport>,
689    fills: IndexMap<VenueOrderId, Vec<FillReport>>,
690}
691
692/// Extract fills for an instrument and convert to snapshots.
693fn extract_fills_for_instrument(
694    mass_status: &ExecutionMassStatus,
695    instrument_id: InstrumentId,
696) -> ExtractedFills {
697    let mut snapshots = Vec::new();
698    let mut order_map = IndexMap::new();
699    let mut fill_map = IndexMap::new();
700
701    // Seed order_map
702    for (id, order) in mass_status.order_reports() {
703        if order.instrument_id == instrument_id {
704            order_map.insert(id, order.clone());
705        }
706    }
707
708    // Extract fills
709    for (venue_order_id, fill_reports) in mass_status.fill_reports() {
710        for fill in fill_reports {
711            if fill.instrument_id == instrument_id {
712                if fill.last_qty.is_zero() {
713                    log::warn!("Skipping zero-quantity fill report: {fill}");
714                    continue;
715                }
716
717                let side = mass_status
718                    .order_reports()
719                    .get(&venue_order_id)
720                    .and_then(|order| order.order_side)
721                    .unwrap_or(fill.order_side);
722
723                snapshots.push(FillSnapshot::new(
724                    venue_order_id,
725                    side,
726                    fill.last_qty.into(),
727                    fill.last_px.into(),
728                    fill.ts_event.as_u64(),
729                ));
730
731                fill_map
732                    .entry(venue_order_id)
733                    .or_insert_with(Vec::new)
734                    .push(fill.clone());
735            }
736        }
737    }
738
739    // Sort chronologically
740    snapshots.sort_by_key(|f| f.ts_event);
741
742    ExtractedFills {
743        snapshots,
744        orders: order_map,
745        fills: fill_map,
746    }
747}
748
749/// Simulate position from chronologically ordered fills using netting logic.
750///
751/// # Returns
752///
753/// Returns a tuple of (quantity, value) after applying all fills.
754#[must_use]
755pub(super) fn simulate_position(fills: &[FillSnapshot]) -> (Decimal, Decimal) {
756    let mut qty = Decimal::ZERO;
757    let mut value = Decimal::ZERO;
758
759    for fill in fills {
760        debug_assert!(
761            fill.qty > Decimal::ZERO,
762            "fill snapshot qty must be positive, received {}",
763            fill.qty,
764        );
765        let direction = Decimal::from(fill.direction());
766        let new_qty = qty + (direction * fill.qty);
767
768        // Check if we're accumulating or crossing zero (flip/close)
769        if (qty >= Decimal::ZERO && direction > Decimal::ZERO)
770            || (qty <= Decimal::ZERO && direction < Decimal::ZERO)
771        {
772            // Accumulating in same direction
773            value += fill.qty * fill.px;
774            qty = new_qty;
775        } else {
776            // Closing or flipping position
777            if qty.abs() >= fill.qty {
778                // Partial close - maintain average price by reducing value proportionally
779                let close_ratio = fill.qty / qty.abs();
780                value *= Decimal::ONE - close_ratio;
781                qty = new_qty;
782            } else {
783                // Close and flip - reset value to opening position
784                let remaining = fill.qty - qty.abs();
785                qty = direction * remaining;
786                value = remaining * fill.px;
787            }
788        }
789    }
790
791    debug_assert!(
792        value >= Decimal::ZERO,
793        "simulated position value must be non-negative, was {value}",
794    );
795    debug_assert!(
796        !(qty != Decimal::ZERO && value.is_sign_negative()),
797        "simulated avg price invariant: qty={qty}, value={value}",
798    );
799
800    (qty, value)
801}
802
803/// Detect zero-crossing timestamps in a sequence of fills.
804///
805/// A zero-crossing occurs when position quantity crosses through zero (FLAT).
806/// This includes both landing exactly on zero and flipping from long to short or vice versa.
807///
808/// # Returns
809///
810/// Returns a list of timestamps where position crosses through zero.
811#[must_use]
812pub(super) fn detect_zero_crossings(fills: &[FillSnapshot]) -> Vec<u64> {
813    let mut running_qty = Decimal::ZERO;
814    let mut zero_crossings = Vec::new();
815
816    for fill in fills {
817        let prev_qty = running_qty;
818        running_qty += Decimal::from(fill.direction()) * fill.qty;
819
820        // Detect when position crosses zero
821        if prev_qty != Decimal::ZERO {
822            if running_qty == Decimal::ZERO {
823                // Landed exactly on zero
824                zero_crossings.push(fill.ts_event);
825            } else if (prev_qty > Decimal::ZERO) != (running_qty > Decimal::ZERO) {
826                // Sign changed - crossed through zero (flip)
827                zero_crossings.push(fill.ts_event);
828            }
829        }
830    }
831
832    zero_crossings
833}
834
835/// Check if simulated position matches venue position within tolerance.
836///
837/// An [`AvgPxReconciliation::OpeningOnly`] venue average is not comparable, so only
838/// quantities must match.
839///
840/// # Returns
841///
842/// Returns true if quantities and average prices match within tolerance.
843#[must_use]
844pub(super) fn check_position_match(
845    simulated_qty: Decimal,
846    simulated_value: Decimal,
847    venue_position: &VenuePositionSnapshot,
848    tolerance: Decimal,
849) -> bool {
850    if simulated_qty != venue_position.signed_qty() {
851        return false;
852    }
853
854    if simulated_qty == Decimal::ZERO {
855        return true; // Both FLAT
856    }
857
858    if venue_position.avg_px_reconciliation == AvgPxReconciliation::OpeningOnly {
859        return true;
860    }
861
862    // Guard against division by zero
863    let abs_qty = simulated_qty.abs();
864    if abs_qty == Decimal::ZERO {
865        return false;
866    }
867
868    let simulated_avg_px = simulated_value / abs_qty;
869
870    position_prices_match(
871        simulated_avg_px,
872        venue_position.avg_px,
873        Some(tolerance),
874        venue_position.avg_px_precision,
875    )
876}
877
878/// Compares average entry prices using relative tolerance (default 0.01%).
879///
880/// Average entry prices need not fall on instrument price ticks. A zero reported
881/// average cannot establish a relative comparison and returns `false`. When the venue
882/// rounds or truncates its average to `venue_precision` decimal places, the difference may
883/// also span one unit at that precision on top of the relative tolerance; a precision above
884/// 28 adds no allowance.
885#[must_use]
886pub fn position_prices_match(
887    cached_avg_px: Decimal,
888    venue_avg_px: Decimal,
889    tolerance: Option<Decimal>,
890    venue_precision: Option<u8>,
891) -> bool {
892    if venue_avg_px.is_zero() {
893        return false;
894    }
895
896    let tolerance = tolerance.unwrap_or(DEFAULT_TOLERANCE);
897    let diff = (cached_avg_px - venue_avg_px).abs();
898
899    if diff / venue_avg_px.abs() <= tolerance {
900        return true;
901    }
902
903    venue_precision.is_some_and(|precision| {
904        let unit = Decimal::try_new(1, u32::from(precision)).unwrap_or(Decimal::ZERO);
905        diff <= tolerance * venue_avg_px.abs() + unit
906    })
907}
908
909/// Calculate the price needed for a reconciliation order to achieve target position.
910///
911/// This is a pure function that calculates what price a fill would need to have
912/// to move from the current position state to the target position state. By default, a
913/// reduction uses the reported average price but cannot change the remaining position's entry
914/// average.
915///
916/// A same-side reduction or accumulation instead keeps the current average as its price when the
917/// target average is [`AvgPxReconciliation::OpeningOnly`], or when the target average carries a
918/// precision and already matches the current average, so neither a different averaging method
919/// nor venue rounding fabricates a fill price. This override takes precedence over scenarios 4
920/// and 5 below.
921///
922/// # Returns
923///
924/// Returns `Some(Decimal)` if a valid reconciliation price can be calculated, `None` otherwise.
925///
926/// # Notes
927///
928/// The function handles five scenarios:
929/// 1. Position to flat: `reconciliation_px` = `current_avg_px` (close at current average)
930/// 2. Flat to position: `reconciliation_px` = `target_avg_px`
931/// 3. Position flip (sign change): `reconciliation_px` = `target_avg_px` (due to value reset in simulation)
932/// 4. Reduction: target average price by default (remaining entry average is unchanged)
933/// 5. Accumulation: weighted average formula by default
934pub fn calculate_reconciliation_price(
935    current_position_qty: Decimal,
936    current_position_avg_px: Option<Decimal>,
937    target_position_qty: Decimal,
938    target_position_avg_px: Option<Decimal>,
939    target_avg_px_reconciliation: AvgPxReconciliation,
940    target_avg_px_precision: Option<u8>,
941) -> Option<Decimal> {
942    let qty_diff = target_position_qty - current_position_qty;
943
944    if qty_diff == Decimal::ZERO {
945        return None; // No reconciliation needed
946    }
947
948    let is_same_side = current_position_qty != Decimal::ZERO
949        && target_position_qty != Decimal::ZERO
950        && (current_position_qty > Decimal::ZERO) == (target_position_qty > Decimal::ZERO);
951
952    if is_same_side
953        && let Some(current_avg_px) = current_position_avg_px
954        && keeps_current_avg_px(
955            current_avg_px,
956            target_position_avg_px,
957            target_avg_px_reconciliation,
958            target_avg_px_precision,
959        )
960    {
961        return Some(current_avg_px);
962    }
963
964    // Special case: closing to flat (target_position_qty == 0)
965    // When flattening, the reconciliation price equals the current position's average price
966    if target_position_qty == Decimal::ZERO {
967        return current_position_avg_px;
968    }
969
970    // If target average price is not provided or zero, we cannot calculate
971    let target_avg_px = target_position_avg_px?;
972    if target_avg_px == Decimal::ZERO {
973        return None;
974    }
975
976    // If current position is flat, the reconciliation price equals target avg price
977    if current_position_qty == Decimal::ZERO || current_position_avg_px.is_none() {
978        return Some(target_avg_px);
979    }
980
981    let current_avg_px = current_position_avg_px?;
982
983    // Check if this is a flip scenario (sign change)
984    // In simulation, flips reset value to remaining * px, so reconciliation_px = target_avg_px
985    let is_flip = (current_position_qty > Decimal::ZERO) != (target_position_qty > Decimal::ZERO)
986        && target_position_qty != Decimal::ZERO;
987
988    if is_flip {
989        return Some(target_avg_px);
990    }
991
992    if target_position_qty.abs() < current_position_qty.abs() {
993        return Some(target_avg_px);
994    }
995
996    // For accumulation, use weighted average formula
997    // Formula: (target_qty * target_avg_px) = (current_qty * current_avg_px) + (qty_diff * reconciliation_px)
998    let target_value = target_position_qty * target_avg_px;
999    let current_value = current_position_qty * current_avg_px;
1000    let diff_value = target_value - current_value;
1001
1002    // qty_diff is guaranteed non-zero by the early return above
1003    let reconciliation_px = diff_value / qty_diff;
1004
1005    // Ensure price is positive
1006    if reconciliation_px > Decimal::ZERO {
1007        return Some(reconciliation_px);
1008    }
1009
1010    None
1011}
1012
1013fn keeps_current_avg_px(
1014    current_avg_px: Decimal,
1015    target_avg_px: Option<Decimal>,
1016    target_avg_px_reconciliation: AvgPxReconciliation,
1017    target_avg_px_precision: Option<u8>,
1018) -> bool {
1019    match target_avg_px_reconciliation {
1020        AvgPxReconciliation::OpeningOnly => true,
1021        AvgPxReconciliation::Match => {
1022            target_avg_px_precision.is_some()
1023                && target_avg_px.is_some_and(|target_avg_px| {
1024                    position_prices_match(
1025                        current_avg_px,
1026                        target_avg_px,
1027                        None,
1028                        target_avg_px_precision,
1029                    )
1030                })
1031        }
1032    }
1033}
1034
1035/// Checks if two decimal values are within a single unit of tolerance for the given precision.
1036///
1037/// For integer precision (0), requires exact match.
1038/// For fractional precision, allows difference of 1 unit at that precision.
1039#[must_use]
1040pub(super) fn is_within_single_unit_tolerance(
1041    value1: Decimal,
1042    value2: Decimal,
1043    precision: u8,
1044) -> bool {
1045    if precision == 0 {
1046        return value1 == value2;
1047    }
1048
1049    let tolerance = Decimal::new(1, u32::from(precision));
1050    let difference = (value1 - value2).abs();
1051    difference <= tolerance
1052}