Skip to main content

nautilus_live/execution/
reconciliation.rs

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