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