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