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