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::time::Duration;
24#[cfg(feature = "node")]
25use std::{cell::RefCell, rc::Rc};
26
27use indexmap::{IndexMap, IndexSet};
28use nautilus_common::{
29    clients::ExecutionClient,
30    enums::LogLevel,
31    live::dst,
32    messages::execution::{
33        TradingCommand,
34        report::{
35            GenerateFillReports, GenerateOrderStatusReport, GenerateOrderStatusReports,
36            GeneratePositionStatusReports,
37        },
38    },
39};
40use nautilus_core::{UUID4, UnixNanos};
41use nautilus_execution::reconciliation::{
42    create_position_reconciliation_venue_order_id, inferred_reconciliation_trade_ids,
43    should_reconciliation_update,
44};
45use nautilus_model::{
46    enums::{LiquiditySide, OrderSide, OrderStatus, OrderType, TimeInForce},
47    events::{OrderCanceled, OrderEventAny, OrderFilled},
48    identifiers::{
49        AccountId, ClientId, ClientOrderId, InstrumentId, PositionId, StrategyId, TradeId, Venue,
50        VenueOrderId,
51    },
52    instruments::{Instrument, InstrumentAny},
53    orders::{Order, OrderAny},
54    position::Position,
55    reports::{FillReport, OrderStatusReport, PositionStatusReport},
56    types::{Money, Price, Quantity},
57};
58use rust_decimal::Decimal;
59
60#[cfg(feature = "node")]
61use super::client::{CollectedReport, LiveExecutionClient, ReportCollector};
62
63/// Composite key identifying a position context by instrument and account.
64///
65/// Used to scope per-position reconciliation state (retry counters, activity
66/// throttles, venue report lookups) so that multiple accounts holding the same
67/// instrument do not share the same tracking entry.
68pub type InstrumentAccountKey = (InstrumentId, AccountId);
69pub(super) type AccountInstrumentStrategyKey = (AccountId, InstrumentId, StrategyId);
70pub(super) type FillKey = (AccountId, InstrumentId, TradeId);
71
72/// Execution clients responsible for reporting one cached entity.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum ReportClientCoverage {
75    /// Every identified client provides the required report coverage.
76    Resolved(IndexSet<ClientId>),
77    /// Identified clients cannot provide the required report coverage.
78    Unavailable(IndexSet<ClientId>),
79    /// No responsible client could be identified.
80    Unresolved,
81}
82
83/// Metadata for an external order that needs to be registered with the execution client.
84#[derive(Debug, Clone)]
85pub struct ExternalOrderMetadata {
86    pub client_order_id: ClientOrderId,
87    pub venue_order_id: VenueOrderId,
88    pub instrument_id: InstrumentId,
89    pub strategy_id: StrategyId,
90    pub ts_init: UnixNanos,
91}
92
93/// Result of reconciliation containing events, external orders, and unresolved position diagnostics.
94#[derive(Debug, Default)]
95pub struct ReconciliationResult {
96    /// Order events generated during reconciliation.
97    pub events: Vec<OrderEventAny>,
98    /// External orders that need to be registered with execution clients.
99    pub external_orders: Vec<ExternalOrderMetadata>,
100    /// Diagnostics for in-scope nonzero venue positions unrecovered after event processing.
101    pub unresolved_positions: Vec<String>,
102}
103
104/// Result of inflight order checks containing terminal events and intermediate queries.
105#[derive(Debug, Default)]
106pub struct InflightCheckResult {
107    /// Terminal events (rejection/cancellation) for orders that exceeded max retries.
108    pub events: Vec<OrderEventAny>,
109    /// Intermediate venue queries for orders still within retry budget.
110    pub queries: Vec<TradingCommand>,
111}
112
113/// Events and targeted queries produced by open-order reconciliation.
114#[derive(Debug, Default)]
115pub(crate) struct OpenOrderReconciliationResult {
116    pub events: Vec<OrderEventAny>,
117    pub targeted_queries: Vec<TargetedOrderQuery>,
118}
119
120/// Order snapshot and client coverage for a targeted status query.
121#[derive(Debug, Clone)]
122pub(crate) struct TargetedOrderQuery {
123    pub(super) client_order_id: ClientOrderId,
124    pub(super) responsible_clients: IndexSet<ClientId>,
125    pub(super) command: GenerateOrderStatusReport,
126    pub(super) report: Option<OrderStatusReport>,
127    pub(super) filled_qty: Quantity,
128}
129
130impl TargetedOrderQuery {
131    /// Returns the order identifier for the targeted query.
132    pub(crate) const fn client_order_id(&self) -> ClientOrderId {
133        self.client_order_id
134    }
135}
136
137/// Targeted status query result with fills and coverage completeness.
138#[derive(Debug)]
139pub(crate) struct TargetedOrderReportResult {
140    pub(super) client_order_id: ClientOrderId,
141    pub(super) client_id: Option<ClientId>,
142    pub(super) report: Option<OrderStatusReport>,
143    pub(super) fills: Vec<FillReport>,
144    pub(super) coverage_complete: bool,
145}
146
147#[cfg(feature = "node")]
148impl TargetedOrderReportResult {
149    /// Returns whether an order status report is present.
150    pub(crate) const fn has_report(&self) -> bool {
151        self.report.is_some()
152    }
153}
154
155/// Order status report paired with its source execution client.
156#[derive(Debug)]
157pub(crate) struct SourcedOrderStatusReport {
158    pub client_id: ClientId,
159    pub report: OrderStatusReport,
160}
161
162/// Snapshot and command for one continuous open-order reconciliation check.
163#[derive(Debug, Clone)]
164pub(crate) struct OpenOrderReportCheck {
165    pub command: GenerateOrderStatusReports,
166    pub filtered_orders: Vec<OrderAny>,
167    pub client_coverage: IndexMap<ClientOrderId, ReportClientCoverage>,
168}
169
170/// Prepare-time state and command for one continuous position reconciliation check.
171#[derive(Debug, Clone)]
172pub struct PositionReportCheck {
173    /// The bulk position query.
174    pub command: GeneratePositionStatusReports,
175    /// Responsible clients by instrument and account.
176    pub client_coverage: IndexMap<InstrumentAccountKey, ReportClientCoverage>,
177    /// Activity revisions captured before the query.
178    pub activity_revisions: IndexMap<InstrumentAccountKey, u64>,
179}
180
181/// Fill report request for one instrument, account, and execution client.
182#[derive(Debug)]
183pub struct PositionFillReportQuery {
184    /// The instrument and account to reconcile.
185    pub key: InstrumentAccountKey,
186    /// The responsible execution client.
187    pub client_id: ClientId,
188    /// The authoritative fill query.
189    pub command: GenerateFillReports,
190}
191
192/// Fill queries and discrepancy keys for a position reconciliation check.
193#[derive(Debug)]
194pub struct PositionFillReportPlan {
195    /// Authoritative fill queries that are safe to run.
196    pub queries: Vec<PositionFillReportQuery>,
197    /// Position keys that still differ from the venue snapshot.
198    pub discrepancy_keys: IndexSet<InstrumentAccountKey>,
199}
200
201/// Whether a fill is attributable and free of inferred-fill or reconciled-position overlap.
202#[derive(Debug)]
203pub enum PositionFillReportPreparation {
204    /// The report can be applied to the cached execution state.
205    Ready,
206    /// An active inferred fill prevents authoritative replay.
207    InferredOverlap,
208    /// A position reconciled from a venue position report already includes the fill.
209    SnapshotOverlap,
210    /// A hedge fill cannot be assigned to an unambiguous position.
211    Unattributed,
212}
213
214/// Cached and venue position quantities and report shape for comparison.
215pub(super) struct PositionQuantityComparison {
216    pub(super) cached_positions: Vec<Position>,
217    pub(super) cached_signed_qty: Decimal,
218    pub(super) cached_long_qty: Decimal,
219    pub(super) cached_short_qty: Decimal,
220    pub(super) venue_signed_qty: Decimal,
221    pub(super) venue_long_qty: Decimal,
222    pub(super) venue_short_qty: Decimal,
223    pub(super) nonflat_count: usize,
224    pub(super) venue_report: Option<PositionStatusReport>,
225    pub(super) venue_has_side_reports: bool,
226}
227
228impl PositionQuantityComparison {
229    /// Checks net quantities and, when both venue sides are reported, side quantities.
230    pub(super) fn quantities_match(&self, tolerance: Decimal) -> bool {
231        let net_qty_matches = (self.cached_signed_qty - self.venue_signed_qty).abs() <= tolerance;
232        let side_qty_matches = (self.cached_long_qty - self.venue_long_qty).abs() <= tolerance
233            && (self.cached_short_qty - self.venue_short_qty).abs() <= tolerance;
234
235        net_qty_matches && (!self.venue_has_side_reports || side_qty_matches)
236    }
237
238    /// Classifies venue reports as a single unambiguous position or multiple legs.
239    pub(super) fn report_shape(&self) -> PositionReportShape {
240        if self.nonflat_count > 1 || self.venue_has_side_reports {
241            PositionReportShape::MultiLeg
242        } else {
243            PositionReportShape::Unambiguous
244        }
245    }
246}
247
248/// Cached fill identities, missing orders, and netting lifecycle boundaries.
249pub(super) struct RetainedFillState {
250    pub(super) fill_keys: IndexSet<FillKey>,
251    pub(super) missing_order_ids: IndexSet<(AccountId, InstrumentId, ClientOrderId)>,
252    pub(super) missing_venue_order_ids: IndexSet<(AccountId, InstrumentId, VenueOrderId)>,
253    pub(super) netting_lifecycle_starts: IndexMap<AccountInstrumentStrategyKey, UnixNanos>,
254}
255
256/// Tracks pending fill identities and their generated reconciliation events.
257#[derive(Default)]
258pub(super) struct ReconciliationFillQueue {
259    pub(super) pending_fill_keys: IndexSet<FillKey>,
260    pub(super) event_fill_keys: IndexMap<UUID4, FillKey>,
261}
262
263impl ReconciliationFillQueue {
264    /// Queues a fill event and records its identity for deduplication.
265    pub(super) fn push(
266        &mut self,
267        events: &mut Vec<OrderEventAny>,
268        fill: OrderFilled,
269        fill_key: FillKey,
270    ) {
271        self.pending_fill_keys.insert(fill_key);
272        self.event_fill_keys.insert(fill.event_id, fill_key);
273        events.push(OrderEventAny::Filled(fill));
274    }
275}
276
277/// Information about an inflight order check.
278#[derive(Debug, Clone)]
279pub(super) struct InflightCheck {
280    pub submitted_at: dst::time::Instant,
281    pub retry_count: u32,
282    // `Instant` debug output is runtime-specific and intentionally only useful
283    // as an opaque monotonic offset.
284    pub last_query_at: Option<dst::time::Instant>,
285}
286
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288pub(super) enum PositionReportShape {
289    Unambiguous,
290    MultiLeg,
291}
292
293/// Retry count and report shape for position reconciliation.
294#[derive(Debug, Clone, Copy)]
295pub(super) struct PositionReconciliationState {
296    pub(super) report_shape: PositionReportShape,
297    pub(super) retries: u32,
298}
299
300/// Requests targeted order status and missing fills from responsible clients.
301pub(crate) async fn request_targeted_order_reports(
302    queries: Vec<TargetedOrderQuery>,
303    clients: &[&dyn ExecutionClient],
304    query_delay: Duration,
305) -> Vec<TargetedOrderReportResult> {
306    let mut results = Vec::with_capacity(queries.len());
307    let mut request_count = 0usize;
308
309    for mut query in queries {
310        let mut report = None;
311        let mut fills = Vec::new();
312        let mut report_client_id = None;
313        let mut coverage_complete = true;
314
315        for client_id in &query.responsible_clients {
316            let client_id = *client_id;
317
318            let Some(client) = clients
319                .iter()
320                .find(|client| client.client_id() == client_id)
321            else {
322                coverage_complete = false;
323                log::warn!(
324                    "Cannot run targeted order status query for {}: execution client {client_id} is unavailable",
325                    query.client_order_id,
326                );
327                continue;
328            };
329
330            if request_count > 0 && !query_delay.is_zero() {
331                dst::time::sleep(query_delay).await;
332            }
333
334            request_count += 1;
335
336            let response = if let Some(report) = query.report.take() {
337                Ok(Some(report))
338            } else {
339                client.generate_order_status_report(&query.command).await
340            };
341
342            if let Some(candidate) =
343                targeted_report_response(&query, client_id, response, &mut coverage_complete)
344            {
345                if terminal_report_has_missing_fills(&candidate, query.filled_qty) {
346                    let command = targeted_fill_command(&query, &candidate);
347
348                    match client.generate_fill_reports(command).await {
349                        Ok(reports) => {
350                            fills = reports
351                                .into_iter()
352                                .filter(|fill| targeted_fill_matches(&candidate, fill))
353                                .collect();
354                        }
355                        Err(e) => log::warn!(
356                            "Failed fill report query from {client_id} for {}: {e}",
357                            query.client_order_id,
358                        ),
359                    }
360                }
361
362                report = Some(candidate);
363                report_client_id = Some(client_id);
364                break;
365            }
366        }
367
368        results.push(TargetedOrderReportResult {
369            client_order_id: query.client_order_id(),
370            client_id: report_client_id,
371            report,
372            fills,
373            coverage_complete,
374        });
375    }
376
377    results
378}
379
380/// Returns whether all targeted queries complete within their shared collection budget.
381///
382/// Accepted statuses and fills remain in `results` across later expiry or cancellation.
383#[cfg(feature = "node")]
384pub(crate) async fn collect_targeted_order_reports(
385    queries: Vec<TargetedOrderQuery>,
386    clients: Vec<LiveExecutionClient>,
387    query_delay: Duration,
388    deadline: dst::time::Instant,
389    results: Rc<RefCell<Vec<TargetedOrderReportResult>>>,
390) -> bool {
391    let mut collectors = IndexMap::new();
392    let mut request_count = 0usize;
393
394    for mut query in queries {
395        collectors.clear();
396
397        let Some(result) = collect_targeted_order_status(
398            &mut query,
399            &clients,
400            query_delay,
401            deadline,
402            &mut request_count,
403        )
404        .await
405        else {
406            return false;
407        };
408
409        let fill_query = result
410            .report
411            .as_ref()
412            .filter(|report| terminal_report_has_missing_fills(report, query.filled_qty))
413            .map(|report| (targeted_fill_command(&query, report), report.clone()));
414        let client_id = result.client_id;
415        let result_index = results.borrow().len();
416        results.borrow_mut().push(result);
417
418        if let Some((command, report)) = fill_query {
419            let client_id = client_id.expect("matched status has a reporting client");
420
421            if dst::time::Instant::now() >= deadline {
422                return false;
423            }
424
425            let receipt = async {
426                let collector =
427                    ReportCollector::for_client(&mut collectors, &clients, client_id).await?;
428                collector.fill_reports(command).await
429            }
430            .await;
431
432            let Some(response) = accept_collected_report(receipt, deadline) else {
433                return false;
434            };
435
436            match response {
437                Ok(fills) => {
438                    results.borrow_mut()[result_index].fills = fills
439                        .into_iter()
440                        .filter(|fill| targeted_fill_matches(&report, fill))
441                        .collect();
442                }
443                Err(e) => log::warn!(
444                    "Failed fill report query from {client_id} for {}: {e}",
445                    query.client_order_id
446                ),
447            }
448        }
449    }
450
451    true
452}
453
454#[cfg(feature = "node")]
455async fn collect_targeted_order_status(
456    query: &mut TargetedOrderQuery,
457    clients: &[LiveExecutionClient],
458    query_delay: Duration,
459    deadline: dst::time::Instant,
460    request_count: &mut usize,
461) -> Option<TargetedOrderReportResult> {
462    let mut coverage_complete = true;
463
464    for client_id in &query.responsible_clients {
465        let client_id = *client_id;
466        let Some(client) = clients
467            .iter()
468            .find(|client| client.client_id() == client_id)
469        else {
470            coverage_complete = false;
471            log::warn!(
472                "Cannot run targeted order status query for {}: execution client {client_id} is unavailable",
473                query.client_order_id
474            );
475            continue;
476        };
477
478        if *request_count > 0 && !query_delay.is_zero() {
479            dst::time::sleep(query_delay).await;
480        }
481
482        if dst::time::Instant::now() >= deadline {
483            return None;
484        }
485
486        *request_count += 1;
487
488        let response = if let Some(report) = query.report.take() {
489            Ok(Some(report))
490        } else {
491            let receipt = client.collect_order_status_report(&query.command).await;
492
493            accept_collected_report(receipt, deadline)?
494        };
495
496        if let Some(report) =
497            targeted_report_response(query, client_id, response, &mut coverage_complete)
498        {
499            return Some(TargetedOrderReportResult {
500                client_order_id: query.client_order_id(),
501                client_id: Some(client_id),
502                report: Some(report),
503                fills: Vec::new(),
504                coverage_complete,
505            });
506        }
507    }
508
509    Some(TargetedOrderReportResult {
510        client_order_id: query.client_order_id(),
511        client_id: None,
512        report: None,
513        fills: Vec::new(),
514        coverage_complete,
515    })
516}
517
518#[cfg(feature = "node")]
519fn accept_collected_report<T>(
520    receipt: anyhow::Result<CollectedReport<T>>,
521    deadline: dst::time::Instant,
522) -> Option<anyhow::Result<T>> {
523    if dst::time::Instant::now() >= deadline {
524        return None;
525    }
526
527    Some(receipt.and_then(CollectedReport::finish))
528}
529
530fn targeted_report_response(
531    query: &TargetedOrderQuery,
532    client_id: ClientId,
533    response: anyhow::Result<Option<OrderStatusReport>>,
534    coverage_complete: &mut bool,
535) -> Option<OrderStatusReport> {
536    match response {
537        Ok(Some(report)) if targeted_report_matches(query, &report) => Some(report),
538        Ok(Some(candidate)) => {
539            *coverage_complete = false;
540            log::warn!(
541                "Ignoring mismatched targeted order status report from {client_id} for {}: client_order_id={:?}, venue_order_id={}, instrument_id={}",
542                query.client_order_id,
543                candidate.client_order_id,
544                candidate.venue_order_id,
545                candidate.instrument_id,
546            );
547            None
548        }
549        Ok(None) => None,
550        Err(e) => {
551            *coverage_complete = false;
552            log::warn!(
553                "Failed targeted order status query from {client_id} for {}: {e}",
554                query.client_order_id
555            );
556            None
557        }
558    }
559}
560
561fn targeted_fill_command(
562    query: &TargetedOrderQuery,
563    report: &OrderStatusReport,
564) -> GenerateFillReports {
565    let mut command = GenerateFillReports::new(
566        UUID4::new(),
567        query.command.ts_init,
568        Some(report.instrument_id),
569        Some(report.venue_order_id),
570        None,
571        None,
572        None,
573        Some(query.command.command_id),
574    );
575    command.log_receipt_level = LogLevel::Debug;
576    command
577}
578
579fn targeted_fill_matches(report: &OrderStatusReport, fill: &FillReport) -> bool {
580    fill.account_id == report.account_id
581        && fill.instrument_id == report.instrument_id
582        && fill.venue_order_id == report.venue_order_id
583        && report.order_side.is_none_or(|side| fill.order_side == side)
584}
585
586/// Checks whether cached order status, filled quantity, and report fields match.
587pub(super) fn is_exact_order_match(order: &OrderAny, report: &OrderStatusReport) -> bool {
588    order.status() == report.order_status
589        && order.filled_qty() == report.filled_qty
590        && !should_reconciliation_update(order, report)
591}
592
593fn targeted_report_matches(query: &TargetedOrderQuery, report: &OrderStatusReport) -> bool {
594    let instrument_matches = query
595        .command
596        .instrument_id
597        .is_none_or(|instrument_id| report.instrument_id == instrument_id);
598    let order_matches = report.client_order_id == Some(query.client_order_id)
599        || query
600            .command
601            .venue_order_id
602            .is_some_and(|venue_order_id| report.venue_order_id == venue_order_id);
603
604    instrument_matches && order_matches
605}
606
607/// Checks whether a canceled or expired report has more fills than the cached order.
608pub(super) fn terminal_report_has_missing_fills(
609    report: &OrderStatusReport,
610    cached_filled_qty: Quantity,
611) -> bool {
612    matches!(
613        report.order_status,
614        OrderStatus::Canceled | OrderStatus::Expired
615    ) && report.filled_qty > cached_filled_qty
616}
617
618/// Builds an order report from fills sharing the same order and venue position.
619///
620/// # Errors
621///
622/// Returns an error if fills are empty or inconsistent, instrument metadata does not match, or
623/// aggregate quantities or prices cannot be represented.
624pub(super) fn create_orphan_fill_order_report(
625    fills: &[&FillReport],
626    instrument: &InstrumentAny,
627) -> anyhow::Result<OrderStatusReport> {
628    let Some(first) = fills.first() else {
629        anyhow::bail!("fill group is empty");
630    };
631
632    let venue_position_id = first
633        .venue_position_id
634        .ok_or_else(|| anyhow::anyhow!("venue position ID is missing"))?;
635
636    for fill in fills.iter().skip(1) {
637        anyhow::ensure!(
638            fill.account_id == first.account_id,
639            "account ID differs across fill group"
640        );
641        anyhow::ensure!(
642            fill.instrument_id == first.instrument_id,
643            "instrument ID differs across fill group"
644        );
645        anyhow::ensure!(
646            fill.venue_order_id == first.venue_order_id,
647            "venue order ID differs across fill group"
648        );
649        anyhow::ensure!(
650            fill.client_order_id == first.client_order_id,
651            "client order ID differs across fill group"
652        );
653        anyhow::ensure!(
654            fill.order_side == first.order_side,
655            "order side differs across fill group"
656        );
657        anyhow::ensure!(
658            fill.venue_position_id == first.venue_position_id,
659            "venue position ID differs across fill group"
660        );
661    }
662
663    anyhow::ensure!(
664        first.instrument_id == instrument.id(),
665        "instrument metadata does not match fill group"
666    );
667
668    let (quantity, notional) = fills.iter().try_fold(
669        (Decimal::ZERO, Decimal::ZERO),
670        |(quantity, notional), fill| {
671            let fill_quantity = fill.last_qty.as_decimal();
672
673            let quantity = quantity.checked_add(fill_quantity).ok_or_else(|| {
674                anyhow::anyhow!("fill quantity overflow while aggregating fill group")
675            })?;
676
677            let fill_notional = fill_quantity
678                .checked_mul(fill.last_px.as_decimal())
679                .ok_or_else(|| {
680                    anyhow::anyhow!("fill notional overflow while aggregating fill group")
681                })?;
682
683            let notional = notional.checked_add(fill_notional).ok_or_else(|| {
684                anyhow::anyhow!("fill notional overflow while aggregating fill group")
685            })?;
686
687            Ok::<_, anyhow::Error>((quantity, notional))
688        },
689    )?;
690
691    anyhow::ensure!(
692        quantity > Decimal::ZERO,
693        "fill group quantity is not positive"
694    );
695
696    let order_qty = Quantity::from_decimal_dp(quantity, instrument.size_precision())?;
697    let avg_px = notional
698        .checked_div(quantity)
699        .ok_or_else(|| anyhow::anyhow!("fill group average price is not representable"))?;
700
701    let ts_accepted = fills
702        .iter()
703        .map(|fill| fill.ts_event)
704        .min()
705        .expect("non-empty fill group");
706
707    let ts_last = fills
708        .iter()
709        .map(|fill| fill.ts_event)
710        .max()
711        .expect("non-empty fill group");
712
713    let ts_init = fills
714        .iter()
715        .map(|fill| fill.ts_init)
716        .max()
717        .expect("non-empty fill group");
718
719    let report = OrderStatusReport::new(
720        first.account_id,
721        first.instrument_id,
722        first.client_order_id,
723        first.venue_order_id,
724        first.order_side.into(),
725        OrderType::Market,
726        TimeInForce::Gtc,
727        OrderStatus::Filled,
728        order_qty,
729        order_qty,
730        ts_accepted,
731        ts_last,
732        ts_init,
733        None,
734    )
735    .with_avg_px(avg_px)
736    .with_venue_position_id(venue_position_id);
737
738    Ok(report)
739}
740
741/// Checks whether a fill belongs in the retained position projection.
742pub(super) fn should_project_fill(
743    fill: &OrderFilled,
744    retained_fill_state: &RetainedFillState,
745    reported_fill_keys: &IndexSet<FillKey>,
746    order_only_ids: &IndexSet<VenueOrderId>,
747    order_only_fill_keys: &IndexSet<FillKey>,
748) -> bool {
749    let fill_key = (fill.account_id, fill.instrument_id, fill.trade_id);
750    if retained_fill_state.fill_keys.contains(&fill_key)
751        || order_only_ids.contains(&fill.venue_order_id)
752        || order_only_fill_keys.contains(&fill_key)
753    {
754        return true;
755    }
756
757    let order_missing = retained_fill_state.missing_order_ids.contains(&(
758        fill.account_id,
759        fill.instrument_id,
760        fill.client_order_id,
761    )) || retained_fill_state.missing_venue_order_ids.contains(&(
762        fill.account_id,
763        fill.instrument_id,
764        fill.venue_order_id,
765    ));
766
767    if order_missing && !reported_fill_keys.contains(&fill_key) {
768        return true;
769    }
770
771    retained_fill_state
772        .netting_lifecycle_starts
773        .get(&(fill.account_id, fill.instrument_id, fill.strategy_id))
774        .is_some_and(|ts_opened| fill.ts_event < *ts_opened)
775}
776
777/// Sorts reconciliation events by event time while keeping each venue order's lifecycle events
778/// ahead of the fills generated after them.
779///
780/// Fills sort by their own event time. A report's acceptance time can postdate its own
781/// fills when the venue reports a last update time or the adapter uses the reconciliation time,
782/// so any other event sorts no later than the earliest fill that follows it for the same client
783/// and venue order. Events of other venue orders, including a replaced order that shares the
784/// client order ID, keep their own times.
785pub(super) fn sort_reconciliation_events(events: &mut Vec<OrderEventAny>) {
786    let mut earliest_later_fill: IndexMap<(ClientOrderId, Option<VenueOrderId>), UnixNanos> =
787        IndexMap::new();
788    let mut keys = vec![UnixNanos::default(); events.len()];
789
790    for (index, event) in events.iter().enumerate().rev() {
791        let venue_order = (event.client_order_id(), event.venue_order_id());
792        let ts_event = event.ts_event();
793
794        keys[index] = if matches!(event, OrderEventAny::Filled(_)) {
795            let earliest = earliest_later_fill.entry(venue_order).or_insert(ts_event);
796            *earliest = (*earliest).min(ts_event);
797            ts_event
798        } else {
799            earliest_later_fill
800                .get(&venue_order)
801                .map_or(ts_event, |fill_ts| ts_event.min(*fill_ts))
802        };
803    }
804
805    let mut keyed: Vec<(UnixNanos, OrderEventAny)> =
806        keys.into_iter().zip(events.drain(..)).collect();
807    keyed.sort_by_key(|(key, _)| *key);
808    events.extend(keyed.into_iter().map(|(_, event)| event));
809}
810
811/// Checks active fill history for deterministic inferred reconciliation IDs.
812///
813/// Replays the order only when an active reconciliation fill has the inferred ID format.
814///
815/// # Errors
816///
817/// Returns an error if the order requires replay and its cached history cannot be replayed.
818pub(super) fn has_active_inferred_fill(order: &OrderAny) -> anyhow::Result<bool> {
819    Ok(!inferred_reconciliation_trade_ids(order)?.is_empty())
820}
821
822/// Calculates inferred-fill commission using the responsible execution client.
823///
824/// # Errors
825///
826/// Returns an error if the client is unavailable or commission calculation fails.
827pub(super) fn resolve_inferred_fill_commission(
828    fill_qty: Quantity,
829    price_and_liquidity: Option<(Price, LiquiditySide)>,
830    instrument: &InstrumentAny,
831    client: Option<&dyn ExecutionClient>,
832) -> anyhow::Result<Option<Money>> {
833    let Some(client) = client else {
834        anyhow::bail!("responsible execution client is unavailable");
835    };
836
837    let Some((last_px, liquidity_side)) = price_and_liquidity else {
838        return Ok(None);
839    };
840
841    client.calculate_commission(instrument, fill_qty, last_px, liquidity_side)
842}
843
844/// Resolves position-report coverage by account, falling back to venue clients.
845pub(crate) fn resolve_position_report_client_coverage(
846    key: InstrumentAccountKey,
847    clients: &[&dyn ExecutionClient],
848) -> ReportClientCoverage {
849    let client_ids = responsible_client_ids(Some(key.1), key.0.venue, clients);
850
851    if client_ids.is_empty() {
852        ReportClientCoverage::Unresolved
853    } else if clients.iter().any(|client| {
854        client_ids.contains(&client.client_id()) && !client.provides_bulk_position_coverage(key.0)
855    }) {
856        ReportClientCoverage::Unavailable(client_ids)
857    } else {
858        ReportClientCoverage::Resolved(client_ids)
859    }
860}
861
862/// Returns the account's clients, falling back to clients handling the venue.
863pub(super) fn responsible_client_ids(
864    account_id: Option<AccountId>,
865    venue: Venue,
866    clients: &[&dyn ExecutionClient],
867) -> IndexSet<ClientId> {
868    if let Some(account_id) = account_id {
869        let account_clients = clients
870            .iter()
871            .filter(|client| client.account_id() == account_id)
872            .map(|client| client.client_id())
873            .collect::<IndexSet<_>>();
874
875        if !account_clients.is_empty() {
876            return account_clients;
877        }
878    }
879
880    clients
881        .iter()
882        .filter(|client| client.handles_order_venue(venue))
883        .map(|client| client.client_id())
884        .collect()
885}
886
887/// Returns the quantity-weighted average of positive position entry prices.
888pub(super) fn position_avg_px<'a>(
889    positions: impl IntoIterator<Item = &'a Position>,
890) -> Option<Decimal> {
891    let mut total_value = Decimal::ZERO;
892    let mut total_qty = Decimal::ZERO;
893
894    for position in positions {
895        let qty = position.signed_decimal_qty().abs();
896        if position.avg_px_open > Decimal::ZERO && qty > Decimal::ZERO {
897            total_value += position.avg_px_open * qty;
898            total_qty += qty;
899        }
900    }
901
902    if total_qty > Decimal::ZERO {
903        Some(total_value / total_qty)
904    } else {
905        None
906    }
907}
908
909/// Aggregates signed quantities into net, long, and absolute short totals.
910pub(super) fn position_qty_aggregates(
911    signed_quantities: impl Iterator<Item = Decimal>,
912) -> (Decimal, Decimal, Decimal) {
913    signed_quantities.fold(
914        (Decimal::ZERO, Decimal::ZERO, Decimal::ZERO),
915        |(net, long, short), qty| {
916            if qty > Decimal::ZERO {
917                (net + qty, long + qty, short)
918            } else {
919                (net + qty, long, short + qty.abs())
920            }
921        },
922    )
923}
924
925/// Returns the reports without duplicates, logging a warning for each one removed.
926///
927/// Reports are duplicates when they match apart from the locally assigned report ID and
928/// initialization timestamp. Keeps the first occurrence of each report in input order.
929pub(super) fn distinct_position_reports(
930    reports: Vec<PositionStatusReport>,
931) -> Vec<PositionStatusReport> {
932    let mut distinct_reports: Vec<PositionStatusReport> = Vec::with_capacity(reports.len());
933
934    for report in reports {
935        let is_duplicate = distinct_reports.iter().any(|distinct| {
936            let mut normalized = distinct.clone();
937            normalized.report_id = report.report_id;
938            normalized.ts_init = report.ts_init;
939            normalized == report
940        });
941
942        if is_duplicate {
943            log::warn!("Duplicate position report for {}", report.instrument_id);
944            continue;
945        }
946
947        distinct_reports.push(report);
948    }
949
950    distinct_reports
951}
952
953/// Builds a filled market-order report that moves a position by `quantity` on `order_side`.
954#[expect(clippy::too_many_arguments)]
955pub(super) fn create_position_reconciliation_report(
956    instrument: &InstrumentAny,
957    account_id: AccountId,
958    order_side: OrderSide,
959    quantity: Quantity,
960    avg_px: Decimal,
961    venue_position_id: Option<PositionId>,
962    tag: Option<&str>,
963    ts_now: UnixNanos,
964    venue_ts_last: UnixNanos,
965) -> OrderStatusReport {
966    let instrument_id = instrument.id();
967    let fill_price = Price::from_decimal_dp(avg_px, instrument.price_precision()).ok();
968    let venue_order_id = create_position_reconciliation_venue_order_id(
969        account_id,
970        instrument_id,
971        order_side,
972        OrderType::Market,
973        quantity,
974        fill_price,
975        venue_position_id,
976        tag,
977        venue_ts_last,
978    );
979
980    let mut report = OrderStatusReport::new(
981        account_id,
982        instrument_id,
983        None,
984        venue_order_id,
985        order_side.into(),
986        OrderType::Market,
987        TimeInForce::Gtc,
988        OrderStatus::Filled,
989        quantity,
990        quantity,
991        ts_now,
992        ts_now,
993        ts_now,
994        None,
995    )
996    .with_avg_px(avg_px);
997
998    if let Some(venue_position_id) = venue_position_id {
999        report = report.with_venue_position_id(venue_position_id);
1000    }
1001
1002    report
1003}
1004
1005/// Builds a reconciliation cancel for an order resolved without a venue status report.
1006pub(super) fn create_reconciliation_canceled(order: &OrderAny, ts_now: UnixNanos) -> OrderEventAny {
1007    OrderEventAny::Canceled(OrderCanceled::new(
1008        order.trader_id(),
1009        order.strategy_id(),
1010        order.instrument_id(),
1011        order.client_order_id(),
1012        UUID4::new(),
1013        ts_now,
1014        ts_now,
1015        true, // reconciliation
1016        order.venue_order_id(),
1017        order.account_id(),
1018        None,
1019    ))
1020}
1021
1022#[cfg(test)]
1023pub(super) mod tests {
1024    use std::cell::RefCell;
1025
1026    use nautilus_core::Params;
1027    use nautilus_execution::reconciliation::inferred_fill_price_and_liquidity;
1028    use nautilus_model::{
1029        accounts::AccountAny,
1030        enums::{OmsType, PositionSide},
1031        identifiers::Venue,
1032        instruments::stubs::crypto_perpetual_ethusdt,
1033        orders::{OrderTestBuilder, stubs::TestOrderEventStubs},
1034        types::{AccountBalance, Currency, MarginBalance, quantity::QUANTITY_MAX},
1035    };
1036    use proptest::prelude::*;
1037    use rstest::rstest;
1038    use rust_decimal_macros::dec;
1039
1040    use super::*;
1041
1042    #[cfg(feature = "node")]
1043    mod targeted_collection {
1044        use std::{
1045            cell::Cell,
1046            sync::{
1047                Arc,
1048                atomic::{AtomicUsize, Ordering},
1049            },
1050        };
1051
1052        use futures_util::FutureExt;
1053        use nautilus_common::clients::ExecutionReportTask;
1054
1055        use super::*;
1056
1057        type CollectionEvent = (&'static str, ClientId, dst::time::Instant);
1058
1059        #[derive(Debug, Clone, Default)]
1060        struct CollectionState {
1061            events: Rc<RefCell<Vec<CollectionEvent>>>,
1062            commands: Rc<RefCell<Vec<GenerateFillReports>>>,
1063            started: Arc<AtomicUsize>,
1064            status_finished: Rc<Cell<usize>>,
1065            fill_finished: Rc<Cell<usize>>,
1066        }
1067
1068        #[derive(Debug)]
1069        struct CollectionClient {
1070            id: ClientId,
1071            report: Option<OrderStatusReport>,
1072            fills: Vec<FillReport>,
1073            state: CollectionState,
1074            worker: bool,
1075            release_at: usize,
1076            release: Arc<tokio::sync::Notify>,
1077        }
1078
1079        impl CollectionClient {
1080            fn status_task(&self) -> ExecutionReportTask<Option<OrderStatusReport>> {
1081                self.state
1082                    .events
1083                    .borrow_mut()
1084                    .push(("status", self.id, dst::time::Instant::now()));
1085                let state = self.state.clone();
1086                let started = self.state.started.clone();
1087                let report = self.report.clone();
1088                let release_at = self.release_at;
1089                let release = self.release.clone();
1090
1091                ExecutionReportTask::new(
1092                    async move {
1093                        if started.fetch_add(1, Ordering::SeqCst) + 1 == release_at {
1094                            release.notified().await;
1095                        }
1096
1097                        Ok(report)
1098                    },
1099                    move |report| {
1100                        state.status_finished.set(state.status_finished.get() + 1);
1101                        Ok(report)
1102                    },
1103                )
1104            }
1105
1106            fn fill_task(&self, cmd: &GenerateFillReports) -> ExecutionReportTask<Vec<FillReport>> {
1107                self.state
1108                    .events
1109                    .borrow_mut()
1110                    .push(("fills", self.id, dst::time::Instant::now()));
1111                self.state.commands.borrow_mut().push(cmd.clone());
1112                let state = self.state.clone();
1113                let started = self.state.started.clone();
1114                let reports = self.fills.clone();
1115                let release_at = self.release_at;
1116                let release = self.release.clone();
1117
1118                ExecutionReportTask::new(
1119                    async move {
1120                        if started.fetch_add(1, Ordering::SeqCst) + 1 == release_at {
1121                            release.notified().await;
1122                        }
1123
1124                        Ok(reports)
1125                    },
1126                    move |reports| {
1127                        state.fill_finished.set(state.fill_finished.get() + 1);
1128                        Ok(reports)
1129                    },
1130                )
1131            }
1132        }
1133
1134        #[async_trait::async_trait(?Send)]
1135        impl ExecutionClient for CollectionClient {
1136            fn is_connected(&self) -> bool {
1137                true
1138            }
1139
1140            fn client_id(&self) -> ClientId {
1141                self.id
1142            }
1143
1144            fn account_id(&self) -> AccountId {
1145                AccountId::from("TEST-001")
1146            }
1147
1148            fn venue(&self) -> Venue {
1149                Venue::from("BINANCE")
1150            }
1151
1152            fn oms_type(&self) -> OmsType {
1153                OmsType::Netting
1154            }
1155
1156            fn get_account(&self) -> Option<AccountAny> {
1157                None
1158            }
1159
1160            fn start(&mut self) -> anyhow::Result<()> {
1161                Ok(())
1162            }
1163
1164            fn stop(&mut self) -> anyhow::Result<()> {
1165                Ok(())
1166            }
1167
1168            fn generate_account_state(
1169                &self,
1170                _: Vec<AccountBalance>,
1171                _: Vec<MarginBalance>,
1172                _: bool,
1173                _: UnixNanos,
1174                _: Option<nautilus_core::Params>,
1175            ) -> anyhow::Result<()> {
1176                Ok(())
1177            }
1178
1179            fn generate_order_status_report_task(
1180                &self,
1181                _: &GenerateOrderStatusReport,
1182            ) -> Option<ExecutionReportTask<Option<OrderStatusReport>>> {
1183                self.worker.then(|| self.status_task())
1184            }
1185
1186            async fn generate_order_status_report(
1187                &self,
1188                _: &GenerateOrderStatusReport,
1189            ) -> anyhow::Result<Option<OrderStatusReport>> {
1190                let task = self.status_task();
1191                task.collection.await;
1192                (task.finish)()
1193            }
1194
1195            fn generate_fill_reports_task(
1196                &self,
1197                cmd: &GenerateFillReports,
1198            ) -> Option<ExecutionReportTask<Vec<FillReport>>> {
1199                self.worker.then(|| self.fill_task(cmd))
1200            }
1201
1202            async fn generate_fill_reports(
1203                &self,
1204                cmd: GenerateFillReports,
1205            ) -> anyhow::Result<Vec<FillReport>> {
1206                let task = self.fill_task(&cmd);
1207                task.collection.await;
1208                (task.finish)()
1209            }
1210        }
1211
1212        fn query() -> TargetedOrderQuery {
1213            TargetedOrderQuery {
1214                client_order_id: ClientOrderId::from("O-TARGETED"),
1215                responsible_clients: IndexSet::from([ClientId::from("A")]),
1216                command: GenerateOrderStatusReport::new(
1217                    UUID4::new(),
1218                    UnixNanos::from(123),
1219                    Some(InstrumentId::from("ETHUSDT-PERP.BINANCE")),
1220                    Some(ClientOrderId::from("O-TARGETED")),
1221                    Some(VenueOrderId::from("V-TARGETED")),
1222                    None,
1223                    None,
1224                ),
1225                report: None,
1226                filled_qty: Quantity::from("0.0"),
1227            }
1228        }
1229
1230        fn report() -> OrderStatusReport {
1231            let mut report = OrderStatusReport::new(
1232                AccountId::from("TEST-001"),
1233                InstrumentId::from("ETHUSDT-PERP.BINANCE"),
1234                Some(ClientOrderId::from("O-TARGETED")),
1235                VenueOrderId::from("V-TARGETED"),
1236                Some(OrderSide::Buy),
1237                OrderType::Limit,
1238                TimeInForce::Gtc,
1239                OrderStatus::Canceled,
1240                Quantity::from("3.0"),
1241                Quantity::from("1.0"),
1242                UnixNanos::from(11),
1243                UnixNanos::from(22),
1244                UnixNanos::from(33),
1245                None,
1246            );
1247            report.report_id = UUID4::from("00000000-0000-4000-8000-000000000011");
1248            report
1249        }
1250
1251        fn fill() -> FillReport {
1252            let mut report = FillReport::new(
1253                AccountId::from("TEST-001"),
1254                InstrumentId::from("ETHUSDT-PERP.BINANCE"),
1255                VenueOrderId::from("V-TARGETED"),
1256                TradeId::from("T-TARGETED"),
1257                OrderSide::Buy,
1258                Quantity::from("1.0"),
1259                Price::from("101.0"),
1260                Money::from("0.25 USDT"),
1261                LiquiditySide::Taker,
1262                Some(ClientOrderId::from("O-TARGETED")),
1263                None,
1264                UnixNanos::from(21),
1265                UnixNanos::from(31),
1266                None,
1267            );
1268            report.report_id = UUID4::from("00000000-0000-4000-8000-000000000022");
1269            report
1270        }
1271
1272        fn client(state: &CollectionState, worker: bool, release_at: usize) -> CollectionClient {
1273            CollectionClient {
1274                id: ClientId::from("A"),
1275                report: Some(report()),
1276                fills: vec![fill()],
1277                state: state.clone(),
1278                worker,
1279                release_at,
1280                release: Arc::new(tokio::sync::Notify::new()),
1281            }
1282        }
1283
1284        #[cfg_attr(
1285            all(feature = "simulation", madsim),
1286            allow(
1287                clippy::unused_async,
1288                reason = "the Tokio clock requires an awaitable advance operation"
1289            )
1290        )]
1291        async fn advance(duration: Duration) {
1292            #[cfg(all(feature = "simulation", madsim))]
1293            madsim::time::advance(duration);
1294            #[cfg(not(all(feature = "simulation", madsim)))]
1295            tokio::time::advance(duration).await;
1296        }
1297
1298        #[rstest]
1299        #[cfg_attr(
1300            not(all(feature = "simulation", madsim)),
1301            tokio::test(start_paused = true)
1302        )]
1303        #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1304        async fn test_targeted_collection_preserves_schedule_and_first_match(
1305            #[values(false, true)] worker: bool,
1306        ) {
1307            let state = CollectionState::default();
1308            let mut a = client(&state, worker, 0);
1309            a.report = None;
1310            let mut b = client(&state, worker, 0);
1311            b.id = ClientId::from("B");
1312            let mut c = client(&state, worker, 0);
1313            c.id = ClientId::from("C");
1314            let clients = vec![a, b, c]
1315                .into_iter()
1316                .map(|c| LiveExecutionClient::new(Box::new(c)))
1317                .collect::<Vec<_>>();
1318            let mut query = query();
1319            query.responsible_clients = IndexSet::from([
1320                ClientId::from("MISSING"),
1321                ClientId::from("A"),
1322                ClientId::from("B"),
1323                ClientId::from("C"),
1324            ]);
1325            let command_id = query.command.command_id;
1326            let results = Rc::new(RefCell::new(Vec::new()));
1327            let start = dst::time::Instant::now();
1328            assert!(
1329                collect_targeted_order_reports(
1330                    vec![query.clone(), query],
1331                    clients,
1332                    Duration::from_millis(10),
1333                    start + Duration::from_secs(1),
1334                    results.clone()
1335                )
1336                .await
1337            );
1338
1339            let events = state
1340                .events
1341                .borrow()
1342                .iter()
1343                .map(|(kind, id, ts)| {
1344                    let elapsed = ts.duration_since(start);
1345                    // Simulated worker wakeups add nanoseconds to millisecond pacing
1346                    #[cfg(all(feature = "simulation", madsim))]
1347                    let elapsed = Duration::from_millis(elapsed.as_millis().try_into().unwrap());
1348                    (*kind, *id, elapsed)
1349                })
1350                .collect::<Vec<_>>();
1351
1352            assert_eq!(
1353                events,
1354                vec![
1355                    ("status", ClientId::from("A"), Duration::ZERO),
1356                    ("status", ClientId::from("B"), Duration::from_millis(10)),
1357                    ("fills", ClientId::from("B"), Duration::from_millis(10)),
1358                    ("status", ClientId::from("A"), Duration::from_millis(20)),
1359                    ("status", ClientId::from("B"), Duration::from_millis(30)),
1360                    ("fills", ClientId::from("B"), Duration::from_millis(30))
1361                ]
1362            );
1363            let results = results.borrow();
1364            assert_eq!(results.len(), 2);
1365
1366            for result in results.iter() {
1367                assert_eq!(result.client_order_id, ClientOrderId::from("O-TARGETED"));
1368                assert_eq!(result.client_id, Some(ClientId::from("B")));
1369                assert_eq!(result.report, Some(report()));
1370                assert!(!result.coverage_complete);
1371            }
1372
1373            assert_eq!(results[0].fills, vec![fill()]);
1374            assert_eq!(results[1].fills, vec![fill()]);
1375
1376            for cmd in state.commands.borrow().iter() {
1377                assert_eq!(cmd.correlation_id, Some(command_id));
1378                assert_eq!(cmd.ts_init, UnixNanos::from(123));
1379                assert_eq!(cmd.instrument_id, Some(fill().instrument_id));
1380                assert_eq!(cmd.venue_order_id, Some(fill().venue_order_id));
1381                assert_eq!(cmd.start, None);
1382                assert_eq!(cmd.end, None);
1383                assert_eq!(cmd.params, None);
1384                assert_eq!(cmd.causation_id, None);
1385                assert_eq!(cmd.log_receipt_level, LogLevel::Debug);
1386            }
1387        }
1388
1389        #[rstest]
1390        #[case::before(-1)]
1391        #[case::exact(0)]
1392        #[case::after(1)]
1393        #[cfg_attr(
1394            not(all(feature = "simulation", madsim)),
1395            tokio::test(start_paused = true)
1396        )]
1397        #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1398        async fn test_targeted_collection_strict_stage_deadline(#[case] offset: i64) {
1399            let state = CollectionState::default();
1400            let c = LiveExecutionClient::new(Box::new(client(&state, true, 0)));
1401            let deadline = dst::time::Instant::now() + Duration::from_secs(1);
1402
1403            let receipt = c
1404                .collect_order_status_report(&query().command)
1405                .await
1406                .unwrap();
1407
1408            let boundary = if offset < 0 {
1409                deadline.checked_sub(Duration::from_nanos(1)).unwrap()
1410            } else {
1411                deadline + Duration::from_nanos(offset.unsigned_abs())
1412            };
1413
1414            advance(boundary.saturating_duration_since(dst::time::Instant::now())).await;
1415            assert_eq!(dst::time::Instant::now(), boundary);
1416            assert_eq!(
1417                accept_collected_report(Ok(receipt), deadline).map(|result| result.unwrap()),
1418                (offset < 0).then(|| Some(report()))
1419            );
1420            assert_eq!(state.status_finished.get(), usize::from(offset < 0));
1421            assert_eq!(state.fill_finished.get(), 0);
1422        }
1423
1424        #[rstest]
1425        #[case::account("account")]
1426        #[case::instrument("instrument")]
1427        #[case::venue_order("venue_order")]
1428        #[case::side("side")]
1429        #[cfg_attr(
1430            not(all(feature = "simulation", madsim)),
1431            tokio::test(start_paused = true)
1432        )]
1433        #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1434        async fn test_targeted_collection_filters_fill_scope(#[case] field: &str) {
1435            let state = CollectionState::default();
1436            let mut c = client(&state, true, 0);
1437            let mut other = fill();
1438            match field {
1439                "account" => other.account_id = AccountId::from("OTHER-002"),
1440                "instrument" => other.instrument_id = InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1441                "venue_order" => other.venue_order_id = VenueOrderId::from("V-OTHER"),
1442                "side" => other.order_side = OrderSide::Sell,
1443                _ => unreachable!(),
1444            }
1445
1446            c.fills.push(other);
1447            let results = Rc::new(RefCell::new(Vec::new()));
1448            assert!(
1449                collect_targeted_order_reports(
1450                    vec![query()],
1451                    vec![LiveExecutionClient::new(Box::new(c))],
1452                    Duration::ZERO,
1453                    dst::time::Instant::now() + Duration::from_secs(1),
1454                    results.clone()
1455                )
1456                .await
1457            );
1458            assert_eq!(results.borrow()[0].fills, vec![fill()]);
1459        }
1460
1461        #[rstest]
1462        #[case::status(1)]
1463        #[case::fills(2)]
1464        #[cfg_attr(
1465            not(all(feature = "simulation", madsim)),
1466            tokio::test(start_paused = true)
1467        )]
1468        #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1469        async fn test_targeted_collection_preserves_original_budget(#[case] release_at: usize) {
1470            let state = CollectionState::default();
1471            let c = client(&state, true, release_at);
1472            let release = c.release.clone();
1473            let clients = vec![LiveExecutionClient::new(Box::new(c))];
1474            let results = Rc::new(RefCell::new(Vec::new()));
1475            let deadline = dst::time::Instant::now() + Duration::from_millis(100);
1476            let mut request = Box::pin(collect_targeted_order_reports(
1477                vec![query()],
1478                clients,
1479                Duration::ZERO,
1480                deadline,
1481                results.clone(),
1482            ));
1483            advance(Duration::from_millis(60)).await;
1484
1485            while state.started.load(Ordering::SeqCst) < release_at {
1486                assert!(request.as_mut().now_or_never().is_none());
1487                tokio::task::yield_now().await;
1488            }
1489
1490            advance(Duration::from_millis(40)).await;
1491            release.notify_one();
1492            assert!(!request.await);
1493            assert_eq!(state.status_finished.get(), usize::from(release_at == 2));
1494            assert_eq!(state.fill_finished.get(), 0);
1495            let results = results.borrow();
1496            assert_eq!(results.len(), usize::from(release_at == 2));
1497
1498            if release_at == 2 {
1499                assert_eq!(results[0].report, Some(report()));
1500                assert!(results[0].fills.is_empty());
1501            }
1502        }
1503    }
1504
1505    #[rstest]
1506    #[tokio::test]
1507    async fn test_targeted_reports_missing_client_marks_coverage_incomplete(
1508        #[values(false, true)] missing_first: bool,
1509    ) {
1510        let client = CommissionStubClient::new(CommissionOutcome::NoOverride);
1511        let missing_id = ClientId::from("MISSING");
1512        let mut responsible_clients = vec![client.client_id(), missing_id];
1513
1514        if missing_first {
1515            responsible_clients.reverse();
1516        }
1517
1518        let client_order_id = ClientOrderId::from("O-MISSING-CLIENT");
1519
1520        let query = TargetedOrderQuery {
1521            client_order_id,
1522            responsible_clients: responsible_clients.into_iter().collect(),
1523            command: GenerateOrderStatusReport::new(
1524                UUID4::new(),
1525                UnixNanos::from(123),
1526                Some(InstrumentId::from("ETHUSDT-PERP.BINANCE")),
1527                Some(client_order_id),
1528                Some(VenueOrderId::from("V-MISSING-CLIENT")),
1529                None,
1530                None,
1531            ),
1532            report: None,
1533            filled_qty: Quantity::from("0.0"),
1534        };
1535
1536        let results = request_targeted_order_reports(vec![query], &[&client], Duration::ZERO).await;
1537
1538        assert_eq!(results.len(), 1);
1539        let result = &results[0];
1540        assert_eq!(result.client_order_id, client_order_id);
1541        assert_eq!(result.client_id, None);
1542        assert_eq!(result.report, None);
1543        assert!(result.fills.is_empty());
1544        assert!(!result.coverage_complete);
1545    }
1546
1547    /// Configured result of a stub commission calculation.
1548    #[derive(Clone)]
1549    pub(crate) enum CommissionOutcome {
1550        Value(Money),
1551        NoOverride,
1552        Failure,
1553    }
1554
1555    /// Execution client that records commission inputs and returns a configured result.
1556    pub(crate) struct CommissionStubClient {
1557        outcome: CommissionOutcome,
1558        seen: RefCell<Option<(Quantity, Price, LiquiditySide)>>,
1559    }
1560
1561    impl CommissionStubClient {
1562        /// Creates a client with the specified commission result.
1563        pub(crate) fn new(outcome: CommissionOutcome) -> Self {
1564            Self {
1565                outcome,
1566                seen: RefCell::new(None),
1567            }
1568        }
1569
1570        /// Returns the last recorded commission inputs.
1571        pub(crate) fn seen(&self) -> Option<(Quantity, Price, LiquiditySide)> {
1572            *self.seen.borrow()
1573        }
1574
1575        /// Clears the recorded commission inputs.
1576        pub(crate) fn clear_seen(&self) {
1577            *self.seen.borrow_mut() = None;
1578        }
1579    }
1580
1581    #[async_trait::async_trait(?Send)]
1582    impl ExecutionClient for CommissionStubClient {
1583        fn is_connected(&self) -> bool {
1584            true
1585        }
1586
1587        fn client_id(&self) -> ClientId {
1588            ClientId::from("STUB")
1589        }
1590
1591        fn account_id(&self) -> AccountId {
1592            AccountId::from("STUB-001")
1593        }
1594
1595        fn venue(&self) -> Venue {
1596            Venue::from("STUB")
1597        }
1598
1599        fn oms_type(&self) -> OmsType {
1600            OmsType::Netting
1601        }
1602
1603        fn get_account(&self) -> Option<AccountAny> {
1604            None
1605        }
1606
1607        fn generate_account_state(
1608            &self,
1609            _balances: Vec<AccountBalance>,
1610            _margins: Vec<MarginBalance>,
1611            _reported: bool,
1612            _ts_event: UnixNanos,
1613            _info: Option<Params>,
1614        ) -> anyhow::Result<()> {
1615            Ok(())
1616        }
1617
1618        fn start(&mut self) -> anyhow::Result<()> {
1619            Ok(())
1620        }
1621
1622        fn stop(&mut self) -> anyhow::Result<()> {
1623            Ok(())
1624        }
1625
1626        fn calculate_commission(
1627            &self,
1628            _instrument: &InstrumentAny,
1629            last_qty: Quantity,
1630            last_px: Price,
1631            liquidity_side: LiquiditySide,
1632        ) -> anyhow::Result<Option<Money>> {
1633            *self.seen.borrow_mut() = Some((last_qty, last_px, liquidity_side));
1634
1635            match &self.outcome {
1636                CommissionOutcome::Value(money) => Ok(Some(*money)),
1637                CommissionOutcome::NoOverride => Ok(None),
1638                CommissionOutcome::Failure => {
1639                    anyhow::bail!("commission is not representable as Money")
1640                }
1641            }
1642        }
1643    }
1644
1645    fn commission_fixtures() -> (OrderAny, OrderStatusReport, InstrumentAny) {
1646        let instrument = crypto_perpetual_ethusdt();
1647        let order = OrderTestBuilder::new(OrderType::Limit)
1648            .instrument_id(instrument.id())
1649            .side(OrderSide::Buy)
1650            .quantity(Quantity::from("10.0"))
1651            .price(Price::from("100.00"))
1652            .build();
1653        let report = OrderStatusReport::new(
1654            AccountId::from("STUB-001"),
1655            instrument.id(),
1656            Some(order.client_order_id()),
1657            VenueOrderId::from("V-1"),
1658            OrderSide::Buy.into(),
1659            OrderType::Limit,
1660            TimeInForce::Gtc,
1661            OrderStatus::Filled,
1662            Quantity::from("10.0"),
1663            Quantity::from("10.0"),
1664            UnixNanos::from(1),
1665            UnixNanos::from(1),
1666            UnixNanos::from(1),
1667            None,
1668        )
1669        .with_avg_px(dec!(100.0));
1670
1671        (order, report, InstrumentAny::CryptoPerpetual(instrument))
1672    }
1673
1674    #[rstest]
1675    fn test_create_orphan_fill_order_report_preserves_aggregate_fields() {
1676        let (instrument, fills) = orphan_fill_fixtures();
1677        let reports = fills.iter().collect::<Vec<_>>();
1678
1679        let report = create_orphan_fill_order_report(&reports, &instrument).unwrap();
1680        let expected = OrderStatusReport::new(
1681            AccountId::from("STUB-001"),
1682            instrument.id(),
1683            Some(ClientOrderId::from("O-ORPHAN")),
1684            VenueOrderId::from("V-ORPHAN"),
1685            OrderSide::Buy.into(),
1686            OrderType::Market,
1687            TimeInForce::Gtc,
1688            OrderStatus::Filled,
1689            Quantity::from("4.000"),
1690            Quantity::from("4.000"),
1691            UnixNanos::from(10),
1692            UnixNanos::from(30),
1693            UnixNanos::from(50),
1694            Some(report.report_id),
1695        )
1696        .with_avg_px(dec!(115))
1697        .with_venue_position_id(PositionId::from("P-ORPHAN"));
1698
1699        assert_eq!(report, expected);
1700    }
1701
1702    #[rstest]
1703    #[case::empty("empty", "fill group is empty")]
1704    #[case::missing_position("missing_position", "venue position ID is missing")]
1705    #[case::account("account", "account ID differs across fill group")]
1706    #[case::instrument("instrument", "instrument ID differs across fill group")]
1707    #[case::venue_order("venue_order", "venue order ID differs across fill group")]
1708    #[case::client_order("client_order", "client order ID differs across fill group")]
1709    #[case::side("side", "order side differs across fill group")]
1710    #[case::position("position", "venue position ID differs across fill group")]
1711    #[case::metadata("metadata", "instrument metadata does not match fill group")]
1712    #[case::zero("zero", "fill group quantity is not positive")]
1713    fn test_create_orphan_fill_order_report_rejects_invalid_group(
1714        #[case] invalid_field: &str,
1715        #[case] expected: &str,
1716    ) {
1717        let (instrument, mut fills) = orphan_fill_fixtures();
1718
1719        match invalid_field {
1720            "empty" => fills.clear(),
1721            "missing_position" => fills[0].venue_position_id = None,
1722            "account" => fills[1].account_id = AccountId::from("OTHER-002"),
1723            "instrument" => fills[1].instrument_id = InstrumentId::from("OTHER.TEST"),
1724            "venue_order" => fills[1].venue_order_id = VenueOrderId::from("V-OTHER"),
1725            "client_order" => fills[1].client_order_id = Some(ClientOrderId::from("O-OTHER")),
1726            "side" => fills[1].order_side = OrderSide::Sell,
1727            "position" => fills[1].venue_position_id = Some(PositionId::from("P-OTHER")),
1728            "metadata" => {
1729                for fill in &mut fills {
1730                    fill.instrument_id = InstrumentId::from("OTHER.TEST");
1731                }
1732            }
1733            "zero" => {
1734                for fill in &mut fills {
1735                    fill.last_qty = Quantity::zero(3);
1736                }
1737            }
1738            _ => unreachable!(),
1739        }
1740
1741        let reports = fills.iter().collect::<Vec<_>>();
1742
1743        let error = create_orphan_fill_order_report(&reports, &instrument).unwrap_err();
1744
1745        assert_eq!(error.to_string(), expected);
1746    }
1747
1748    #[rstest]
1749    fn test_create_orphan_fill_order_report_rejects_aggregate_quantity_overflow() {
1750        let (instrument, mut fills) = orphan_fill_fixtures();
1751        let max_qty = Quantity::new(QUANTITY_MAX, 0);
1752
1753        for fill in &mut fills {
1754            fill.last_qty = max_qty;
1755            fill.last_px = Price::from("1.00");
1756        }
1757
1758        let expected =
1759            Quantity::from_decimal_dp(max_qty.as_decimal() * dec!(2), instrument.size_precision())
1760                .unwrap_err();
1761        let reports = fills.iter().collect::<Vec<_>>();
1762
1763        let error = create_orphan_fill_order_report(&reports, &instrument).unwrap_err();
1764
1765        assert_eq!(error.to_string(), expected.to_string());
1766    }
1767
1768    fn orphan_fill_fixtures() -> (InstrumentAny, Vec<FillReport>) {
1769        let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
1770
1771        let fills = [("1.000", "100.00", 30, 40), ("3.000", "120.00", 10, 50)]
1772            .into_iter()
1773            .enumerate()
1774            .map(|(index, (qty, px, ts_event, ts_init))| {
1775                FillReport::new(
1776                    AccountId::from("STUB-001"),
1777                    instrument.id(),
1778                    VenueOrderId::from("V-ORPHAN"),
1779                    TradeId::new(format!("T-{index}")),
1780                    OrderSide::Buy,
1781                    Quantity::from(qty),
1782                    Price::from(px),
1783                    Money::from("0.10 USDT"),
1784                    LiquiditySide::Taker,
1785                    Some(ClientOrderId::from("O-ORPHAN")),
1786                    Some(PositionId::from("P-ORPHAN")),
1787                    UnixNanos::from(ts_event),
1788                    UnixNanos::from(ts_init),
1789                    None,
1790                )
1791            })
1792            .collect();
1793
1794        (instrument, fills)
1795    }
1796
1797    #[rstest]
1798    #[case::venue_format("T-VENUE-1")]
1799    #[case::uuid_v5_format("2d89666b-1a1e-5a75-b193-4eb3b454c757")]
1800    fn test_has_active_inferred_fill_ignores_venue_trade_ids(#[case] trade_id: &str) {
1801        let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
1802        let mut order = OrderTestBuilder::new(OrderType::Market)
1803            .instrument_id(instrument.id())
1804            .side(OrderSide::Buy)
1805            .quantity(Quantity::from("1.000"))
1806            .build();
1807        let submitted = TestOrderEventStubs::submitted(&order, AccountId::from("TEST-001"));
1808        order.apply(submitted).unwrap();
1809
1810        let trade_id = TradeId::from(trade_id);
1811
1812        let OrderEventAny::Filled(mut fill) = TestOrderEventStubs::filled(
1813            &order,
1814            &instrument,
1815            Some(trade_id),
1816            None,
1817            Some(Price::from("100.00")),
1818            Some(Quantity::from("1.000")),
1819            None,
1820            None,
1821            None,
1822            None,
1823        ) else {
1824            unreachable!();
1825        };
1826
1827        fill.reconciliation = true;
1828        order.apply(OrderEventAny::Filled(fill)).unwrap();
1829
1830        assert!(order.trade_ids().contains(&&trade_id));
1831        assert!(!has_active_inferred_fill(&order).unwrap());
1832    }
1833
1834    #[rstest]
1835    fn test_resolve_inferred_fill_commission_without_client_fails_closed() {
1836        let (order, report, instrument) = commission_fixtures();
1837
1838        let price_and_liquidity = inferred_fill_price_and_liquidity(&order, &report, &instrument);
1839
1840        let error = resolve_inferred_fill_commission(
1841            Quantity::from("5.0"),
1842            price_and_liquidity,
1843            &instrument,
1844            None,
1845        )
1846        .expect_err("a missing responsible client must defer the fill");
1847
1848        assert_eq!(
1849            error.to_string(),
1850            "responsible execution client is unavailable"
1851        );
1852    }
1853
1854    #[rstest]
1855    fn test_resolve_inferred_fill_commission_without_price_uses_generic_path() {
1856        let instrument = crypto_perpetual_ethusdt();
1857        let order = OrderTestBuilder::new(OrderType::Market)
1858            .instrument_id(instrument.id())
1859            .side(OrderSide::Buy)
1860            .quantity(Quantity::from("10.0"))
1861            .build();
1862
1863        let report = OrderStatusReport::new(
1864            AccountId::from("STUB-001"),
1865            instrument.id(),
1866            Some(order.client_order_id()),
1867            VenueOrderId::from("V-1"),
1868            OrderSide::Buy.into(),
1869            OrderType::Market,
1870            TimeInForce::Gtc,
1871            OrderStatus::Filled,
1872            Quantity::from("10.0"),
1873            Quantity::from("10.0"),
1874            UnixNanos::from(1),
1875            UnixNanos::from(1),
1876            UnixNanos::from(1),
1877            None,
1878        );
1879        let client =
1880            CommissionStubClient::new(CommissionOutcome::Value(Money::new(1.0, Currency::USDT())));
1881        let instrument = InstrumentAny::CryptoPerpetual(instrument);
1882
1883        let price_and_liquidity = inferred_fill_price_and_liquidity(&order, &report, &instrument);
1884
1885        let commission = resolve_inferred_fill_commission(
1886            Quantity::from("5.0"),
1887            price_and_liquidity,
1888            &instrument,
1889            Some(&client),
1890        )
1891        .expect("an unresolvable price is not a failure");
1892
1893        assert_eq!(commission, None, "no price means no venue commission");
1894    }
1895
1896    #[rstest]
1897    fn test_resolve_inferred_fill_commission_returns_venue_value() {
1898        let (order, report, instrument) = commission_fixtures();
1899        let expected = Money::new(2.5, Currency::USDT());
1900        let client = CommissionStubClient::new(CommissionOutcome::Value(expected));
1901
1902        let price_and_liquidity = inferred_fill_price_and_liquidity(&order, &report, &instrument);
1903
1904        let commission = resolve_inferred_fill_commission(
1905            Quantity::from("5.0"),
1906            price_and_liquidity,
1907            &instrument,
1908            Some(&client),
1909        )
1910        .expect("a representable commission succeeds");
1911
1912        assert_eq!(commission, Some(expected));
1913        assert_eq!(
1914            client.seen(),
1915            Some((
1916                Quantity::from("5.0"),
1917                Price::from("100.00"),
1918                LiquiditySide::NoLiquiditySide,
1919            )),
1920            "the resolver passes the inferred fill quantity, resolved price, and liquidity side"
1921        );
1922    }
1923
1924    #[rstest]
1925    fn test_resolve_inferred_fill_commission_honors_no_override() {
1926        let (order, report, instrument) = commission_fixtures();
1927        let client = CommissionStubClient::new(CommissionOutcome::NoOverride);
1928
1929        let price_and_liquidity = inferred_fill_price_and_liquidity(&order, &report, &instrument);
1930
1931        let commission = resolve_inferred_fill_commission(
1932            Quantity::from("5.0"),
1933            price_and_liquidity,
1934            &instrument,
1935            Some(&client),
1936        )
1937        .expect("no override is not a failure");
1938
1939        assert_eq!(commission, None);
1940    }
1941
1942    #[rstest]
1943    fn test_resolve_inferred_fill_commission_propagates_failure() {
1944        let (order, report, instrument) = commission_fixtures();
1945        let client = CommissionStubClient::new(CommissionOutcome::Failure);
1946
1947        let price_and_liquidity = inferred_fill_price_and_liquidity(&order, &report, &instrument);
1948
1949        let result = resolve_inferred_fill_commission(
1950            Quantity::from("5.0"),
1951            price_and_liquidity,
1952            &instrument,
1953            Some(&client),
1954        );
1955
1956        assert_eq!(
1957            result.unwrap_err().to_string(),
1958            "commission is not representable as Money"
1959        );
1960    }
1961
1962    #[rstest]
1963    #[case::account(true, false, true, true, "resolved")]
1964    #[case::account_unavailable(true, false, false, true, "unavailable")]
1965    #[case::venue(false, true, true, true, "resolved")]
1966    #[case::venue_unavailable(false, true, false, true, "unavailable")]
1967    #[case::unmatched(false, false, true, true, "unresolved")]
1968    #[case::no_clients(false, false, true, false, "unresolved")]
1969    fn test_resolve_position_report_client_coverage(
1970        #[case] account_matches: bool,
1971        #[case] venue_matches: bool,
1972        #[case] available: bool,
1973        #[case] include_client: bool,
1974        #[case] expected: &str,
1975    ) {
1976        let key = (InstrumentId::from("ETH.TEST"), AccountId::from("TEST-001"));
1977
1978        let client = CoverageStubClient {
1979            id: ClientId::from("COVERAGE"),
1980            account_id: if account_matches {
1981                key.1
1982            } else {
1983                AccountId::from("OTHER-001")
1984            },
1985            venue: if venue_matches {
1986                key.0.venue
1987            } else {
1988                Venue::from("OTHER")
1989            },
1990            available,
1991        };
1992
1993        let clients: Vec<&dyn ExecutionClient> = if include_client {
1994            vec![&client]
1995        } else {
1996            vec![]
1997        };
1998
1999        let expected = match expected {
2000            "resolved" => ReportClientCoverage::Resolved(IndexSet::from([client.id])),
2001            "unavailable" => ReportClientCoverage::Unavailable(IndexSet::from([client.id])),
2002            "unresolved" => ReportClientCoverage::Unresolved,
2003            _ => unreachable!(),
2004        };
2005
2006        let result = resolve_position_report_client_coverage(key, &clients);
2007
2008        assert_eq!(result, expected);
2009    }
2010
2011    #[rstest]
2012    #[case::account_available(true, false)]
2013    #[case::account_unavailable(false, true)]
2014    fn test_position_coverage_prefers_account_clients(
2015        #[case] account_available: bool,
2016        #[case] venue_available: bool,
2017    ) {
2018        let key = (InstrumentId::from("ETH.TEST"), AccountId::from("TEST-001"));
2019
2020        let account_client = CoverageStubClient {
2021            id: ClientId::from("ACCOUNT"),
2022            account_id: key.1,
2023            venue: Venue::from("OTHER"),
2024            available: account_available,
2025        };
2026
2027        let venue_client = CoverageStubClient {
2028            id: ClientId::from("VENUE"),
2029            account_id: AccountId::from("OTHER-001"),
2030            venue: key.0.venue,
2031            available: venue_available,
2032        };
2033
2034        let expected_clients = IndexSet::from([account_client.id]);
2035
2036        let expected = if account_available {
2037            ReportClientCoverage::Resolved(expected_clients)
2038        } else {
2039            ReportClientCoverage::Unavailable(expected_clients)
2040        };
2041
2042        let result =
2043            resolve_position_report_client_coverage(key, &[&venue_client, &account_client]);
2044
2045        assert_eq!(result, expected);
2046    }
2047
2048    struct CoverageStubClient {
2049        id: ClientId,
2050        account_id: AccountId,
2051        venue: Venue,
2052        available: bool,
2053    }
2054
2055    #[async_trait::async_trait(?Send)]
2056    impl ExecutionClient for CoverageStubClient {
2057        fn is_connected(&self) -> bool {
2058            true
2059        }
2060
2061        fn client_id(&self) -> ClientId {
2062            self.id
2063        }
2064
2065        fn account_id(&self) -> AccountId {
2066            self.account_id
2067        }
2068
2069        fn venue(&self) -> Venue {
2070            self.venue
2071        }
2072
2073        fn oms_type(&self) -> OmsType {
2074            OmsType::Netting
2075        }
2076
2077        fn get_account(&self) -> Option<AccountAny> {
2078            None
2079        }
2080
2081        fn provides_bulk_position_coverage(&self, _instrument_id: InstrumentId) -> bool {
2082            self.available
2083        }
2084
2085        fn generate_account_state(
2086            &self,
2087            _balances: Vec<AccountBalance>,
2088            _margins: Vec<MarginBalance>,
2089            _reported: bool,
2090            _ts_event: UnixNanos,
2091            _info: Option<Params>,
2092        ) -> anyhow::Result<()> {
2093            Ok(())
2094        }
2095
2096        fn start(&mut self) -> anyhow::Result<()> {
2097            Ok(())
2098        }
2099
2100        fn stop(&mut self) -> anyhow::Result<()> {
2101            Ok(())
2102        }
2103    }
2104
2105    #[rstest]
2106    #[case::empty(vec![], None)]
2107    #[case::flat(vec![(0, dec!(100))], None)]
2108    #[case::weighted(vec![(1, dec!(100)), (3, dec!(120))], Some(dec!(115)))]
2109    #[case::short(vec![(-1, dec!(100)), (-3, dec!(120))], Some(dec!(115)))]
2110    #[case::ignored(vec![(1, dec!(0)), (2, dec!(-10)), (3, dec!(120))], Some(dec!(120)))]
2111    fn test_position_avg_px(
2112        #[case] entries: Vec<(i32, Decimal)>,
2113        #[case] expected: Option<Decimal>,
2114    ) {
2115        let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
2116        let order = OrderTestBuilder::new(OrderType::Market)
2117            .instrument_id(instrument.id())
2118            .side(OrderSide::Buy)
2119            .quantity(Quantity::from("1.000"))
2120            .build();
2121        let fill = TestOrderEventStubs::filled(
2122            &order,
2123            &instrument,
2124            None,
2125            Some(PositionId::from("P-AVG")),
2126            Some(Price::from("100.00")),
2127            Some(Quantity::from("1.000")),
2128            None,
2129            None,
2130            None,
2131            None,
2132        );
2133        let position = Position::new(&instrument, fill.into());
2134
2135        let positions = entries
2136            .into_iter()
2137            .map(|(qty, px)| {
2138                let mut position = position.clone();
2139                position.signed_qty = Decimal::from(qty);
2140                position.quantity = Quantity::from_decimal_dp(Decimal::from(qty.abs()), 3).unwrap();
2141                position.avg_px_open = px;
2142                position
2143            })
2144            .collect::<Vec<_>>();
2145
2146        let result = position_avg_px(&positions);
2147
2148        assert_eq!(result, expected);
2149    }
2150
2151    #[rstest]
2152    #[case::empty(vec![], (dec!(0), dec!(0), dec!(0)))]
2153    #[case::zero(vec![0, 0], (dec!(0), dec!(0), dec!(0)))]
2154    #[case::long(vec![2, 3], (dec!(5), dec!(5), dec!(0)))]
2155    #[case::short(vec![-2, -3], (dec!(-5), dec!(0), dec!(5)))]
2156    #[case::mixed(vec![2, -5, 1], (dec!(-2), dec!(3), dec!(5)))]
2157    #[case::offset(vec![3, -3], (dec!(0), dec!(3), dec!(3)))]
2158    fn test_position_qty_aggregates(
2159        #[case] quantities: Vec<i32>,
2160        #[case] expected: (Decimal, Decimal, Decimal),
2161    ) {
2162        let result = position_qty_aggregates(quantities.into_iter().map(Decimal::from));
2163
2164        assert_eq!(result, expected);
2165    }
2166
2167    proptest! {
2168        #[rstest]
2169        fn prop_position_quantities_match_tolerance_and_side_exposure(
2170            long in 100i64..1_000_000,
2171            short in 100i64..1_000_000,
2172            long_delta in -10i64..=10,
2173            short_delta in -10i64..=10,
2174            tolerance in 0i64..=20,
2175            scale in 0u32..=6,
2176            side_reports in any::<bool>(),
2177        ) {
2178            let venue_net = long + long_delta - short - short_delta;
2179            let (venue_long, venue_short) = if side_reports {
2180                (long + long_delta, short + short_delta)
2181            } else {
2182                (venue_net.max(0), (-venue_net).max(0))
2183            };
2184            let comparison = PositionQuantityComparison {
2185                cached_positions: Vec::new(),
2186                cached_signed_qty: Decimal::new(long - short, scale),
2187                cached_long_qty: Decimal::new(long, scale),
2188                cached_short_qty: Decimal::new(short, scale),
2189                venue_signed_qty: Decimal::new(venue_net, scale),
2190                venue_long_qty: Decimal::new(venue_long, scale),
2191                venue_short_qty: Decimal::new(venue_short, scale),
2192                nonflat_count: if side_reports { 2 } else { 1 },
2193                venue_report: None,
2194                venue_has_side_reports: side_reports,
2195            };
2196            let mut largest_error = (long_delta - short_delta).abs();
2197
2198            if side_reports {
2199                largest_error = largest_error.max(long_delta.abs()).max(short_delta.abs());
2200            }
2201
2202            prop_assert_eq!(
2203                comparison.quantities_match(Decimal::new(tolerance, scale)),
2204                largest_error <= tolerance,
2205            );
2206            prop_assert!(comparison.quantities_match(Decimal::new(largest_error, scale)));
2207
2208            if largest_error > 0 {
2209                prop_assert!(!comparison.quantities_match(Decimal::new(largest_error - 1, scale)));
2210            }
2211        }
2212    }
2213
2214    proptest! {
2215        #[rstest]
2216        fn prop_position_qty_aggregates_preserves_sign_and_net(
2217            values in proptest::collection::vec(-1_000_000i64..=1_000_000, 0..32),
2218        ) {
2219            let quantities = values.iter().map(|v| Decimal::new(*v, 3)).collect::<Vec<_>>();
2220            let (net, long, short) = position_qty_aggregates(quantities.iter().copied());
2221            let reversed = position_qty_aggregates(quantities.iter().map(|v| -*v));
2222            let expected_net = Decimal::new(values.iter().sum(), 3);
2223
2224            prop_assert_eq!(net, expected_net);
2225            prop_assert_eq!(net, long - short);
2226            prop_assert!(long >= Decimal::ZERO);
2227            prop_assert!(short >= Decimal::ZERO);
2228            prop_assert_eq!(reversed, (-net, short, long));
2229        }
2230    }
2231
2232    #[rstest]
2233    fn test_distinct_position_reports_removes_non_adjacent_duplicates() {
2234        let report = |side: PositionSide, quantity: &str, venue_position_id: &str, ts_init: u64| {
2235            PositionStatusReport::new(
2236                AccountId::from("BINANCE-001"),
2237                InstrumentId::from("ETHUSDT-PERP.BINANCE"),
2238                side,
2239                Quantity::from(quantity),
2240                UnixNanos::from(1_000),
2241                UnixNanos::from(ts_init),
2242                None,
2243                Some(PositionId::from(venue_position_id)),
2244                Some(dec!(3000)),
2245            )
2246        };
2247
2248        let long = report(PositionSide::Long, "2.0", "P-LONG", 2_000);
2249        let short = report(PositionSide::Short, "3.0", "P-SHORT", 2_001);
2250        let long_duplicate = report(PositionSide::Long, "2.0", "P-LONG", 2_002);
2251        let short_duplicate = report(PositionSide::Short, "3.0", "P-SHORT", 2_003);
2252
2253        let result = distinct_position_reports(vec![
2254            long.clone(),
2255            short.clone(),
2256            long_duplicate,
2257            short_duplicate,
2258        ]);
2259
2260        assert_eq!(result, vec![long, short]);
2261    }
2262
2263    #[rstest]
2264    fn test_sort_reconciliation_events_accepts_before_earlier_fills() {
2265        let first = market_order("O-FIRST", OrderSide::Buy);
2266        let second = market_order("O-SECOND", OrderSide::Sell);
2267        let mut events = vec![
2268            accepted_at(&second, "V-SECOND", 900),
2269            filled_at(&second, "V-SECOND", "T-SECOND", 200),
2270            accepted_at(&first, "V-FIRST", 900),
2271            filled_at(&first, "V-FIRST", "T-FIRST", 100),
2272            canceled_at(&first, "V-FIRST", 950),
2273        ];
2274
2275        sort_reconciliation_events(&mut events);
2276
2277        assert_eq!(
2278            event_sequence(&events),
2279            vec![
2280                "O-FIRST V-FIRST accepted",
2281                "O-FIRST V-FIRST filled",
2282                "O-SECOND V-SECOND accepted",
2283                "O-SECOND V-SECOND filled",
2284                "O-FIRST V-FIRST canceled",
2285            ]
2286        );
2287    }
2288
2289    #[rstest]
2290    fn test_sort_reconciliation_events_keeps_other_venue_order_fill_times() {
2291        let order = market_order("O-REPLACED", OrderSide::Buy);
2292        let mut events = vec![
2293            accepted_at(&order, "V-NEW", 900),
2294            filled_at(&order, "V-NEW", "T-NEW", 950),
2295            filled_at(&order, "V-OLD", "T-OLD", 100),
2296        ];
2297
2298        sort_reconciliation_events(&mut events);
2299
2300        assert_eq!(
2301            event_sequence(&events),
2302            vec![
2303                "O-REPLACED V-OLD filled",
2304                "O-REPLACED V-NEW accepted",
2305                "O-REPLACED V-NEW filled",
2306            ]
2307        );
2308    }
2309
2310    #[rstest]
2311    fn test_sort_reconciliation_events_keeps_fills_after_earlier_terminal_time() {
2312        let canceled_order = market_order("O-CANCELED", OrderSide::Buy);
2313        let filled_order = market_order("O-FILLED", OrderSide::Sell);
2314        let mut events = vec![
2315            accepted_at(&canceled_order, "V-CANCELED", 100),
2316            filled_at(&canceled_order, "V-CANCELED", "T-CANCELED", 500),
2317            canceled_at(&canceled_order, "V-CANCELED", 100),
2318            accepted_at(&filled_order, "V-FILLED", 200),
2319            filled_at(&filled_order, "V-FILLED", "T-FILLED", 300),
2320        ];
2321
2322        sort_reconciliation_events(&mut events);
2323
2324        assert_eq!(
2325            event_sequence(&events),
2326            vec![
2327                "O-CANCELED V-CANCELED accepted",
2328                "O-CANCELED V-CANCELED canceled",
2329                "O-FILLED V-FILLED accepted",
2330                "O-FILLED V-FILLED filled",
2331                "O-CANCELED V-CANCELED filled",
2332            ]
2333        );
2334    }
2335
2336    fn market_order(client_order_id: &str, side: OrderSide) -> OrderAny {
2337        OrderTestBuilder::new(OrderType::Market)
2338            .instrument_id(crypto_perpetual_ethusdt().id())
2339            .client_order_id(ClientOrderId::from(client_order_id))
2340            .side(side)
2341            .quantity(Quantity::from("1.000"))
2342            .build()
2343    }
2344
2345    fn accepted_at(order: &OrderAny, venue_order_id: &str, ts: u64) -> OrderEventAny {
2346        let mut event = TestOrderEventStubs::accepted(
2347            order,
2348            AccountId::from("SIM-001"),
2349            VenueOrderId::from(venue_order_id),
2350        );
2351
2352        if let OrderEventAny::Accepted(accepted) = &mut event {
2353            accepted.ts_event = UnixNanos::from(ts);
2354        }
2355
2356        event
2357    }
2358
2359    fn filled_at(order: &OrderAny, venue_order_id: &str, trade_id: &str, ts: u64) -> OrderEventAny {
2360        let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
2361        let mut event = TestOrderEventStubs::filled(
2362            order,
2363            &instrument,
2364            Some(TradeId::from(trade_id)),
2365            None,
2366            Some(Price::from("100.00")),
2367            Some(Quantity::from("1.000")),
2368            None,
2369            None,
2370            Some(UnixNanos::from(ts)),
2371            Some(AccountId::from("SIM-001")),
2372        );
2373
2374        if let OrderEventAny::Filled(filled) = &mut event {
2375            filled.venue_order_id = VenueOrderId::from(venue_order_id);
2376        }
2377
2378        event
2379    }
2380
2381    fn canceled_at(order: &OrderAny, venue_order_id: &str, ts: u64) -> OrderEventAny {
2382        let mut event = TestOrderEventStubs::canceled(
2383            order,
2384            AccountId::from("SIM-001"),
2385            Some(VenueOrderId::from(venue_order_id)),
2386        );
2387
2388        if let OrderEventAny::Canceled(canceled) = &mut event {
2389            canceled.ts_event = UnixNanos::from(ts);
2390        }
2391
2392        event
2393    }
2394
2395    fn event_sequence(events: &[OrderEventAny]) -> Vec<String> {
2396        events
2397            .iter()
2398            .map(|event| {
2399                let kind = match event {
2400                    OrderEventAny::Accepted(_) => "accepted",
2401                    OrderEventAny::Filled(_) => "filled",
2402                    OrderEventAny::Canceled(_) => "canceled",
2403                    _ => "other",
2404                };
2405                let venue_order_id = event
2406                    .venue_order_id()
2407                    .expect("test events carry a venue order ID");
2408
2409                format!("{} {venue_order_id} {kind}", event.client_order_id())
2410            })
2411            .collect()
2412    }
2413}