Skip to main content

nautilus_live/execution/
reconciliation.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Reconciliation snapshots, outcomes, state-independent decisions, and targeted report collection.
17//!
18//! Shared types describe prepared checks and report results. Functions compare reports,
19//! validate fill groups, replay inferred-fill history, and calculate reconciliation quantities.
20//! Targeted requests collect order status and missing fills for the manager and live node.
21//! The manager owns cache-dependent decisions; the live node owns recurring task lifecycles.
22
23use std::{str::FromStr, time::Duration};
24
25use indexmap::{IndexMap, IndexSet};
26use nautilus_common::{
27    clients::ExecutionClient,
28    enums::LogLevel,
29    live::dst,
30    messages::execution::{
31        TradingCommand,
32        report::{
33            GenerateFillReports, GenerateOrderStatusReport, GenerateOrderStatusReports,
34            GeneratePositionStatusReports,
35        },
36    },
37};
38use nautilus_core::{UUID4, UnixNanos};
39use nautilus_execution::reconciliation::{
40    create_position_reconciliation_venue_order_id, inferred_reconciliation_trade_ids,
41    should_reconciliation_update,
42};
43use nautilus_model::{
44    enums::{LiquiditySide, OrderSide, OrderStatus, OrderType, TimeInForce},
45    events::{OrderCanceled, OrderEventAny, OrderFilled},
46    identifiers::{
47        AccountId, ClientId, ClientOrderId, InstrumentId, PositionId, StrategyId, TradeId, Venue,
48        VenueOrderId,
49    },
50    instruments::{Instrument, InstrumentAny},
51    orders::{Order, OrderAny},
52    position::Position,
53    reports::{FillReport, OrderStatusReport, PositionStatusReport},
54    types::{Money, Price, Quantity},
55};
56use rust_decimal::Decimal;
57
58/// Composite key identifying a position context by instrument and account.
59///
60/// Used to scope per-position reconciliation state (retry counters, activity
61/// throttles, venue report lookups) so that multiple accounts holding the same
62/// instrument do not share the same tracking entry.
63pub type InstrumentAccountKey = (InstrumentId, AccountId);
64pub(super) type AccountInstrumentStrategyKey = (AccountId, InstrumentId, StrategyId);
65pub(super) type FillKey = (AccountId, InstrumentId, TradeId);
66
67/// Execution clients responsible for reporting one cached entity.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum ReportClientCoverage {
70    /// Every identified client provides the required report coverage.
71    Resolved(IndexSet<ClientId>),
72    /// Identified clients cannot provide the required report coverage.
73    Unavailable(IndexSet<ClientId>),
74    /// No responsible client could be identified.
75    Unresolved,
76}
77
78/// Metadata for an external order that needs to be registered with the execution client.
79#[derive(Debug, Clone)]
80pub struct ExternalOrderMetadata {
81    pub client_order_id: ClientOrderId,
82    pub venue_order_id: VenueOrderId,
83    pub instrument_id: InstrumentId,
84    pub strategy_id: StrategyId,
85    pub ts_init: UnixNanos,
86}
87
88/// Result of reconciliation containing events, external orders, and unresolved position diagnostics.
89#[derive(Debug, Default)]
90pub struct ReconciliationResult {
91    /// Order events generated during reconciliation.
92    pub events: Vec<OrderEventAny>,
93    /// External orders that need to be registered with execution clients.
94    pub external_orders: Vec<ExternalOrderMetadata>,
95    /// Diagnostics for in-scope nonzero venue positions unrecovered after event processing.
96    pub unresolved_positions: Vec<String>,
97}
98
99/// Result of inflight order checks containing terminal events and intermediate queries.
100#[derive(Debug, Default)]
101pub struct InflightCheckResult {
102    /// Terminal events (rejection/cancellation) for orders that exceeded max retries.
103    pub events: Vec<OrderEventAny>,
104    /// Intermediate venue queries for orders still within retry budget.
105    pub queries: Vec<TradingCommand>,
106}
107
108/// Events and targeted queries produced by open-order reconciliation.
109#[derive(Debug, Default)]
110pub(crate) struct OpenOrderReconciliationResult {
111    pub events: Vec<OrderEventAny>,
112    pub targeted_queries: Vec<TargetedOrderQuery>,
113}
114
115/// Order snapshot and client coverage for a targeted status query.
116#[derive(Debug, Clone)]
117pub(crate) struct TargetedOrderQuery {
118    pub(super) client_order_id: ClientOrderId,
119    pub(super) responsible_clients: IndexSet<ClientId>,
120    pub(super) command: GenerateOrderStatusReport,
121    pub(super) report: Option<OrderStatusReport>,
122    pub(super) filled_qty: Quantity,
123}
124
125impl TargetedOrderQuery {
126    /// Returns the order identifier for the targeted query.
127    pub(crate) const fn client_order_id(&self) -> ClientOrderId {
128        self.client_order_id
129    }
130}
131
132/// Targeted status query result with fills and coverage completeness.
133#[derive(Debug)]
134pub(crate) struct TargetedOrderReportResult {
135    pub(super) client_order_id: ClientOrderId,
136    pub(super) client_id: Option<ClientId>,
137    pub(super) report: Option<OrderStatusReport>,
138    pub(super) fills: Vec<FillReport>,
139    pub(super) coverage_complete: bool,
140}
141
142/// Order status report paired with its source execution client.
143#[derive(Debug)]
144pub(crate) struct SourcedOrderStatusReport {
145    pub client_id: ClientId,
146    pub report: OrderStatusReport,
147}
148
149/// Snapshot and command for one continuous open-order reconciliation check.
150#[derive(Debug, Clone)]
151pub(crate) struct OpenOrderReportCheck {
152    pub command: GenerateOrderStatusReports,
153    pub filtered_orders: Vec<OrderAny>,
154    pub client_coverage: IndexMap<ClientOrderId, ReportClientCoverage>,
155}
156
157/// Prepare-time state and command for one continuous position reconciliation check.
158#[derive(Debug, Clone)]
159pub struct PositionReportCheck {
160    /// The bulk position query.
161    pub command: GeneratePositionStatusReports,
162    /// Responsible clients by instrument and account.
163    pub client_coverage: IndexMap<InstrumentAccountKey, ReportClientCoverage>,
164    /// Activity revisions captured before the query.
165    pub activity_revisions: IndexMap<InstrumentAccountKey, u64>,
166}
167
168/// Fill report request for one instrument, account, and execution client.
169#[derive(Debug)]
170pub struct PositionFillReportQuery {
171    /// The instrument and account to reconcile.
172    pub key: InstrumentAccountKey,
173    /// The responsible execution client.
174    pub client_id: ClientId,
175    /// The authoritative fill query.
176    pub command: GenerateFillReports,
177}
178
179/// Fill queries and discrepancy keys for a position reconciliation check.
180#[derive(Debug)]
181pub struct PositionFillReportPlan {
182    /// Authoritative fill queries that are safe to run.
183    pub queries: Vec<PositionFillReportQuery>,
184    /// Position keys that still differ from the venue snapshot.
185    pub discrepancy_keys: IndexSet<InstrumentAccountKey>,
186}
187
188/// Whether a fill is attributable and free of inferred-fill or reconciled-position overlap.
189#[derive(Debug)]
190pub enum PositionFillReportPreparation {
191    /// The report can be applied to the cached execution state.
192    Ready,
193    /// An active inferred fill prevents authoritative replay.
194    InferredOverlap,
195    /// A position reconciled from a venue position report already includes the fill.
196    SnapshotOverlap,
197    /// A hedge fill cannot be assigned to an unambiguous position.
198    Unattributed,
199}
200
201/// Cached and venue position quantities and report shape for comparison.
202pub(super) struct PositionQuantityComparison {
203    pub(super) cached_positions: Vec<Position>,
204    pub(super) cached_signed_qty: Decimal,
205    pub(super) cached_long_qty: Decimal,
206    pub(super) cached_short_qty: Decimal,
207    pub(super) venue_signed_qty: Decimal,
208    pub(super) venue_long_qty: Decimal,
209    pub(super) venue_short_qty: Decimal,
210    pub(super) nonflat_count: usize,
211    pub(super) venue_report: Option<PositionStatusReport>,
212    pub(super) venue_has_side_reports: bool,
213}
214
215impl PositionQuantityComparison {
216    /// Checks net quantities and, when both venue sides are reported, side quantities.
217    pub(super) fn quantities_match(&self, tolerance: Decimal) -> bool {
218        let net_qty_matches = (self.cached_signed_qty - self.venue_signed_qty).abs() <= tolerance;
219        let side_qty_matches = (self.cached_long_qty - self.venue_long_qty).abs() <= tolerance
220            && (self.cached_short_qty - self.venue_short_qty).abs() <= tolerance;
221
222        net_qty_matches && (!self.venue_has_side_reports || side_qty_matches)
223    }
224
225    /// Classifies venue reports as a single unambiguous position or multiple legs.
226    pub(super) fn report_shape(&self) -> PositionReportShape {
227        if self.nonflat_count > 1 || self.venue_has_side_reports {
228            PositionReportShape::MultiLeg
229        } else {
230            PositionReportShape::Unambiguous
231        }
232    }
233}
234
235/// Cached fill identities, missing orders, and netting lifecycle boundaries.
236pub(super) struct RetainedFillState {
237    pub(super) fill_keys: IndexSet<FillKey>,
238    pub(super) missing_order_ids: IndexSet<(AccountId, InstrumentId, ClientOrderId)>,
239    pub(super) missing_venue_order_ids: IndexSet<(AccountId, InstrumentId, VenueOrderId)>,
240    pub(super) netting_lifecycle_starts: IndexMap<AccountInstrumentStrategyKey, UnixNanos>,
241}
242
243/// Tracks pending fill identities and their generated reconciliation events.
244#[derive(Default)]
245pub(super) struct ReconciliationFillQueue {
246    pub(super) pending_fill_keys: IndexSet<FillKey>,
247    pub(super) event_fill_keys: IndexMap<UUID4, FillKey>,
248}
249
250impl ReconciliationFillQueue {
251    /// Queues a fill event and records its identity for deduplication.
252    pub(super) fn push(
253        &mut self,
254        events: &mut Vec<OrderEventAny>,
255        fill: OrderFilled,
256        fill_key: FillKey,
257    ) {
258        self.pending_fill_keys.insert(fill_key);
259        self.event_fill_keys.insert(fill.event_id, fill_key);
260        events.push(OrderEventAny::Filled(fill));
261    }
262}
263
264/// Information about an inflight order check.
265#[derive(Debug, Clone)]
266pub(super) struct InflightCheck {
267    pub submitted_at: dst::time::Instant,
268    pub retry_count: u32,
269    // `Instant` debug output is runtime-specific and intentionally only useful
270    // as an opaque monotonic offset.
271    pub last_query_at: Option<dst::time::Instant>,
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
275pub(super) enum PositionReportShape {
276    Unambiguous,
277    MultiLeg,
278}
279
280/// Retry count and report shape for position reconciliation.
281#[derive(Debug, Clone, Copy)]
282pub(super) struct PositionReconciliationState {
283    pub(super) report_shape: PositionReportShape,
284    pub(super) retries: u32,
285}
286
287/// Requests targeted order status and missing fills from responsible clients.
288pub(crate) async fn request_targeted_order_reports(
289    queries: Vec<TargetedOrderQuery>,
290    clients: &[&dyn ExecutionClient],
291    query_delay: Duration,
292) -> Vec<TargetedOrderReportResult> {
293    let mut results = Vec::with_capacity(queries.len());
294    let mut request_count = 0usize;
295
296    for mut query in queries {
297        let mut report = None;
298        let mut fills = Vec::new();
299        let mut report_client_id = None;
300        let mut coverage_complete = true;
301
302        for client_id in &query.responsible_clients {
303            let client_id = *client_id;
304
305            let Some(client) = clients
306                .iter()
307                .find(|client| client.client_id() == client_id)
308            else {
309                coverage_complete = false;
310                log::warn!(
311                    "Cannot run targeted order status query for {}: execution client {client_id} is unavailable",
312                    query.client_order_id,
313                );
314                continue;
315            };
316
317            if request_count > 0 && !query_delay.is_zero() {
318                dst::time::sleep(query_delay).await;
319            }
320
321            request_count += 1;
322
323            let response = if let Some(report) = query.report.take() {
324                Ok(Some(report))
325            } else {
326                client.generate_order_status_report(&query.command).await
327            };
328
329            match response {
330                Ok(Some(candidate)) if targeted_report_matches(&query, &candidate) => {
331                    if terminal_report_has_missing_fills(&candidate, query.filled_qty) {
332                        let mut command = GenerateFillReports::new(
333                            UUID4::new(),
334                            query.command.ts_init,
335                            Some(candidate.instrument_id),
336                            Some(candidate.venue_order_id),
337                            None,
338                            None,
339                            None,
340                            Some(query.command.command_id),
341                        );
342                        command.log_receipt_level = LogLevel::Debug;
343
344                        match client.generate_fill_reports(command).await {
345                            Ok(reports) => {
346                                fills = reports
347                                    .into_iter()
348                                    .filter(|fill| {
349                                        fill.account_id == candidate.account_id
350                                            && fill.instrument_id == candidate.instrument_id
351                                            && fill.venue_order_id == candidate.venue_order_id
352                                            && candidate
353                                                .order_side
354                                                .is_none_or(|side| fill.order_side == side)
355                                    })
356                                    .collect();
357                            }
358                            Err(e) => log::warn!(
359                                "Failed fill report query from {client_id} for {}: {e}",
360                                query.client_order_id,
361                            ),
362                        }
363                    }
364
365                    report = Some(candidate);
366                    report_client_id = Some(client_id);
367                    break;
368                }
369                Ok(Some(candidate)) => {
370                    coverage_complete = false;
371                    log::warn!(
372                        "Ignoring mismatched targeted order status report from {client_id} for {}: client_order_id={:?}, venue_order_id={}, instrument_id={}",
373                        query.client_order_id,
374                        candidate.client_order_id,
375                        candidate.venue_order_id,
376                        candidate.instrument_id,
377                    );
378                }
379                Ok(None) => {}
380                Err(e) => {
381                    coverage_complete = false;
382                    log::warn!(
383                        "Failed targeted order status query from {client_id} for {}: {e}",
384                        query.client_order_id,
385                    );
386                }
387            }
388        }
389
390        results.push(TargetedOrderReportResult {
391            client_order_id: query.client_order_id(),
392            client_id: report_client_id,
393            report,
394            fills,
395            coverage_complete,
396        });
397    }
398
399    results
400}
401
402/// Checks whether cached order status, filled quantity, and report fields match.
403pub(super) fn is_exact_order_match(order: &OrderAny, report: &OrderStatusReport) -> bool {
404    order.status() == report.order_status
405        && order.filled_qty() == report.filled_qty
406        && !should_reconciliation_update(order, report)
407}
408
409fn targeted_report_matches(query: &TargetedOrderQuery, report: &OrderStatusReport) -> bool {
410    let instrument_matches = query
411        .command
412        .instrument_id
413        .is_none_or(|instrument_id| report.instrument_id == instrument_id);
414    let order_matches = report.client_order_id == Some(query.client_order_id)
415        || query
416            .command
417            .venue_order_id
418            .is_some_and(|venue_order_id| report.venue_order_id == venue_order_id);
419
420    instrument_matches && order_matches
421}
422
423/// Checks whether a canceled or expired report has more fills than the cached order.
424pub(super) fn terminal_report_has_missing_fills(
425    report: &OrderStatusReport,
426    cached_filled_qty: Quantity,
427) -> bool {
428    matches!(
429        report.order_status,
430        OrderStatus::Canceled | OrderStatus::Expired
431    ) && report.filled_qty > cached_filled_qty
432}
433
434/// Builds an order report from fills sharing the same order and venue position.
435///
436/// # Errors
437///
438/// Returns an error if fills are empty or inconsistent, instrument metadata does not match, or
439/// aggregate quantities or prices cannot be represented.
440pub(super) fn create_orphan_fill_order_report(
441    fills: &[&FillReport],
442    instrument: &InstrumentAny,
443) -> anyhow::Result<OrderStatusReport> {
444    let Some(first) = fills.first() else {
445        anyhow::bail!("fill group is empty");
446    };
447
448    let venue_position_id = first
449        .venue_position_id
450        .ok_or_else(|| anyhow::anyhow!("venue position ID is missing"))?;
451
452    for fill in fills.iter().skip(1) {
453        anyhow::ensure!(
454            fill.account_id == first.account_id,
455            "account ID differs across fill group"
456        );
457        anyhow::ensure!(
458            fill.instrument_id == first.instrument_id,
459            "instrument ID differs across fill group"
460        );
461        anyhow::ensure!(
462            fill.venue_order_id == first.venue_order_id,
463            "venue order ID differs across fill group"
464        );
465        anyhow::ensure!(
466            fill.client_order_id == first.client_order_id,
467            "client order ID differs across fill group"
468        );
469        anyhow::ensure!(
470            fill.order_side == first.order_side,
471            "order side differs across fill group"
472        );
473        anyhow::ensure!(
474            fill.venue_position_id == first.venue_position_id,
475            "venue position ID differs across fill group"
476        );
477    }
478
479    anyhow::ensure!(
480        first.instrument_id == instrument.id(),
481        "instrument metadata does not match fill group"
482    );
483
484    let (quantity, notional) = fills.iter().try_fold(
485        (Decimal::ZERO, Decimal::ZERO),
486        |(quantity, notional), fill| {
487            let fill_quantity = fill.last_qty.as_decimal();
488
489            let quantity = quantity.checked_add(fill_quantity).ok_or_else(|| {
490                anyhow::anyhow!("fill quantity overflow while aggregating fill group")
491            })?;
492
493            let fill_notional = fill_quantity
494                .checked_mul(fill.last_px.as_decimal())
495                .ok_or_else(|| {
496                    anyhow::anyhow!("fill notional overflow while aggregating fill group")
497                })?;
498
499            let notional = notional.checked_add(fill_notional).ok_or_else(|| {
500                anyhow::anyhow!("fill notional overflow while aggregating fill group")
501            })?;
502
503            Ok::<_, anyhow::Error>((quantity, notional))
504        },
505    )?;
506
507    anyhow::ensure!(
508        quantity > Decimal::ZERO,
509        "fill group quantity is not positive"
510    );
511
512    let order_qty = Quantity::from_decimal_dp(quantity, instrument.size_precision())?;
513    let avg_px = notional
514        .checked_div(quantity)
515        .ok_or_else(|| anyhow::anyhow!("fill group average price is not representable"))?;
516
517    let ts_accepted = fills
518        .iter()
519        .map(|fill| fill.ts_event)
520        .min()
521        .expect("non-empty fill group");
522
523    let ts_last = fills
524        .iter()
525        .map(|fill| fill.ts_event)
526        .max()
527        .expect("non-empty fill group");
528
529    let ts_init = fills
530        .iter()
531        .map(|fill| fill.ts_init)
532        .max()
533        .expect("non-empty fill group");
534
535    let report = OrderStatusReport::new(
536        first.account_id,
537        first.instrument_id,
538        first.client_order_id,
539        first.venue_order_id,
540        first.order_side.into(),
541        OrderType::Market,
542        TimeInForce::Gtc,
543        OrderStatus::Filled,
544        order_qty,
545        order_qty,
546        ts_accepted,
547        ts_last,
548        ts_init,
549        None,
550    )
551    .with_avg_px(avg_px)
552    .with_venue_position_id(venue_position_id);
553
554    Ok(report)
555}
556
557/// Checks whether a fill belongs in the retained position projection.
558pub(super) fn should_project_fill(
559    fill: &OrderFilled,
560    retained_fill_state: &RetainedFillState,
561    reported_fill_keys: &IndexSet<FillKey>,
562    order_only_ids: &IndexSet<VenueOrderId>,
563    order_only_fill_keys: &IndexSet<FillKey>,
564) -> bool {
565    let fill_key = (fill.account_id, fill.instrument_id, fill.trade_id);
566    if retained_fill_state.fill_keys.contains(&fill_key)
567        || order_only_ids.contains(&fill.venue_order_id)
568        || order_only_fill_keys.contains(&fill_key)
569    {
570        return true;
571    }
572
573    let order_missing = retained_fill_state.missing_order_ids.contains(&(
574        fill.account_id,
575        fill.instrument_id,
576        fill.client_order_id,
577    )) || retained_fill_state.missing_venue_order_ids.contains(&(
578        fill.account_id,
579        fill.instrument_id,
580        fill.venue_order_id,
581    ));
582
583    if order_missing && !reported_fill_keys.contains(&fill_key) {
584        return true;
585    }
586
587    retained_fill_state
588        .netting_lifecycle_starts
589        .get(&(fill.account_id, fill.instrument_id, fill.strategy_id))
590        .is_some_and(|ts_opened| fill.ts_event < *ts_opened)
591}
592
593/// Sorts reconciliation events by event time while keeping each venue order's lifecycle events
594/// ahead of the fills generated after them.
595///
596/// Fills sort by their own event time. A report's acceptance time can postdate its own
597/// fills when the venue reports a last update time or the adapter uses the reconciliation time,
598/// so any other event sorts no later than the earliest fill that follows it for the same client
599/// and venue order. Events of other venue orders, including a replaced order that shares the
600/// client order ID, keep their own times.
601pub(super) fn sort_reconciliation_events(events: &mut Vec<OrderEventAny>) {
602    let mut earliest_later_fill: IndexMap<(ClientOrderId, Option<VenueOrderId>), UnixNanos> =
603        IndexMap::new();
604    let mut keys = vec![UnixNanos::default(); events.len()];
605
606    for (index, event) in events.iter().enumerate().rev() {
607        let venue_order = (event.client_order_id(), event.venue_order_id());
608        let ts_event = event.ts_event();
609
610        keys[index] = if matches!(event, OrderEventAny::Filled(_)) {
611            let earliest = earliest_later_fill.entry(venue_order).or_insert(ts_event);
612            *earliest = (*earliest).min(ts_event);
613            ts_event
614        } else {
615            earliest_later_fill
616                .get(&venue_order)
617                .map_or(ts_event, |fill_ts| ts_event.min(*fill_ts))
618        };
619    }
620
621    let mut keyed: Vec<(UnixNanos, OrderEventAny)> =
622        keys.into_iter().zip(events.drain(..)).collect();
623    keyed.sort_by_key(|(key, _)| *key);
624    events.extend(keyed.into_iter().map(|(_, event)| event));
625}
626
627/// Checks active fill history for deterministic inferred reconciliation IDs.
628///
629/// Replays the order only when an active reconciliation fill has the inferred ID format.
630///
631/// # Errors
632///
633/// Returns an error if the order requires replay and its cached history cannot be replayed.
634pub(super) fn has_active_inferred_fill(order: &OrderAny) -> anyhow::Result<bool> {
635    Ok(!inferred_reconciliation_trade_ids(order)?.is_empty())
636}
637
638/// Calculates inferred-fill commission using the responsible execution client.
639///
640/// # Errors
641///
642/// Returns an error if the client is unavailable or commission calculation fails.
643pub(super) fn resolve_inferred_fill_commission(
644    fill_qty: Quantity,
645    price_and_liquidity: Option<(Price, LiquiditySide)>,
646    instrument: &InstrumentAny,
647    client: Option<&dyn ExecutionClient>,
648) -> anyhow::Result<Option<Money>> {
649    let Some(client) = client else {
650        anyhow::bail!("responsible execution client is unavailable");
651    };
652
653    let Some((last_px, liquidity_side)) = price_and_liquidity else {
654        return Ok(None);
655    };
656
657    client.calculate_commission(instrument, fill_qty, last_px, liquidity_side)
658}
659
660/// Resolves position-report coverage by account, falling back to venue clients.
661pub(crate) fn resolve_position_report_client_coverage(
662    key: InstrumentAccountKey,
663    clients: &[&dyn ExecutionClient],
664) -> ReportClientCoverage {
665    let client_ids = responsible_client_ids(Some(key.1), key.0.venue, clients);
666
667    if client_ids.is_empty() {
668        ReportClientCoverage::Unresolved
669    } else if clients.iter().any(|client| {
670        client_ids.contains(&client.client_id()) && !client.provides_bulk_position_coverage(key.0)
671    }) {
672        ReportClientCoverage::Unavailable(client_ids)
673    } else {
674        ReportClientCoverage::Resolved(client_ids)
675    }
676}
677
678/// Returns the account's clients, falling back to clients handling the venue.
679pub(super) fn responsible_client_ids(
680    account_id: Option<AccountId>,
681    venue: Venue,
682    clients: &[&dyn ExecutionClient],
683) -> IndexSet<ClientId> {
684    if let Some(account_id) = account_id {
685        let account_clients = clients
686            .iter()
687            .filter(|client| client.account_id() == account_id)
688            .map(|client| client.client_id())
689            .collect::<IndexSet<_>>();
690
691        if !account_clients.is_empty() {
692            return account_clients;
693        }
694    }
695
696    clients
697        .iter()
698        .filter(|client| client.handles_order_venue(venue))
699        .map(|client| client.client_id())
700        .collect()
701}
702
703/// Returns the quantity-weighted average of positive position entry prices.
704pub(super) fn position_avg_px<'a>(
705    positions: impl IntoIterator<Item = &'a Position>,
706) -> Option<Decimal> {
707    let mut total_value = Decimal::ZERO;
708    let mut total_qty = Decimal::ZERO;
709
710    for position in positions {
711        let qty = position.signed_decimal_qty().abs();
712        if position.avg_px_open > 0.0
713            && qty > Decimal::ZERO
714            && let Ok(avg_px) = Decimal::from_str(&position.avg_px_open.to_string())
715        {
716            total_value += avg_px * qty;
717            total_qty += qty;
718        }
719    }
720
721    if total_qty > Decimal::ZERO {
722        Some(total_value / total_qty)
723    } else {
724        None
725    }
726}
727
728/// Aggregates signed quantities into net, long, and absolute short totals.
729pub(super) fn position_qty_aggregates(
730    signed_quantities: impl Iterator<Item = Decimal>,
731) -> (Decimal, Decimal, Decimal) {
732    signed_quantities.fold(
733        (Decimal::ZERO, Decimal::ZERO, Decimal::ZERO),
734        |(net, long, short), qty| {
735            if qty > Decimal::ZERO {
736                (net + qty, long + qty, short)
737            } else {
738                (net + qty, long, short + qty.abs())
739            }
740        },
741    )
742}
743
744/// Returns the reports without duplicates, logging a warning for each one removed.
745///
746/// Reports are duplicates when they match apart from the locally assigned report ID and
747/// initialization timestamp. Keeps the first occurrence of each report in input order.
748pub(super) fn distinct_position_reports(
749    reports: Vec<PositionStatusReport>,
750) -> Vec<PositionStatusReport> {
751    let mut distinct_reports: Vec<PositionStatusReport> = Vec::with_capacity(reports.len());
752
753    for report in reports {
754        let is_duplicate = distinct_reports.iter().any(|distinct| {
755            let mut normalized = distinct.clone();
756            normalized.report_id = report.report_id;
757            normalized.ts_init = report.ts_init;
758            normalized == report
759        });
760
761        if is_duplicate {
762            log::warn!("Duplicate position report for {}", report.instrument_id);
763            continue;
764        }
765
766        distinct_reports.push(report);
767    }
768
769    distinct_reports
770}
771
772/// Builds a filled market-order report that moves a position by `quantity` on `order_side`.
773#[expect(clippy::too_many_arguments)]
774pub(super) fn create_position_reconciliation_report(
775    instrument: &InstrumentAny,
776    account_id: AccountId,
777    order_side: OrderSide,
778    quantity: Quantity,
779    avg_px: Decimal,
780    venue_position_id: Option<PositionId>,
781    tag: Option<&str>,
782    ts_now: UnixNanos,
783    venue_ts_last: UnixNanos,
784) -> OrderStatusReport {
785    let instrument_id = instrument.id();
786    let fill_price = Price::from_decimal_dp(avg_px, instrument.price_precision()).ok();
787    let venue_order_id = create_position_reconciliation_venue_order_id(
788        account_id,
789        instrument_id,
790        order_side,
791        OrderType::Market,
792        quantity,
793        fill_price,
794        venue_position_id,
795        tag,
796        venue_ts_last,
797    );
798
799    let mut report = OrderStatusReport::new(
800        account_id,
801        instrument_id,
802        None,
803        venue_order_id,
804        order_side.into(),
805        OrderType::Market,
806        TimeInForce::Gtc,
807        OrderStatus::Filled,
808        quantity,
809        quantity,
810        ts_now,
811        ts_now,
812        ts_now,
813        None,
814    )
815    .with_avg_px(avg_px);
816
817    if let Some(venue_position_id) = venue_position_id {
818        report = report.with_venue_position_id(venue_position_id);
819    }
820
821    report
822}
823
824/// Builds a reconciliation cancel for an order resolved without a venue status report.
825pub(super) fn create_reconciliation_canceled(order: &OrderAny, ts_now: UnixNanos) -> OrderEventAny {
826    OrderEventAny::Canceled(OrderCanceled::new(
827        order.trader_id(),
828        order.strategy_id(),
829        order.instrument_id(),
830        order.client_order_id(),
831        UUID4::new(),
832        ts_now,
833        ts_now,
834        true, // reconciliation
835        order.venue_order_id(),
836        order.account_id(),
837        None,
838    ))
839}
840
841#[cfg(test)]
842pub(super) mod tests {
843    use std::cell::RefCell;
844
845    use nautilus_core::Params;
846    use nautilus_execution::reconciliation::inferred_fill_price_and_liquidity;
847    use nautilus_model::{
848        accounts::AccountAny,
849        enums::{OmsType, PositionSide},
850        identifiers::Venue,
851        instruments::stubs::crypto_perpetual_ethusdt,
852        orders::{OrderTestBuilder, stubs::TestOrderEventStubs},
853        types::{AccountBalance, Currency, MarginBalance, quantity::QUANTITY_MAX},
854    };
855    use proptest::prelude::*;
856    use rstest::rstest;
857    use rust_decimal_macros::dec;
858
859    use super::*;
860
861    #[rstest]
862    #[tokio::test]
863    async fn test_targeted_reports_missing_client_marks_coverage_incomplete(
864        #[values(false, true)] missing_first: bool,
865    ) {
866        let client = CommissionStubClient::new(CommissionOutcome::NoOverride);
867        let missing_id = ClientId::from("MISSING");
868        let mut responsible_clients = vec![client.client_id(), missing_id];
869
870        if missing_first {
871            responsible_clients.reverse();
872        }
873
874        let client_order_id = ClientOrderId::from("O-MISSING-CLIENT");
875
876        let query = TargetedOrderQuery {
877            client_order_id,
878            responsible_clients: responsible_clients.into_iter().collect(),
879            command: GenerateOrderStatusReport::new(
880                UUID4::new(),
881                UnixNanos::from(123),
882                Some(InstrumentId::from("ETHUSDT-PERP.BINANCE")),
883                Some(client_order_id),
884                Some(VenueOrderId::from("V-MISSING-CLIENT")),
885                None,
886                None,
887            ),
888            report: None,
889            filled_qty: Quantity::from("0.0"),
890        };
891
892        let results = request_targeted_order_reports(vec![query], &[&client], Duration::ZERO).await;
893
894        assert_eq!(results.len(), 1);
895        let result = &results[0];
896        assert_eq!(result.client_order_id, client_order_id);
897        assert_eq!(result.client_id, None);
898        assert_eq!(result.report, None);
899        assert!(result.fills.is_empty());
900        assert!(!result.coverage_complete);
901    }
902
903    /// Configured result of a stub commission calculation.
904    #[derive(Clone)]
905    pub(crate) enum CommissionOutcome {
906        Value(Money),
907        NoOverride,
908        Failure,
909    }
910
911    /// Execution client that records commission inputs and returns a configured result.
912    pub(crate) struct CommissionStubClient {
913        outcome: CommissionOutcome,
914        seen: RefCell<Option<(Quantity, Price, LiquiditySide)>>,
915    }
916
917    impl CommissionStubClient {
918        /// Creates a client with the specified commission result.
919        pub(crate) fn new(outcome: CommissionOutcome) -> Self {
920            Self {
921                outcome,
922                seen: RefCell::new(None),
923            }
924        }
925
926        /// Returns the last recorded commission inputs.
927        pub(crate) fn seen(&self) -> Option<(Quantity, Price, LiquiditySide)> {
928            *self.seen.borrow()
929        }
930
931        /// Clears the recorded commission inputs.
932        pub(crate) fn clear_seen(&self) {
933            *self.seen.borrow_mut() = None;
934        }
935    }
936
937    #[async_trait::async_trait(?Send)]
938    impl ExecutionClient for CommissionStubClient {
939        fn is_connected(&self) -> bool {
940            true
941        }
942
943        fn client_id(&self) -> ClientId {
944            ClientId::from("STUB")
945        }
946
947        fn account_id(&self) -> AccountId {
948            AccountId::from("STUB-001")
949        }
950
951        fn venue(&self) -> Venue {
952            Venue::from("STUB")
953        }
954
955        fn oms_type(&self) -> OmsType {
956            OmsType::Netting
957        }
958
959        fn get_account(&self) -> Option<AccountAny> {
960            None
961        }
962
963        fn generate_account_state(
964            &self,
965            _balances: Vec<AccountBalance>,
966            _margins: Vec<MarginBalance>,
967            _reported: bool,
968            _ts_event: UnixNanos,
969            _info: Option<Params>,
970        ) -> anyhow::Result<()> {
971            Ok(())
972        }
973
974        fn start(&mut self) -> anyhow::Result<()> {
975            Ok(())
976        }
977
978        fn stop(&mut self) -> anyhow::Result<()> {
979            Ok(())
980        }
981
982        fn calculate_commission(
983            &self,
984            _instrument: &InstrumentAny,
985            last_qty: Quantity,
986            last_px: Price,
987            liquidity_side: LiquiditySide,
988        ) -> anyhow::Result<Option<Money>> {
989            *self.seen.borrow_mut() = Some((last_qty, last_px, liquidity_side));
990
991            match &self.outcome {
992                CommissionOutcome::Value(money) => Ok(Some(*money)),
993                CommissionOutcome::NoOverride => Ok(None),
994                CommissionOutcome::Failure => {
995                    anyhow::bail!("commission is not representable as Money")
996                }
997            }
998        }
999    }
1000
1001    fn commission_fixtures() -> (OrderAny, OrderStatusReport, InstrumentAny) {
1002        let instrument = crypto_perpetual_ethusdt();
1003        let order = OrderTestBuilder::new(OrderType::Limit)
1004            .instrument_id(instrument.id())
1005            .side(OrderSide::Buy)
1006            .quantity(Quantity::from("10.0"))
1007            .price(Price::from("100.00"))
1008            .build();
1009        let report = OrderStatusReport::new(
1010            AccountId::from("STUB-001"),
1011            instrument.id(),
1012            Some(order.client_order_id()),
1013            VenueOrderId::from("V-1"),
1014            OrderSide::Buy.into(),
1015            OrderType::Limit,
1016            TimeInForce::Gtc,
1017            OrderStatus::Filled,
1018            Quantity::from("10.0"),
1019            Quantity::from("10.0"),
1020            UnixNanos::from(1),
1021            UnixNanos::from(1),
1022            UnixNanos::from(1),
1023            None,
1024        )
1025        .with_avg_px(dec!(100.0));
1026
1027        (order, report, InstrumentAny::CryptoPerpetual(instrument))
1028    }
1029
1030    #[rstest]
1031    fn test_create_orphan_fill_order_report_preserves_aggregate_fields() {
1032        let (instrument, fills) = orphan_fill_fixtures();
1033        let reports = fills.iter().collect::<Vec<_>>();
1034
1035        let report = create_orphan_fill_order_report(&reports, &instrument).unwrap();
1036        let expected = OrderStatusReport::new(
1037            AccountId::from("STUB-001"),
1038            instrument.id(),
1039            Some(ClientOrderId::from("O-ORPHAN")),
1040            VenueOrderId::from("V-ORPHAN"),
1041            OrderSide::Buy.into(),
1042            OrderType::Market,
1043            TimeInForce::Gtc,
1044            OrderStatus::Filled,
1045            Quantity::from("4.000"),
1046            Quantity::from("4.000"),
1047            UnixNanos::from(10),
1048            UnixNanos::from(30),
1049            UnixNanos::from(50),
1050            Some(report.report_id),
1051        )
1052        .with_avg_px(dec!(115))
1053        .with_venue_position_id(PositionId::from("P-ORPHAN"));
1054
1055        assert_eq!(report, expected);
1056    }
1057
1058    #[rstest]
1059    #[case::empty("empty", "fill group is empty")]
1060    #[case::missing_position("missing_position", "venue position ID is missing")]
1061    #[case::account("account", "account ID differs across fill group")]
1062    #[case::instrument("instrument", "instrument ID differs across fill group")]
1063    #[case::venue_order("venue_order", "venue order ID differs across fill group")]
1064    #[case::client_order("client_order", "client order ID differs across fill group")]
1065    #[case::side("side", "order side differs across fill group")]
1066    #[case::position("position", "venue position ID differs across fill group")]
1067    #[case::metadata("metadata", "instrument metadata does not match fill group")]
1068    #[case::zero("zero", "fill group quantity is not positive")]
1069    fn test_create_orphan_fill_order_report_rejects_invalid_group(
1070        #[case] invalid_field: &str,
1071        #[case] expected: &str,
1072    ) {
1073        let (instrument, mut fills) = orphan_fill_fixtures();
1074
1075        match invalid_field {
1076            "empty" => fills.clear(),
1077            "missing_position" => fills[0].venue_position_id = None,
1078            "account" => fills[1].account_id = AccountId::from("OTHER-002"),
1079            "instrument" => fills[1].instrument_id = InstrumentId::from("OTHER.TEST"),
1080            "venue_order" => fills[1].venue_order_id = VenueOrderId::from("V-OTHER"),
1081            "client_order" => fills[1].client_order_id = Some(ClientOrderId::from("O-OTHER")),
1082            "side" => fills[1].order_side = OrderSide::Sell,
1083            "position" => fills[1].venue_position_id = Some(PositionId::from("P-OTHER")),
1084            "metadata" => {
1085                for fill in &mut fills {
1086                    fill.instrument_id = InstrumentId::from("OTHER.TEST");
1087                }
1088            }
1089            "zero" => {
1090                for fill in &mut fills {
1091                    fill.last_qty = Quantity::zero(3);
1092                }
1093            }
1094            _ => unreachable!(),
1095        }
1096
1097        let reports = fills.iter().collect::<Vec<_>>();
1098
1099        let error = create_orphan_fill_order_report(&reports, &instrument).unwrap_err();
1100
1101        assert_eq!(error.to_string(), expected);
1102    }
1103
1104    #[rstest]
1105    fn test_create_orphan_fill_order_report_rejects_aggregate_quantity_overflow() {
1106        let (instrument, mut fills) = orphan_fill_fixtures();
1107        let max_qty = Quantity::new(QUANTITY_MAX, 0);
1108
1109        for fill in &mut fills {
1110            fill.last_qty = max_qty;
1111            fill.last_px = Price::from("1.00");
1112        }
1113
1114        let expected =
1115            Quantity::from_decimal_dp(max_qty.as_decimal() * dec!(2), instrument.size_precision())
1116                .unwrap_err();
1117        let reports = fills.iter().collect::<Vec<_>>();
1118
1119        let error = create_orphan_fill_order_report(&reports, &instrument).unwrap_err();
1120
1121        assert_eq!(error.to_string(), expected.to_string());
1122    }
1123
1124    fn orphan_fill_fixtures() -> (InstrumentAny, Vec<FillReport>) {
1125        let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
1126
1127        let fills = [("1.000", "100.00", 30, 40), ("3.000", "120.00", 10, 50)]
1128            .into_iter()
1129            .enumerate()
1130            .map(|(index, (qty, px, ts_event, ts_init))| {
1131                FillReport::new(
1132                    AccountId::from("STUB-001"),
1133                    instrument.id(),
1134                    VenueOrderId::from("V-ORPHAN"),
1135                    TradeId::new(format!("T-{index}")),
1136                    OrderSide::Buy,
1137                    Quantity::from(qty),
1138                    Price::from(px),
1139                    Money::from("0.10 USDT"),
1140                    LiquiditySide::Taker,
1141                    Some(ClientOrderId::from("O-ORPHAN")),
1142                    Some(PositionId::from("P-ORPHAN")),
1143                    UnixNanos::from(ts_event),
1144                    UnixNanos::from(ts_init),
1145                    None,
1146                )
1147            })
1148            .collect();
1149
1150        (instrument, fills)
1151    }
1152
1153    #[rstest]
1154    #[case::venue_format("T-VENUE-1")]
1155    #[case::uuid_v5_format("2d89666b-1a1e-5a75-b193-4eb3b454c757")]
1156    fn test_has_active_inferred_fill_ignores_venue_trade_ids(#[case] trade_id: &str) {
1157        let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
1158        let mut order = OrderTestBuilder::new(OrderType::Market)
1159            .instrument_id(instrument.id())
1160            .side(OrderSide::Buy)
1161            .quantity(Quantity::from("1.000"))
1162            .build();
1163        let submitted = TestOrderEventStubs::submitted(&order, AccountId::from("TEST-001"));
1164        order.apply(submitted).unwrap();
1165
1166        let trade_id = TradeId::from(trade_id);
1167
1168        let OrderEventAny::Filled(mut fill) = TestOrderEventStubs::filled(
1169            &order,
1170            &instrument,
1171            Some(trade_id),
1172            None,
1173            Some(Price::from("100.00")),
1174            Some(Quantity::from("1.000")),
1175            None,
1176            None,
1177            None,
1178            None,
1179        ) else {
1180            unreachable!();
1181        };
1182
1183        fill.reconciliation = true;
1184        order.apply(OrderEventAny::Filled(fill)).unwrap();
1185
1186        assert!(order.trade_ids().contains(&&trade_id));
1187        assert!(!has_active_inferred_fill(&order).unwrap());
1188    }
1189
1190    #[rstest]
1191    fn test_resolve_inferred_fill_commission_without_client_fails_closed() {
1192        let (order, report, instrument) = commission_fixtures();
1193
1194        let price_and_liquidity = inferred_fill_price_and_liquidity(&order, &report, &instrument);
1195
1196        let error = resolve_inferred_fill_commission(
1197            Quantity::from("5.0"),
1198            price_and_liquidity,
1199            &instrument,
1200            None,
1201        )
1202        .expect_err("a missing responsible client must defer the fill");
1203
1204        assert_eq!(
1205            error.to_string(),
1206            "responsible execution client is unavailable"
1207        );
1208    }
1209
1210    #[rstest]
1211    fn test_resolve_inferred_fill_commission_without_price_uses_generic_path() {
1212        let instrument = crypto_perpetual_ethusdt();
1213        let order = OrderTestBuilder::new(OrderType::Market)
1214            .instrument_id(instrument.id())
1215            .side(OrderSide::Buy)
1216            .quantity(Quantity::from("10.0"))
1217            .build();
1218
1219        let report = OrderStatusReport::new(
1220            AccountId::from("STUB-001"),
1221            instrument.id(),
1222            Some(order.client_order_id()),
1223            VenueOrderId::from("V-1"),
1224            OrderSide::Buy.into(),
1225            OrderType::Market,
1226            TimeInForce::Gtc,
1227            OrderStatus::Filled,
1228            Quantity::from("10.0"),
1229            Quantity::from("10.0"),
1230            UnixNanos::from(1),
1231            UnixNanos::from(1),
1232            UnixNanos::from(1),
1233            None,
1234        );
1235        let client =
1236            CommissionStubClient::new(CommissionOutcome::Value(Money::new(1.0, Currency::USDT())));
1237        let instrument = InstrumentAny::CryptoPerpetual(instrument);
1238
1239        let price_and_liquidity = inferred_fill_price_and_liquidity(&order, &report, &instrument);
1240
1241        let commission = resolve_inferred_fill_commission(
1242            Quantity::from("5.0"),
1243            price_and_liquidity,
1244            &instrument,
1245            Some(&client),
1246        )
1247        .expect("an unresolvable price is not a failure");
1248
1249        assert_eq!(commission, None, "no price means no venue commission");
1250    }
1251
1252    #[rstest]
1253    fn test_resolve_inferred_fill_commission_returns_venue_value() {
1254        let (order, report, instrument) = commission_fixtures();
1255        let expected = Money::new(2.5, Currency::USDT());
1256        let client = CommissionStubClient::new(CommissionOutcome::Value(expected));
1257
1258        let price_and_liquidity = inferred_fill_price_and_liquidity(&order, &report, &instrument);
1259
1260        let commission = resolve_inferred_fill_commission(
1261            Quantity::from("5.0"),
1262            price_and_liquidity,
1263            &instrument,
1264            Some(&client),
1265        )
1266        .expect("a representable commission succeeds");
1267
1268        assert_eq!(commission, Some(expected));
1269        assert_eq!(
1270            client.seen(),
1271            Some((
1272                Quantity::from("5.0"),
1273                Price::from("100.00"),
1274                LiquiditySide::NoLiquiditySide,
1275            )),
1276            "the resolver passes the inferred fill quantity, resolved price, and liquidity side"
1277        );
1278    }
1279
1280    #[rstest]
1281    fn test_resolve_inferred_fill_commission_honors_no_override() {
1282        let (order, report, instrument) = commission_fixtures();
1283        let client = CommissionStubClient::new(CommissionOutcome::NoOverride);
1284
1285        let price_and_liquidity = inferred_fill_price_and_liquidity(&order, &report, &instrument);
1286
1287        let commission = resolve_inferred_fill_commission(
1288            Quantity::from("5.0"),
1289            price_and_liquidity,
1290            &instrument,
1291            Some(&client),
1292        )
1293        .expect("no override is not a failure");
1294
1295        assert_eq!(commission, None);
1296    }
1297
1298    #[rstest]
1299    fn test_resolve_inferred_fill_commission_propagates_failure() {
1300        let (order, report, instrument) = commission_fixtures();
1301        let client = CommissionStubClient::new(CommissionOutcome::Failure);
1302
1303        let price_and_liquidity = inferred_fill_price_and_liquidity(&order, &report, &instrument);
1304
1305        let result = resolve_inferred_fill_commission(
1306            Quantity::from("5.0"),
1307            price_and_liquidity,
1308            &instrument,
1309            Some(&client),
1310        );
1311
1312        assert_eq!(
1313            result.unwrap_err().to_string(),
1314            "commission is not representable as Money"
1315        );
1316    }
1317
1318    #[rstest]
1319    #[case::account(true, false, true, true, "resolved")]
1320    #[case::account_unavailable(true, false, false, true, "unavailable")]
1321    #[case::venue(false, true, true, true, "resolved")]
1322    #[case::venue_unavailable(false, true, false, true, "unavailable")]
1323    #[case::unmatched(false, false, true, true, "unresolved")]
1324    #[case::no_clients(false, false, true, false, "unresolved")]
1325    fn test_resolve_position_report_client_coverage(
1326        #[case] account_matches: bool,
1327        #[case] venue_matches: bool,
1328        #[case] available: bool,
1329        #[case] include_client: bool,
1330        #[case] expected: &str,
1331    ) {
1332        let key = (InstrumentId::from("ETH.TEST"), AccountId::from("TEST-001"));
1333
1334        let client = CoverageStubClient {
1335            id: ClientId::from("COVERAGE"),
1336            account_id: if account_matches {
1337                key.1
1338            } else {
1339                AccountId::from("OTHER-001")
1340            },
1341            venue: if venue_matches {
1342                key.0.venue
1343            } else {
1344                Venue::from("OTHER")
1345            },
1346            available,
1347        };
1348
1349        let clients: Vec<&dyn ExecutionClient> = if include_client {
1350            vec![&client]
1351        } else {
1352            vec![]
1353        };
1354
1355        let expected = match expected {
1356            "resolved" => ReportClientCoverage::Resolved(IndexSet::from([client.id])),
1357            "unavailable" => ReportClientCoverage::Unavailable(IndexSet::from([client.id])),
1358            "unresolved" => ReportClientCoverage::Unresolved,
1359            _ => unreachable!(),
1360        };
1361
1362        let result = resolve_position_report_client_coverage(key, &clients);
1363
1364        assert_eq!(result, expected);
1365    }
1366
1367    #[rstest]
1368    #[case::account_available(true, false)]
1369    #[case::account_unavailable(false, true)]
1370    fn test_position_coverage_prefers_account_clients(
1371        #[case] account_available: bool,
1372        #[case] venue_available: bool,
1373    ) {
1374        let key = (InstrumentId::from("ETH.TEST"), AccountId::from("TEST-001"));
1375
1376        let account_client = CoverageStubClient {
1377            id: ClientId::from("ACCOUNT"),
1378            account_id: key.1,
1379            venue: Venue::from("OTHER"),
1380            available: account_available,
1381        };
1382
1383        let venue_client = CoverageStubClient {
1384            id: ClientId::from("VENUE"),
1385            account_id: AccountId::from("OTHER-001"),
1386            venue: key.0.venue,
1387            available: venue_available,
1388        };
1389
1390        let expected_clients = IndexSet::from([account_client.id]);
1391
1392        let expected = if account_available {
1393            ReportClientCoverage::Resolved(expected_clients)
1394        } else {
1395            ReportClientCoverage::Unavailable(expected_clients)
1396        };
1397
1398        let result =
1399            resolve_position_report_client_coverage(key, &[&venue_client, &account_client]);
1400
1401        assert_eq!(result, expected);
1402    }
1403
1404    struct CoverageStubClient {
1405        id: ClientId,
1406        account_id: AccountId,
1407        venue: Venue,
1408        available: bool,
1409    }
1410
1411    #[async_trait::async_trait(?Send)]
1412    impl ExecutionClient for CoverageStubClient {
1413        fn is_connected(&self) -> bool {
1414            true
1415        }
1416
1417        fn client_id(&self) -> ClientId {
1418            self.id
1419        }
1420
1421        fn account_id(&self) -> AccountId {
1422            self.account_id
1423        }
1424
1425        fn venue(&self) -> Venue {
1426            self.venue
1427        }
1428
1429        fn oms_type(&self) -> OmsType {
1430            OmsType::Netting
1431        }
1432
1433        fn get_account(&self) -> Option<AccountAny> {
1434            None
1435        }
1436
1437        fn provides_bulk_position_coverage(&self, _instrument_id: InstrumentId) -> bool {
1438            self.available
1439        }
1440
1441        fn generate_account_state(
1442            &self,
1443            _balances: Vec<AccountBalance>,
1444            _margins: Vec<MarginBalance>,
1445            _reported: bool,
1446            _ts_event: UnixNanos,
1447            _info: Option<Params>,
1448        ) -> anyhow::Result<()> {
1449            Ok(())
1450        }
1451
1452        fn start(&mut self) -> anyhow::Result<()> {
1453            Ok(())
1454        }
1455
1456        fn stop(&mut self) -> anyhow::Result<()> {
1457            Ok(())
1458        }
1459    }
1460
1461    #[rstest]
1462    #[case::empty(vec![], None)]
1463    #[case::flat(vec![(0, 100.0)], None)]
1464    #[case::weighted(vec![(1, 100.0), (3, 120.0)], Some(dec!(115)))]
1465    #[case::short(vec![(-1, 100.0), (-3, 120.0)], Some(dec!(115)))]
1466    #[case::ignored(vec![(1, 0.0), (2, -10.0), (3, 120.0)], Some(dec!(120)))]
1467    fn test_position_avg_px(#[case] entries: Vec<(i32, f64)>, #[case] expected: Option<Decimal>) {
1468        let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
1469        let order = OrderTestBuilder::new(OrderType::Market)
1470            .instrument_id(instrument.id())
1471            .side(OrderSide::Buy)
1472            .quantity(Quantity::from("1.000"))
1473            .build();
1474        let fill = TestOrderEventStubs::filled(
1475            &order,
1476            &instrument,
1477            None,
1478            Some(PositionId::from("P-AVG")),
1479            Some(Price::from("100.00")),
1480            Some(Quantity::from("1.000")),
1481            None,
1482            None,
1483            None,
1484            None,
1485        );
1486        let position = Position::new(&instrument, fill.into());
1487
1488        let positions = entries
1489            .into_iter()
1490            .map(|(qty, px)| {
1491                let mut position = position.clone();
1492                position.signed_qty = f64::from(qty);
1493                position.quantity = Quantity::new(f64::from(qty.abs()), 3);
1494                position.avg_px_open = px;
1495                position
1496            })
1497            .collect::<Vec<_>>();
1498
1499        let result = position_avg_px(&positions);
1500
1501        assert_eq!(result, expected);
1502    }
1503
1504    #[rstest]
1505    #[case::empty(vec![], (dec!(0), dec!(0), dec!(0)))]
1506    #[case::zero(vec![0, 0], (dec!(0), dec!(0), dec!(0)))]
1507    #[case::long(vec![2, 3], (dec!(5), dec!(5), dec!(0)))]
1508    #[case::short(vec![-2, -3], (dec!(-5), dec!(0), dec!(5)))]
1509    #[case::mixed(vec![2, -5, 1], (dec!(-2), dec!(3), dec!(5)))]
1510    #[case::offset(vec![3, -3], (dec!(0), dec!(3), dec!(3)))]
1511    fn test_position_qty_aggregates(
1512        #[case] quantities: Vec<i32>,
1513        #[case] expected: (Decimal, Decimal, Decimal),
1514    ) {
1515        let result = position_qty_aggregates(quantities.into_iter().map(Decimal::from));
1516
1517        assert_eq!(result, expected);
1518    }
1519
1520    proptest! {
1521        #[rstest]
1522        fn prop_position_quantities_match_tolerance_and_side_exposure(
1523            long in 100i64..1_000_000,
1524            short in 100i64..1_000_000,
1525            long_delta in -10i64..=10,
1526            short_delta in -10i64..=10,
1527            tolerance in 0i64..=20,
1528            scale in 0u32..=6,
1529            side_reports in any::<bool>(),
1530        ) {
1531            let venue_net = long + long_delta - short - short_delta;
1532            let (venue_long, venue_short) = if side_reports {
1533                (long + long_delta, short + short_delta)
1534            } else {
1535                (venue_net.max(0), (-venue_net).max(0))
1536            };
1537            let comparison = PositionQuantityComparison {
1538                cached_positions: Vec::new(),
1539                cached_signed_qty: Decimal::new(long - short, scale),
1540                cached_long_qty: Decimal::new(long, scale),
1541                cached_short_qty: Decimal::new(short, scale),
1542                venue_signed_qty: Decimal::new(venue_net, scale),
1543                venue_long_qty: Decimal::new(venue_long, scale),
1544                venue_short_qty: Decimal::new(venue_short, scale),
1545                nonflat_count: if side_reports { 2 } else { 1 },
1546                venue_report: None,
1547                venue_has_side_reports: side_reports,
1548            };
1549            let mut largest_error = (long_delta - short_delta).abs();
1550
1551            if side_reports {
1552                largest_error = largest_error.max(long_delta.abs()).max(short_delta.abs());
1553            }
1554
1555            prop_assert_eq!(
1556                comparison.quantities_match(Decimal::new(tolerance, scale)),
1557                largest_error <= tolerance,
1558            );
1559            prop_assert!(comparison.quantities_match(Decimal::new(largest_error, scale)));
1560
1561            if largest_error > 0 {
1562                prop_assert!(!comparison.quantities_match(Decimal::new(largest_error - 1, scale)));
1563            }
1564        }
1565    }
1566
1567    proptest! {
1568        #[rstest]
1569        fn prop_position_qty_aggregates_preserves_sign_and_net(
1570            values in proptest::collection::vec(-1_000_000i64..=1_000_000, 0..32),
1571        ) {
1572            let quantities = values.iter().map(|v| Decimal::new(*v, 3)).collect::<Vec<_>>();
1573            let (net, long, short) = position_qty_aggregates(quantities.iter().copied());
1574            let reversed = position_qty_aggregates(quantities.iter().map(|v| -*v));
1575            let expected_net = Decimal::new(values.iter().sum(), 3);
1576
1577            prop_assert_eq!(net, expected_net);
1578            prop_assert_eq!(net, long - short);
1579            prop_assert!(long >= Decimal::ZERO);
1580            prop_assert!(short >= Decimal::ZERO);
1581            prop_assert_eq!(reversed, (-net, short, long));
1582        }
1583    }
1584
1585    #[rstest]
1586    fn test_distinct_position_reports_removes_non_adjacent_duplicates() {
1587        let report = |side: PositionSide, quantity: &str, venue_position_id: &str, ts_init: u64| {
1588            PositionStatusReport::new(
1589                AccountId::from("BINANCE-001"),
1590                InstrumentId::from("ETHUSDT-PERP.BINANCE"),
1591                side,
1592                Quantity::from(quantity),
1593                UnixNanos::from(1_000),
1594                UnixNanos::from(ts_init),
1595                None,
1596                Some(PositionId::from(venue_position_id)),
1597                Some(dec!(3000)),
1598            )
1599        };
1600
1601        let long = report(PositionSide::Long, "2.0", "P-LONG", 2_000);
1602        let short = report(PositionSide::Short, "3.0", "P-SHORT", 2_001);
1603        let long_duplicate = report(PositionSide::Long, "2.0", "P-LONG", 2_002);
1604        let short_duplicate = report(PositionSide::Short, "3.0", "P-SHORT", 2_003);
1605
1606        let result = distinct_position_reports(vec![
1607            long.clone(),
1608            short.clone(),
1609            long_duplicate,
1610            short_duplicate,
1611        ]);
1612
1613        assert_eq!(result, vec![long, short]);
1614    }
1615
1616    #[rstest]
1617    fn test_sort_reconciliation_events_accepts_before_earlier_fills() {
1618        let first = market_order("O-FIRST", OrderSide::Buy);
1619        let second = market_order("O-SECOND", OrderSide::Sell);
1620        let mut events = vec![
1621            accepted_at(&second, "V-SECOND", 900),
1622            filled_at(&second, "V-SECOND", "T-SECOND", 200),
1623            accepted_at(&first, "V-FIRST", 900),
1624            filled_at(&first, "V-FIRST", "T-FIRST", 100),
1625            canceled_at(&first, "V-FIRST", 950),
1626        ];
1627
1628        sort_reconciliation_events(&mut events);
1629
1630        assert_eq!(
1631            event_sequence(&events),
1632            vec![
1633                "O-FIRST V-FIRST accepted",
1634                "O-FIRST V-FIRST filled",
1635                "O-SECOND V-SECOND accepted",
1636                "O-SECOND V-SECOND filled",
1637                "O-FIRST V-FIRST canceled",
1638            ]
1639        );
1640    }
1641
1642    #[rstest]
1643    fn test_sort_reconciliation_events_keeps_other_venue_order_fill_times() {
1644        let order = market_order("O-REPLACED", OrderSide::Buy);
1645        let mut events = vec![
1646            accepted_at(&order, "V-NEW", 900),
1647            filled_at(&order, "V-NEW", "T-NEW", 950),
1648            filled_at(&order, "V-OLD", "T-OLD", 100),
1649        ];
1650
1651        sort_reconciliation_events(&mut events);
1652
1653        assert_eq!(
1654            event_sequence(&events),
1655            vec![
1656                "O-REPLACED V-OLD filled",
1657                "O-REPLACED V-NEW accepted",
1658                "O-REPLACED V-NEW filled",
1659            ]
1660        );
1661    }
1662
1663    #[rstest]
1664    fn test_sort_reconciliation_events_keeps_fills_after_earlier_terminal_time() {
1665        let canceled_order = market_order("O-CANCELED", OrderSide::Buy);
1666        let filled_order = market_order("O-FILLED", OrderSide::Sell);
1667        let mut events = vec![
1668            accepted_at(&canceled_order, "V-CANCELED", 100),
1669            filled_at(&canceled_order, "V-CANCELED", "T-CANCELED", 500),
1670            canceled_at(&canceled_order, "V-CANCELED", 100),
1671            accepted_at(&filled_order, "V-FILLED", 200),
1672            filled_at(&filled_order, "V-FILLED", "T-FILLED", 300),
1673        ];
1674
1675        sort_reconciliation_events(&mut events);
1676
1677        assert_eq!(
1678            event_sequence(&events),
1679            vec![
1680                "O-CANCELED V-CANCELED accepted",
1681                "O-CANCELED V-CANCELED canceled",
1682                "O-FILLED V-FILLED accepted",
1683                "O-FILLED V-FILLED filled",
1684                "O-CANCELED V-CANCELED filled",
1685            ]
1686        );
1687    }
1688
1689    fn market_order(client_order_id: &str, side: OrderSide) -> OrderAny {
1690        OrderTestBuilder::new(OrderType::Market)
1691            .instrument_id(crypto_perpetual_ethusdt().id())
1692            .client_order_id(ClientOrderId::from(client_order_id))
1693            .side(side)
1694            .quantity(Quantity::from("1.000"))
1695            .build()
1696    }
1697
1698    fn accepted_at(order: &OrderAny, venue_order_id: &str, ts: u64) -> OrderEventAny {
1699        let mut event = TestOrderEventStubs::accepted(
1700            order,
1701            AccountId::from("SIM-001"),
1702            VenueOrderId::from(venue_order_id),
1703        );
1704
1705        if let OrderEventAny::Accepted(accepted) = &mut event {
1706            accepted.ts_event = UnixNanos::from(ts);
1707        }
1708
1709        event
1710    }
1711
1712    fn filled_at(order: &OrderAny, venue_order_id: &str, trade_id: &str, ts: u64) -> OrderEventAny {
1713        let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
1714        let mut event = TestOrderEventStubs::filled(
1715            order,
1716            &instrument,
1717            Some(TradeId::from(trade_id)),
1718            None,
1719            Some(Price::from("100.00")),
1720            Some(Quantity::from("1.000")),
1721            None,
1722            None,
1723            Some(UnixNanos::from(ts)),
1724            Some(AccountId::from("SIM-001")),
1725        );
1726
1727        if let OrderEventAny::Filled(filled) = &mut event {
1728            filled.venue_order_id = VenueOrderId::from(venue_order_id);
1729        }
1730
1731        event
1732    }
1733
1734    fn canceled_at(order: &OrderAny, venue_order_id: &str, ts: u64) -> OrderEventAny {
1735        let mut event = TestOrderEventStubs::canceled(
1736            order,
1737            AccountId::from("SIM-001"),
1738            Some(VenueOrderId::from(venue_order_id)),
1739        );
1740
1741        if let OrderEventAny::Canceled(canceled) = &mut event {
1742            canceled.ts_event = UnixNanos::from(ts);
1743        }
1744
1745        event
1746    }
1747
1748    fn event_sequence(events: &[OrderEventAny]) -> Vec<String> {
1749        events
1750            .iter()
1751            .map(|event| {
1752                let kind = match event {
1753                    OrderEventAny::Accepted(_) => "accepted",
1754                    OrderEventAny::Filled(_) => "filled",
1755                    OrderEventAny::Canceled(_) => "canceled",
1756                    _ => "other",
1757                };
1758                let venue_order_id = event
1759                    .venue_order_id()
1760                    .expect("test events carry a venue order ID");
1761
1762                format!("{} {venue_order_id} {kind}", event.client_order_id())
1763            })
1764            .collect()
1765    }
1766}